I tried out Tim Santeford’s solution, but the problem was with the tag results. In his solution, you get all existing tags returned through autocomplete, and not scoped to your models and taggable fields! So, I came up with a solution which is in my opinion much much better; it’s automatically extensible to any model you want to tag, it’s efficient, and above all else it’s very simple. It uses the acts-as-taggable-on gem and the select2 JavaScript library.

Install the Acts-As-Taggable-On gem

  1. Add acts-as-taggable-on to your Gemfile: gem 'acts-as-taggable-on', '~> 3.5'
  2. Run bundle install to install it
  3. Generate the necessary migrations: rake acts_as_taggable_on_engine:install:migrations
  4. Run the migrations with rake db:migrate

Done!

Set up normal tagging in your MVC

Let’s say we have a Film model (because I do). Simply add the following two lines to your model:

class Film < ActiveRecord::Base
    acts_as_taggable
    acts_as_taggable_on :genres
end

That’s it for the model. Now onto the controller. You need to accept the tag lists in your parameters. So I have the following in my FilmsController:

class FilmsController < ApplicationController
    def index
        ...
    end
    ...
    
    private
    
    def films_params
        params[:film].permit(..., :genre_list)
    end
end

Notice that the parameter isn’t genres like we specified in the model. Don’t ask me why, but acts-as-taggable-on expects singular + _list, and this is what is required in the views.

Onto the view layer! I use the SimpleForm gem and the Slim template engine for views, so my form might look a little different than yours if you don’t use the gem. But, it’s just a normal text input field:

= f.input :genre_list, input_html: {value: @film.genre_list.to_s}

You need this input_html attribute with that value set in order to render it as a comma-separated string (which is what acts-as-taggable-on expects in the controller). Tagging should now work when you submit the form! If it doesn’t work, I recommend watching (the amazing) Ryan Bates’ Railscast episode on tagging.

Integrating select2 into your forms

First off, we need to include the select2 library; you can either include it manually, or use (my preference) the select2-rails gem which packages select2 for the Rails asset pipeline.

Add the gem to your Gemfile: gem 'select2-rails', '~> 4.0', then run bundle install.

Include the JavaScript and CSS in your asset pipeline:

In application.js: //= require select2-full. In application.css: *= require select2.

Now you need to modify your forms a bit to include what select2 expects for tagging. This can seem a bit confusing, but I’ll explain everything. Change your previous form input:

= f.input :genre_list, input_html: {value: @film.genre_list.to_s}

to:

= f.hidden_field :genre_list, value: @film.genre_list.to_s
= f.input :genre_list,
    input_html: { id: "genre_list_select2",
                name: "genre_list_select2",
                multiple: true,
                data: { taggable: true, taggable_type: "Film", context: "genres" } },
    collection: @film.genre_list

We add a hidden input which will act as the real value sent to the controller. Select2 returns an array, where acts-as-taggable-on expects a comma-separated string. The select2 form input is converted to that string when its value changes, and set into the hidden field. We’ll get to that soon.

The id and name attributes for the f.input actually don’t matter. They just can’t overlap with your hidden input. The data hash is really important here. The taggable field allows us to use JavaScript to initialize all select2 inputs in one go, instead of manually initializing by id for each one. The taggable_type field is used to filter tags for your particular model, and the context field is to filter on tags that have been used before in that field. Finally, the collection field simply sets the values appropriately in the input.

The next part is JavaScript. We need to initialize all select2 elements throughout the application. To do this, I simply added the following function to my application.js file, so that it works for every route:

