#382 Tagging
- Download:
- source codeProject Files in Zip (94.1 KB)
- mp4Full Size H.264 Video (27.5 MB)
- m4vSmaller H.264 Video (13.8 MB)
- webmFull Size VP8 Video (15.6 MB)
- ogvFull Size Theora Video (32.7 MB)
Below is a page from a blogging application that shows a list of articles. We’d like to add tagging so that each article can have a number of tags assigned to it. In this episode we’ll show two different techniques to add tagging support to a Rails app.
Adding Tagging With Acts-as-Taggable-on
There are a lot of tagging libraries available but by far the most popular is acts-as-taggable-on. This gem is a little dated but it’s one of the few to have been maintained over the years. To use it in a Rails application we just need to add it to the gemfile then run bundle to install it.
gem 'acts-as-taggable-on'
Next we need to run a generator that the gem provides to create a migration and then migrate the database to add the fields that acts-as-taggable-on
uses.
$ rails g acts_as_taggable_on:migration $ rake db:migrate
This migration creates two tables: tags
and taggings
. We don’t have to create models for these as they’re included with the gem so we can get started with adding tagging to our application. We want an extra field on the page for editing an article where we can add tags for that article. To do this we’ll modify the Article
model by adding a call to acts_as_taggable
, which is a method provided by the gem, and also a tag_list
field to the attr_accessible
list.
class Article < ActiveRecord::Base attr_accessible :content, :name, :tag_list acts_as_taggable end
Next we’ll add a new field to our article form for editing the list of tags.
<div class="field"> <%= f.label :tag_list, "Tags (separated by commas)" %><br /> <%= f.text_field :tag_list %> </div>
We can try this out now. If we edit an article and add a couple of tags to the new field (separated by commas) we’ll see those tags listed when we go back to edit the article again, so it looks as if this is working so far.
If you want to add auto-completion behaviour when you edit the tags, take a look at episode 102. We won’t be doing this here; the next thing we’ll do is add each article’s tags under its content. The simplest way to do this is to show the tag_list
for each article.
<h1>Articles</h1> <div id="articles"> <% @articles.each do |article| %> <h2><%= link_to article.name, article %></h2> <%= simple_format article.content %> <p>Tags: <%= article.tag_list %></p> <p><%= link_to "Edit Article", edit_article_path(article) %></p> <% end %> </div> <p><%= link_to "New Article", new_article_path %></p>
The tags for each article will now be displayed on the index
page.
It would be better if the tags were links so that if we click one we’ll see just the articles that have that tag. The tag_list
attribute actually returns an array of strings, even though when we set the tag list it’s set through a single string with the tags separated by commas. We can turn this array of tags into a set of links like this:
<p>Tags: <%= article.tag_list.map { |t| link_to t, tag_path(t) }.join(', ') %></p>
We don’t have any routes set up for tags so we’ll do that now. We could set up a full tags resource but to keep things simple we’ll set up a single route. This will take a :tag
argument and map to the articles index page.
Blog::Application.routes.draw do get 'tags/:tag', to: 'articles#index', as: :tag resources :articles root to: 'articles#index' end
When we reload the page this doesn’t quite work, however, as the HTML for the tags is escaped. To fix this we can pass this code through the raw
method so that it isn’t escaped. Whenever we use this method it’s a sign that we’re doing something a little complicated for the view layer and that we should move it off into a helper method. We won’t do that here, but it’s worth bearing this in mind.
<p>Tags: <%= raw article.tag_list.map { |t| link_to t, tag_path(t) }.join(', ') %></p>
When we reload the page now we have links for each tag, each of which points to the correct path. Clicking on one doesn’t filter the list of articles yet, though.
We can do this easily enough in the ArticlesController
’s index action by checking for a :tag
parameter and filtering the list by that tag if it’s found. The gem provides a tagged_with
that we can used to find the articles with a given tag.
def index if params[:tag] @articles = Article.tagged_with(params[:tag]) else @articles = Article.all end end
When we reload the page now the articles are filtered by the selected tag.
Adding a Tag Cloud
Our tag list works now but what if we want to browse all the tags? A common way to do this is to use a tag cloud that shows all the tags, each with a size proportional to its popularity. We’ll add this near the top of the index template and as the gem supplies a tag_cloud
method doing this is quite easy.
<div id="tag_cloud"> <% tag_cloud Article.tag_counts, %w{s m l} do |tag, css_class| %> <%= link_to tag.name, tag_path(tag.name), class: css_class %> <% end %> </div>
This method takes two arguments. The first one is a set of the tags that we want to display. An easy way to get this is to call Article.tag_counts
which will give us a list of the tags and how often each one is used, which is needed to know how big each tag should be shown. The second argument we pass in should be a array of the CSS classes we want to use for the various sizes. The method also takes a block which is passed a tag
object and the CSS class that matches that tag. Inside this block we render out the tag, passing link_to
the tag object’s name, the tag_path
(which we also pass the tag name as we want to use the name in the URL), and the CSS class. We don’t have styles defined for our tag cloud so we’ll add those now.
#tag_cloud { width: 400px; line-height: 1.6em; .s { font-size: 0.8em; } .m { font-size: 1.2em; } .l { font-size: 1.8em; } }
When we reload the articles page now we’ll see a tag cloud with the two tags that we’ve already added. If we add some more tags to the articles and reload the page we’ll see a better looking tag cloud with tags that vary in size depending on the number of articles with that tag. Clicking a tag will filter the articles to those with that tag.
There’s a lot more we can do here such as highlighting the current tag or sorting the tags by their name but we won’t do those in our application.
Implementing Tagging From Scratch
We’ve done a lot with the acts-as-taggable-on gem
but how much work would it be to implement something like this from scratch? Before we can do this we’ll need to roll back some of the functionality that we’ve added. First we’ll undo the database change we made and remove the migration.
$ rake db:rollback $ rails d acts_as_taggable_on:migration
Next we’ll remove the gem from the gem file and run bundle again. We can then start rebuilding the tagging functionality from scratch. First we’ll generate a Tag
model with a name
column.
$ rails g model tag name
Next we’ll create a Tagging
model that belongs to both Tag
and Article
.
$ rails g model tagging tag:belongs_to article:belongs_to
We could set up a polymorphic association here which is what the gem does internally but since we have a simple setup we’ll link directly to the Article
model here. We can then run rake db:migrate
to ad the new tables. Next we need to set up the associations in the models, starting with Tag
.
class Tag < ActiveRecord::Base attr_accessible :name has_many :taggings has_many :articles, through: :taggings end
The Article
model needs to be a little more complicated as we need to replace the functionality of acts_as_taggable
. We’ll need to add some associations and a few methods for managing tags.
class Article < ActiveRecord::Base attr_accessible :content, :name, :tag_list has_many :taggings has_many :tags, through: :taggings def self.tagged_with(name) Tag.find_by_name!(name).articles end def self.tag_counts Tag.select("tags.*, count(taggings.tag_id) as count"). joins(:taggings).group("taggings.tag_id") end def tag_list tags.map(&:name).join(", ") end def tag_list=(names) self.tags = names.split(",").map do |n| Tag.where(name: n.strip).first_or_create! end end end
First we set up the associations for taggings and for tags through taggings then we implement the four methods that we call on the Article
model elsewhere in our application. The tagged_with
method returns the articles with tags matching a given name while tag_counts
returns the tags and a count for each one. (This method may well be better off in the Tag
model.) We also have methods for getting and setting the tag_list
, although there’s a slight change here as the getter returns a string instead of an array. Because of this we’ll need to change the index
template as it currently treats the tag_list
as an array. Instead we’ll call article.tags
and map the name
attribute so that it has similar behaviour to the gem.
<%= raw article.tags.map(&:name).map { |t| link_to t, tag_path(t) }.join(', ') %>
The last thing we need to write is the tag_cloud
method. We’ll write this in the ApplicationHelper
module.
module ApplicationHelper def tag_cloud(tags, classes) max = tags.sort_by(&:count).last tags.each do |tag| index = tag.count.to_f / max.count * (classes.size - 1) yield(tag, classes[index.round]) end end end
This method is fairly simple and takes the same arguments as the gem’s method. In it we find the tag with the highest count so that we can determine the relative popularity of the other tags and determine the class to use with each one. We now have roughly the same functionality but written from scratch. If we add a couple of tags to an article then go back to the list we’ll see the tags next to that article and also added to the cloud.
The majority of the code for our solution is in the Article
model. We’ve not had to write too much code to implement this and now we don’t have an extra dependency in our application. There are a lot of features in the gem that we haven’t added to our scratch solution such as different contexts that we can supply when we create tags on a model. With the gem we can also mark owners so that we can make, say, a User a tagger and the gem will keep track of this when we track models.
If you just want a simple tagging solution then implementing it from scratch is a good idea, but for a full featured solution them gem makes more sense.