Ruby
Article

Tagging from Scratch in Rails

By Nouran Mahmoud

Price tag icon

Tags are sort of like categories, describing a piece of information (content) and allowing to the user to search for it again. In this tutorial, you will see how to build a simple tagging system, from the ground up, in Rails.

This tutorial assumes a basic knowledge with Ruby on Rails. I’ll try to keep it simple.

A common use of tags, which we all already know, is by Twitter to collect tweets around a certain topic related to the (hash)tag.

Tools

  • Rails 4.1.4
  • Ruby 2.0.1
  • Foundation 5

Getting Started

The enitre source code of this application can be found in this repository

First, create your Rails project:

rails new TaggingTut

Then, add the foundation-rails gem to the Gemfile, are remove the turbolinks gem. Run bundle install.

We will use sqlite, the default database used by Rails

Remove this line from app/assets/javascript/application.js., since we removed the turbolinks gem.

//= require turbolinks

Creating Tags

Generate the model for tags with a single attribute, name:

rails g model tag name:string

We will index the name attribute (index: true) to speed up our search with these tags. I recommend this tutorial for using indexes in Rails associations.

Tags have many Posts, and Posts can have more than one tag. As such, the relationship will be many-to-many. We can represent this association in Rails in two ways:

  • First: Use a has_and_belongs_to_many association. This will generate the join table in the database, but there wont be a generated model for the join. So, you won’t be able to add validations or any other attributes to the join.

  • Second: Use has_many, through which requires a model to be created for the join table. This way is preferred for most cases, so we will use it.

Create models “Post” and “Tagging”, as well

rails g model post author:string content:text
rails g model tagging post:belongs_to tag:belongs_to

After creating these models, run rake db:migrate.

Now, we will create associations between posts and tags through ActiveRecord as follows:

app/models/post.rb

has_many :taggings
has_many :tags, through: :taggings

app/models/tag.rb

has_many :taggings
has_many :posts, through: :taggings

app/models/tagging.rb will be generated like so:

belongs_to :post
belongs_to :tag

We now have a join between posts and tags through the taggings join table.

Now, we will need to handle the creation of tags as part of the post create action. So, we will define a method to take all the entered tags, strip them, and then write each tag to the database.

The Post model had two attributes, author and content, which are also defined in the current form. An attribute for all_tags will be added to the form data, as well. In Rails 4, add the desired (virtual, in this case) attribute using strong parameters. Virtual attributes are very simple, in this case defined as a getter and setter methods. The strip function is for removing whitespace

app/models/post.rb

def all_tags=(names)
  self.tags = names.split(",").map do |name|
      Tag.where(name: name.strip).first_or_create!
  end
end

def all_tags
  self.tags.map(&:name).join(", ")
end

The all_tags function will be customized to render all the tags separated by commas.

Before creating the controller and views, install Zurb Foundation:

rails g foundation:install

Now, customize the controller and views for rendering the posts including all the tags. Create app/controllers/posts_controller.rb by typing:

rails g controller posts index create

Specify the strong parameters as follows, including our virtual attribute to hold all the tags entered through the view:

private
def post_params
  params.require(:post).permit(:author, :content, :all_tags)
end

The permit method creates a whitelist of parameters to be allowed to pass. Read more about about strong parameters here

Let’s create the form with a text field for tags. We will create the post using AJAX. It’s pretty simple:

app/views/posts/_new.html.erb

<div class="row text-center">
  <%= form_for(Post.new, remote: true) do |f| %>
    <div class="large-10 large-centered columns">
      <%= f.text_field :author, placeholder: "Author name" %>
    </div>
    <div class="large-10 large-centered columns">
      <%= f.text_area :content, placeholder: "Your post", rows: 5 %>
    </div>
    <div class="large-10 large-centered columns">
      <%= f.text_field :all_tags, placeholder: "Tags separated with comma" %>
    </div>
    <div class="large-10 large-centered columns">
      <%= f.submit "Post", class: "button"%>
    </div>
  <% end %>
</div>

__remote: true__ is the attribute that tells the form to be submitted via AJAX rather than by the browser’s normal submit mechanism.

