I wrote a blog post about this recently; for the sake of brevity, my method allows you to have (optional) context-filtered tags (e.g. by model and by attribute on the model), whereas @Tim Santeford's solution will get you all tags for a model (not filtered by field). Below is the verbatim post.
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
- Add acts-as-taggable-on to your Gemfile:
gem 'acts-as-taggable-on', '~> 3.5'
- Run
bundle install
to install it
- Generate the necessary migrations:
rake acts_as_taggable_on_engine:install:migrations
- 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!