// Initialize all acts-as-taggable-on + select2 tag inputs
$("*[data-taggable='true']").each(function() {
    console.log("Taggable: " + $(this).attr('id') + "; initializing select2");
    $(this).select2({
        tags: true,
        theme: "bootstrap",
        width: "100%",
        tokenSeparators: [','],
        minimumInputLength: 2,
        ajax: {
            url: "/tags",
            dataType: 'json',
            delay: 100,
            data: function (params) {
                console.log("Using AJAX to get tags...");
                console.log("Tag name: " + params.term);
                console.log("Existing tags: " + $(this).val());
                console.log("Taggable type: " + $(this).data("taggable-type"));
                console.log("Tag context: " + $(this).data("context"));
                return {
                    name: params.term,
                    tags_chosen: $(this).val(),
                    taggable_type: $(this).data("taggable-type"),
                    context: $(this).data("context"),
                    page: params.page
                }
            },
            processResults: function (data, params) {
                console.log("Got tags from AJAX: " + JSON.stringify(data, null, '\t'));
                params.page = params.page || 1;
        
                return {
                    results: $.map(data, function (item) {
                        return {
                            text: item.name,
                            // id has to be the tag name, because acts_as_taggable expects it!
                            id: item.name
                        }
                    })
                };
            },
            cache: true
        }
    });
});

This may look complex, but it’s not too difficult. Basically, the $("*[data-taggable='true']") selector just gets every HTML element where we have taggable: true set in the data. We just added that to the form, and this is why - we want to be able to initialize select2 for all taggable fields.

The rest is just AJAX-related code. Essentially, we make an AJAX call to /tags with the parameters name, taggable_type and context. Sound familiar? Those are the data attributes that we set in our form input. When the results are returned, we simply give select2 the name of the tag.

Now you’re probably thinking: I dont have a /tags route!. You’re right! But you’re about to :)

Adding the /tags route

Go into your routes.rb file and add the following: resources :tags. You don’t have to add all the routes for tags, but I did so that I could have an easy way to CRUD tags. You could also simply do: get '/tags' => 'tags#index'

That’s really the only route we need at the moment. Now that we have the route, we have to create a controller called TagsController:

class TagsController < ApplicationController
    def index
        @tags = ActsAsTaggableOn::Tag
                .where("name ILIKE ?", "%#{params[:name]}%")
                .where.not(name: params[:tags_chosen])
                .includes(:taggings)
                .where(taggings: {taggable_type: params[:taggable_type]})
        @tags = @tags.where(taggings: {context: params[:context] }) if params[:context]
        @tags.order!(name: :asc)
        render json: @tags
    end
end

This is fairly simple. We can send a request to /tags, with the parameters name (the tag text), tags_chosen (the existing selected tags), taggable_type (the model that is tagged), and optional context (the field that is tagged). If we have genre tags for “comedy” and “conspiracy”, then type co in our form, the JSON rendered should look something like this:

[
	{
		"id": 12,
		"name": "comedy",
		"taggings_count": 1
	},
	{
		"id": 11,
		"name": "conspiracy",
		"taggings_count": 1
	}
]

Now in the select2 input, you should see “comedy” and “conspiracy” as auto-completed tag options!

My tags won’t save!

There’s one last step. We need to set the select2 values into our hidden field that we created earlier.

This code may be different for you depending on how you structure your form, but you essentially want to get the select2 input, convert the array of strings to a CSV string (e.g. ["comedy", "conspiracy"] –> "comedy, conspiracy"), and set that CSV string into the hidden field. That’s not too difficult, fortunately.

You can catch the select2 input changed event, or anything else that suits you. It’s your choice, but this step must be done to ensure that the Rails controller receives the value correctly. Again, in application.js:

/*
* When any taggable input changes, get the value from the select2 input and
* convert it to a comma-separated string. Assign this value to the nearest hidden
* input, which is the input for the acts-on-taggable field. Select2 submits an array,
* but acts-as-taggable-on expects a CSV string; it is why this conversion exists.
*/
$(document).on('select2:select select2:unselect', "*[data-taggable='true']", function() {
    
    var taggable_id = $(this).attr('id')
    // genre_list_select2 --> genre_list
    var hidden_id = taggable_id.replace("_select2", "");
    // film_*genre_list* ($= jQuery selectors ends with)
    var hidden = $("[id$=" + hidden_id + "]")
    // Select2 either has elements selected or it doesn't, in which case use []
    var joined = ($(this).val() || []).join(",");
    hidden.val(joined);
});

You should see the following in your controller action once you have successfully converted your values: "genre_list"=>"comedy,conspiracy"

And that’s all you need to do autocomplete tags in Rails using acts-as-taggable-on and select2!

Comments