After creating our post, create will redirect to the index action and view the existing posts.

app/controllers/posts_controller.rb

def index
  @posts = Post.all
end

app/views/posts/index.html.erb

<div class="row">
  <div class="large-8 columns">
    <%= render partial: "posts/new" %>
  </div>
</div>

Don’t forget to handle the routes.
config/routes.rb

root 'posts#index'
resources :posts, only: [:create]

Add some very simple styling to the view as follows:
app/assets/stylesheets/posts.css.scss

.tags-cloud {
  margin-top: 16px;
  padding: 14px;
}

.top-pad {
  padding: 25px;
}
.glassy-bg{
  box-shadow: 0px 3px 8px -4px rgba(0,0,0,0.15);
  background: white;
  border-radius: 4px;
  padding-bottom: 12px;
}

.mt{
  margin-top: 10px;
}
.mb{
  margin-bottom: 10px;
}

.pt{
  padding-top: 10px;
}
.pb{
  padding-bottom: 10px;
}

Run rails s and let’s see what we have.
Form for post

Oops, there are no posts!. We never wrote the create action.

app/controllers/posts_controller.rb

def create
  @post = Post.new(post_params)
  respond_to do |format|
    if @post.save
      format.js # Will search for create.js.erb
    else
      format.html { render root_path }
    end
  end
end

This snippet creates a new Post with the parameters specified by the user, checking whether it’s valid and returning the result. Since the form is submitted with AJAX, the respond format is js.

Now, we need to create the create.js.erb file to hold the javascript that will run after creating the post:
app/views/posts/create.js.erb

var new_post = $("<%= escape_javascript(render(partial: @post))%>").hide();
$('#posts').prepend(new_post);
$('#post_<%= @post.id %>').fadeIn('slow');
$('#new_post')[0].reset();

This code renders a partial view of the newly created post, the prepend function allows it to be rendered on top of the old posts with a fadeIn effect.

Create a partial that will render each post:

app/views/posts/_post.html.erb

<%= div_for post do %>
  <div class="large-12 columns border border-box glassy-bg mt pt">
    <strong><%= h(post.author) %></strong><br />
    <sup class="text-muted">From <%= time_ago_in_words(post.created_at)%></sup><br />
    <div class="mb pb">
      <%= h(post.content) %>
    </div>
    <div class="tags">
      <%=raw post.all_tags %>
    </div>
  </div>
<% end %>

Before we check the output, modify the index view to hold the partial for posts:

app/views/index.html.erb

<div class="row mt pt">
  <div class="large-5 columns">
    <div class="top-pad glassy-bg">
      <%= render partial: "posts/new" %>
    </div>
  </div>
  <div class= "large-7 columns" id="posts">
    <%= render partial: @posts.reverse %>
  </div>
</div>

Posts will be in reverse order from top to bottom, meaning, the most recenlty entered post will be first.

At this stage, we have posts with tags stored in the database using the two tables, tags and taggings . The taggings table saves the association between posts and tags. Here’s what our posts look like:

posts

Tag-based Search

In this section, we will create scope-based searches on tag name.

Create a class method called tagged_with(name) which will take the name of the specified tag and search for posts associated with it.

app/model/post.rb

def self.tagged_with(name)
  Tag.find_by_name!(name).posts
end

Create an instance variable holding the results on the controller.

app/controllers/posts_controller.rb

def index
  if params[:tag]
    @posts = Post.tagged_with(params[:tag])
  else
    @posts = Post.all
  end
end

Add a get route to hold the tag name and point to the posts_controller#index method:

config/routes.rb

get 'tags/:tag', to: 'posts#index', as: "tag"

After that, change the tags of each post to be links to the ‘index’ method, as follows:

app/views/_post.html.erb

<%=raw tag_links(post.all_tags)%>

tag_links(tags) is a helper method which will hold the logic of converting the tags to links.

app/helpers/posts_helper.rb

def tag_links(tags)
  tags.split(",").map{|tag| link_to tag.strip, tag_path(tag.strip) }.join(", ") 
end

Yay! Now, we have tag-based search for our posts!

