How to add tagging with autocomplete to an existing model in Rails?
Asked Answered
W

3

29

I'm trying to add "tags" to an Article model in a Rails 3 application.

I'm wondering if there is a gem or plugin that has adds both the "tagging" functionality in the model and also the auto-complete helpers for the views.

I've found acts_as_taggable but I'm not sure if that's what I should be using. Is there something newer? I'm getting results from 2007 when I google acts_as_taggable

Whitehead answered 8/2, 2011 at 19:7 Comment(1)
I implemented and tested my answer and it works great! See my updated answer with full instructions and fixes.Spectre
S
54

acts_as_taggable_on and rails3-jquery-autocomplete work nicely together to make a SO like tagging system see example below. I don't think a suitable all in one option exists yet for rails.

Follow these steps to get this all installed:

1 . Backup your rails app!

2 . Install jquery-rails

Note: You can install jQuery UI with jquery-rails but I chose not to.

3 . Download and install jQuery UI

Choose a theme that will compliment your web design (be sure to test the autocomplete demo with the theme you choose, the default theme did not work for me). Download the custom zip and place the [zipfile]/js/jquery-ui-#.#.#.custom.min.js file into your app's /public/javascripts/ folder. place the [zipfile]/css/custom-theme/ folder and all files into your app's public/stylesheets/custom-theme/ folder.

4 . Add the following to your Gemfile and then run "bundle install"

gem 'acts-as-taggable-on'
gem 'rails3-jquery-autocomplete'

5 . From the console run the following commands:

rails generate acts_as_taggable_on:migration
rake db:migrate
rails generate autocomplete:install

Make these changes in your app

Include the necessary javascript and css files in your application layout:

<%= stylesheet_link_tag "application", "custom-theme/jquery-ui-1.8.9.custom" %>  
<%= javascript_include_tag :defaults, "jquery-ui-#.#.#.custom.min", "autocomplete-rails" %>

Controller Example

EDIT: Made changes based on Seth Pellegrino's comments.

class ArticlesController < Admin::BaseController  
  #autocomplete :tag, :name  <- Old   
  autocomplete :tag, :name, :class_name => 'ActsAsTaggableOn::Tag' # <- New
end

Model Example

class Article < ActiveRecord::Base
   acts_as_taggable_on :tags
end

Route.rb

resources :articles do
  get :autocomplete_tag_name, :on => :collection    
end

View Example

<%= form_for(@article) do |f| %>
  <%= f.autocomplete_field :tag_list, autocomplete_tag_name_articles_path, :"data-delimiter" => ', ' %> 
  # note tag_list above is a virtual column created by acts_as_taggable_on
<% end %> 

Note: This example assumes that you are only tagging one model in your entire app and you are only using the default tag type :tags. Basically the code above will search all tags and not limit them to "Article" tags.

Spectre answered 15/2, 2011 at 19:2 Comment(14)
Hi Tim, thanks for the detailed answer. I'm going to try this and come back to update the thread :)Whitehead
Everything is working except for the autocomplete, I must be missing something...Whitehead
@Whitehead - Confirm that jquery, and jquery ui, autocomplete-rails js files are included by viewing the source html. Additionally make sure the ui css file is also included. Are you using Firebug to debug with?Spectre
I figured it out, it was problem with firebug and jquery, unrelated to your code. It works great, thank you againWhitehead
@Whitehead - Great! I think this solution was more complicated that either one of use would have liked. But I think both plugins are well established and good in there own right. If I find a more elegant solution in the future I will try to post it here.Spectre
@Tim Santeford - how can you change the query if tagging is used in more locations? i.e. to search through one specific tagged field?Kentigera
@TimSanteford It looks like the solution above broke when acts_as_taggable_on was namespaced. Changing the autocomplete line to explicitly specify the tag class (e.g. autocomplete :tag, :name, :class_name => 'ActsAsTaggableOn::Tag') gets everything up and running again.Samp
@TimSanteford - if you wanted to limit the returned values to (a) a custom tag type, eg: topics and (b) limit them to "Article" tags, how do we alter the call "autocomplete :tag, :name, :class_name => 'ActsAsTaggableOn::Tag'Hereupon
@TimSanteford Hi Tim, any ideas on how to scope tags by user?Whitehead
Scratch that, I figured out a way to filter by current user #7245015Whitehead
Why is the first item "Backup your rails app"? Surely your rails app is already under version control, thus you don't need to back it up, and you have all the changes versioned?Natalee
How do I add the user id to the tags so that I am able scope?Szczecin
I know this answer is a bit old, but any idea if this the rails3-jquery-autocomplete gem can work with the jquery-ui-rails gem (github.com/joliss/jquery-ui-rails)?Naevus
I'm using bootstrap, is there a way to customize it to use bootstrap css instead of jquery ui. currently I'm seeing a lot many js and css in app using this solution. It is working thoughBeige
C
6

The acts_as_taggable_on_steroids gem is probably your best bet. I've found that many of the tagging gems are more of a "good place to start" but then require a fair amount of customization to get the result you want.

Clodhopping answered 8/2, 2011 at 19:46 Comment(1)
thanks I will look at taggable on steroids. Rails gems/plugins sure have funny namesWhitehead
M
0

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

  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!

Myotome answered 6/2, 2017 at 20:56 Comment(7)
Very interesting I am trying to get it to work, but the tags controller throws syntax errors: "unexpected '.', expecting kEND .where". Why does that happen? Tagging works, but autocomplete not yet! When compacting it on one line I get: "wrong number of arguments (0 for 1)"Sachet
@Sachet You probably actually have a syntax error. Can you post your code on Pastebin or something and link it?Myotome
I put it on pastebin.com/6tnWqHVy. Since tagging works, but autocomplete does not, I suspect the Tags Controller might be the problem.Sachet
@Sachet Since you're on Rails 3, it looks like you can't use .where.not for negative queries... you'll have to change it a bit like in this answer. Play around with that particular syntax... I've never used Rails 3 so I don't know of a precise example for how to change it, but you'll have to change the .where.not clause for sure! And if you like my answer, give me a +1 ;)Myotome
How about: ".where(:name != params[:tags_chosen])", this way the page loads, but I get an invalide MySQL statement. pastebin.com/deCW0FsLSachet
@Sachet Yeah I don't think you can use != in Rails that way, at least not in Rails 3. You'll have to figure this one out for yourself by searching StackOverflow and other sources. Since I have never used Rails 3 I don't know the correct query syntax for a negation like that...Myotome
Let us continue this discussion in chat.Sachet

© 2022 - 2024 — McMap. All rights reserved.