tag-based search

Tag Cloud

Let’s generate one of those cool tag clouds based on counting the number of occurrences for each tag across all posts.

First, create a method to count all tags associated with posts:

app/models/tag.rb

def self.counts
  self.select("name, count(taggings.tag_id) as count").joins(:taggings).group("taggings.tag_id")
end

This query groups the matched tag_ids from the taggings join table and counts them.

We will style them according to their counts by creating a helper method called tag_cloud which take the result of calling the counts function and CSS classes.

app/helpers/posts_helper.rb

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

This helper method will get the tag with the max count. Then, it loops on each tag to calculate the index which will choose the CSS class based on rounded value. Then, the passed block will be executed.

We need to add styles for different sizes as follows:

app/assets/tags.css.scss

.css1 { font-size: 1.0em;}
.css2 { font-size: 1.2em;}
.css3 { font-size: 1.4em;}
.css4 { font-size: 1.6em;}

Don’t forget to add *= require tags to application.css.

Finally, add the code to display the tags in the view and apply the CSS classes to them.

app/views/posts/index.html.erb

<div class="tags-cloud glassy-bg">
  <h4>Tags Cloud</h4>
  <% tag_cloud Tag.counts, %w{css1 css2 css3 css4} do |tag, css_class| %>
    <%= link_to tag.name, tag_path(tag.name), class: css_class %>
  <% end %>
</div>

Check it out, our tags are in a cloud!
tag cloud

actastaggable_on

After this article, you should be able to handle the act_as_taggable_on gem without issue. You can read more about it on its github repo.

Conclusion

I hope this tutorial helps you understand what goes into creating a basic tagging system. Tag, you’re it! :)

  • http://occuly.me GuillaumeWest

    Thank you very much
    I’ll try it now!

  • http://kbarre123.github.io K.C. Barrett

    Great article! Giving it a shot this weekend. Thanks!

  • Ahmad Hasan Khan

    Nice article, but I think by mistake you are telling to generate Tag model “rails g model tag name:string” twice.

    • ggsp

      Yup, thanks for telling us! Fixed!

      • Ahmad Hasan Khan

        Still some mistakes are there if you compare blogger guidance with screenshot

  • Danny Ocean

    Just in time. Thanks!

  • Dana Nourie

    Nice tutorial. A few issues:

    * Shouldn’t

    private
    def post_params
    params.require(:event).permit(:author, :content, :all_tags)
    end

    be params.require(:post)? Where did ‘event’ come from?

    When I change the above to
    private
    def post_params
    params.require(:post).permit(:author, :content, :all_tags)
    end

    I get a routing error:

    No route matches [GET] “/posts”

    My routes.rb:

    Rails.application.routes.draw do
    root ‘posts#index’
    resources :posts, only: [:create]
    get ‘tags/:tag’, to: ‘posts#index’, as: “tag”

    end

    • ggsp

      Hey Dana,

      You’re right about the `params.require` Not sure why you’re seeing the routing error. Can you put your code somewhere we can see it (Github, maybe?)

    • Ahmad Hasan Khan

      Some codes are missing or not appropriate in article.
      Checkout the code repo https://github.com/NouranMahmoud/TaggingTut.

    • Nouran Mahmoud

      After debugging your code, it’s obvious that ‘sprockets-rails (3.0.0.beta1)’ is not able to load the ‘jquery’ properly and remote: true doesn’t generate the ‘javascript’ format in the request header. So, just include the jquery by one of these two methods and It’ll work with you.

      By putting them before including ‘application.js’, like this

      Or by just adding

      Then in the initializers/assets.rb, just add this line
      Rails.application.config.assets.precompile += %w( jquery.js )

  • Dana Nourie

    I also had changed the misspelling of “author” which is in the code as “auther”. Going through my models now.

  • Adit

    As really often Rails community benefits of the PostgreSQL as favourite DB, we should really try to use some of their best features, which are really well supported. PostgreSQL can use arrays and hashes, why not going in that direction and avoid left joins? Cheers, Adit

  • Dana Nourie

    Thank you! I’ll try that and see what happens.

  • ggsp

    So, I don’t get the unknown route error, but I get an UnknownFormat error. Look at this gist: https://gist.github.com/ruprict/51db73c885e7892580a4 to see what I did.

    Sorry it took so long to reply

  • http://leafout-app.com Nate

    This was a great tutorial that helped me tremendously!

    I just wanted to say for anyone using PostgreSQL (like on Heroku) you’ll need to add tags.name to the group() line in the tag count method above. (app/models/tag.rb line 2 from above)

    • http://danny-sullivan.github.io Daniel Sullivan

      Thank you, was banging my head on this.

  • http://leafout-app.com Nate

    Again, thanks for this great tutorial.

    I’m trying to incorporate the same structure, but with categories. Categories have many posts, and I don’t want posts tagged in another category to show on the category page. I would also like to hide tags in the tag cloud that aren’t used in that category.

    I changed to @posts = @category.posts.tagged_with(params[:tag]), but that didn’t change anything.

    Any ideas you could offer would be much appreciated!

  • http://www.humbleware.com/ Muhammad Usman

    That’s the best tutorial, i have ever seen. Thank You

  • http://www.humbleware.com/ Muhammad Usman

    why you removes turbo links ?

    • Nouran Mahmoud

      Because it sometimes causes problems, I didn’t want to encounter it in the tutorial.

  • railsr

    do you think its better than acts-as-taggable-on gem?

    • Nouran Mahmoud

      For me, I use gems only when I need to implement somethings needs a huge code, or when its implementation needs much time. so it depends on you.

  • Jeroen van Ingen

    I am really missing TDD or BDD (rspec or so) in this tutorial

  • cmcollin41

    Great tutorial! Thank you. Is there a simple way to display the posts on the index page but sorted into columns by their tag? So, if you had three tags there would be three columns with associated posts?

  • http://www.sebiworld.net Sebastian

    Thank you for this great tutorial!

  • Ariff

    Great tutorial! Especially love the AJAX part.

    One question though.. is adding h() or raw necessary and if so when should I use it?

  • Tim

    I just want to add my thanks for this tutorial. Exactly what I needed!

  • http://www.ibizwebsites.com Ken Mcfadden

    After adding the create.js.erb and _post partial and index changes…i get ActionController::InvalidAuthenticityToken

    From console..

    Started POST “/posts” for 127.0.0.1 at 2015-05-29 12:58:05 -0700
    Processing by PostsController#create as HTML
    Parameters: {“utf8″=>”✓”, “post”=>{“author”=>”test”, “content”=>”test”, “all_tags”=>””}, “commit”=>”Post”}
    Can’t verify CSRF token authenticity
    Completed 422 Unprocessable Entity in 1ms (ActiveRecord: 0.0ms)

    Anyone else?

  • http://RawrLike.Me/ Brandon Stewart

    Whenever I try to delete a post I get this error: http://prntscr.com/7gdnj0

    here is my code: http://prntscr.com/7gdnmf

  • http://RawrLike.Me/ Brandon Stewart

    Whenever I delete a post I get an error

  • APK Share

    like this article so much

  • Bert Ljung

    How to remove duplicate tags in ‘all_tags=(names)’ method?

    • joshdotmn

      .uniq

  • Efe Imoloame

    I have added tags to my articles and also projects on my portfolio page, how can i use a tag-based search on both of them where the page that comes up after clicking a tag shows both the projects and the articles.

  • Bala Paranj

    I have upgraded this application to Rails 5 and Zurb Foundation 6.2.3, check it out here: https://rubyplus.com/articles/4251-Tagging-from-Scratch-in-Rails-5-using-Zurb-Foundation

  • Nicolás Sebastián Vidal

    Thank you for this post, I really like it. I did a little update here: https://github.com/nisevi/taggingtut and in order to add more value I added reCAPTCHA and pagination with AJAX by using Kaminari. Here is the app running on heroku https://taggingtut.herokuapp.com/.

Recommended
Sponsors
Because We Like You
Free Ebooks!

Grab SitePoint's top 10 web dev and design ebooks, completely free!

Get the latest in Ruby, once a week, for free.