Tweetegy On the edge of chaos with Ruby, Rails, JavaScript and AngularJS.

| About | Search | Archive | Github | RSS |

Create Nested Polymorphic Comments with Rails and Ancestry Gem

I know the post title sounds a little complex and a real mouth full but, hey, its just a commenting solution that can be applied to any resource in a Rails application. Additionally, comments can be commented on (hence ‘nested polymorphic comments’).

By the way, this post comes with a full working Rails Application example available on Github. The code is not exactly the same as shown below but the application should work if you follow the README!

So, therefore, in this post I am going to build an application that uses the Ancestry Gem to enable a hand rolled nested commenting system! You can think of this as an example that combines the techniques demonstrated in RailsCasts episode 154 – polymorphic associations and RailsCasts episode 262 – trees with ancestry.

Installation

First up, install the excellent Ancestry Gem. I am using Rails 3 so this is what I will demonstrate here. As always, its pretty straightforward to install and setup. Simply,

Add this to your Gemfile (and, of course, run “bundle” to install it!)

1 gem 'ancestry'

Add the ancestry column to any tables that require it. In my application, I’ll add it to comments as part of the “change” migration since this is a brand new application.

Here is how my migration looks after manually adding the index (obviously run rake db:migrate to update your database with these changes). Note that I add commentable_id and commentable_type so that comments is polymorphic and can be added as a has_many association to any model that requires comments.

 1 class CreateComments < ActiveRecord::Migration
 2   def change
 3     create_table :comments do |t|
 4       t.text :content
 5       t.integer :commentable_id
 6       t.string :commentable_type
 7       t.string :ancestry
 8 
 9       t.timestamps
10     end
11 
12     add_index :comments, :ancestry
13   end
14 end

Add the has_ancestry call to your model, like so:

1 class Comment < ActiveRecord::Base
2   has_ancestry
3 end

Additionally, add has_comments to any model that you want to make 'commentable'. Let's make this application something related to movies (as I am real fan!). In this case I'll make the Movie model commentable by adding a has_many association to it like so:

1 class Movie < ActiveRecord::Base
2   has_many :comments, :as => :commentable, :dependent => :destroy
3 end

Thats it for the setup!

Rendering out some nested comments

Since the main benefit of using the Ancestry gem is how it stores data in a tree like structure, we'll get straight on to displaying our comments as a nested tree.

Firstly create this helper method. This solution was inspired from the following RailsCast on this subject:

1 module CommentsHelper
2   def nested_comments(comments)
3     comments.map do |comment, sub_comments|
4       content_tag(:div, render(comment), :class => "media")
5     end.join.html_safe
6   end
7 end

Next, call this helper method from your show view to render the nested comments!

1 = nested_comments @comments

The actual comment partial (which will live in views/comments/_comment.html.haml) looks like this (note the recursive call to nested_comments in the last line!).

1 .media-body
2   %h4.media-heading=comment.user.name
3   %i= comment.created_at.strftime('%b %d, %Y at %H:%M')
4   %p= simple_format comment.content
5   = nested_comments comment.children

Adding a new comment / thread

Now lets look at how to create a new thread. What we need is to render at the bottom of our show view, right after the nested_comments call, a form to start a new comment thread. We'll render a shared partial in this case since we will want to use the same form for any thing that is commentable:

 1 #In the show view
 2 = render "comments/form"
 3 
 4 # In comments/_form partial
 5 = form_for [@commentable, @comment] do |f|
 6   = f.hidden_field :parent_id
 7   %p
 8     = f.label :content, "New comment"
 9     %br
10     = f.text_area :content, :rows => 4
11   %p
12     = f.submit "Post Comment"

You might now be wondering where are the instance variables @commentable and @comment being set? Well we could set these directly in the show action of the MoviesController, however, that would quickly lead to code duplication when we want to make another model commentable. How about we make a module and include that into our controller instead? That way any model that is commentable, we only need to include the module into the corresponding controller. Here is the code for the Commentable module:

 1 require 'active_support/concern'
 2 
 3 module FilmFan::Commentable
 4   extend ActiveSupport::Concern
 5 
 6   included do
 7     before_filter :comments, only => [:show]
 8   end
 9 
10   def comments
11     @commentable = find_commentable
12     @comments = @commentable.comments.arrange(:order => :created_at)
13     @comment = Comment.new
14   end
15 
16   private
17 
18   def find_commentable
19     return params[:controller].singularize.classify.constantize.find(params[:id])
20   end
21 
22 end
23 
24 # In MoviesController
25 class MoviesController < ApplicationController
26   include FilmFan::Commentable
27 end

Thats nice as it cleans up our Controllers show actions. However, there is still an issue! The form partial form_for [@commentable, @comment] declaration means that we need to post to a generic CommentsController each time - not the actual controller that is "commetnable". This means we need to create this controller and provide a create method in it.

 1 class CommentsController < ApplicationController
 2   def create
 3     @commentable = find_commentable
 4     @comment = @commentable.comments.build(params[:comment])
 5     if @comment.save
 6       flash[:notice] = "Successfully created comment."
 7       redirect_to @commentable
 8     else
 9       flash[:error] = "Error adding comment."
10     end
11   end
12 
13   private
14   def find_commentable
15     params.each do |name, value|
16       if name =~ /(.+)_id$/
17         return $1.classify.constantize.find(value)
18       end
19     end
20     nil
21   end
22 end

Note the find_commentable method courtasy of RailsCast episode 262.

Adding nested comments

Now lets combine the 'polymorphic' with the 'ancestor' and thus add the functionality to create a comment of a comment! The first thing to do is add a 'reply' link in our comment partial. This can go just above the recursive call to nested_comments so that we get this link rendered just after each comment. Note the use of the helper method new_polymorphic_path and that we still use the @commentable instance but in this case we pass an instance of Comment.new so that link will call the CommentController#new action. Note also that we are passing in a parent_id which the Ancestry gem requires for building the tree.

1 # start of partial ommitted....
2   .actions
3     = link_to "Reply", new_polymorphic_path([@commentable, Comment.new], :parent_id => comment)
4   = nested_comments comment.children

As mentioned above, when the 'Reply' link is clicked the new action in the CommentsController is called. The method takes the commentable type, finds the actual instance of that using the private method find_commentable (shown above) and creates and new instance of Comment based on these parameters. The method looks like so:

1 class CommentsController < ApplicationController
2   def new
3     @parent_id = params.delete(:parent_id)
4     @commentable = find_commentable
5     @comment = Comment.new( :parent_id => @parent_id,
6                             :commentable_id => @commentable.id,
7                             :commentable_type => @commentable.class.to_s)
8   end

The final piece of the puzzle is the form which needs to be rendered here. While it would be more desirable to render the form inline using an Ajax solution, here we will just simply render another view with the corresponding form that contains the new Comment object (with the correct commentable_id, commentable_type and parent_id all set, of course!).

1 # In comments/new.html.haml
2 %h1 New Comment
3 = render 'form'

Actually, the form that is rendered is the same comments form partial as shown above. Of course, in this case the @comment instance variable now represents a new child comment since its parent id is now set!

Enabling another resource 'commentable'

Now lets add an Actor to the mix with the association Movie <= has_and_belongs_to_many => Actors. How would we make the Actor resource commentable? Simply follow these steps:

  1. Add "has_many :comments, :as => :commentable, :dependent => :destroy" to the Actor model
  2. Include the FilmFan::Commentable into the ActorsController
  3. Add the "nested_comments @comments" and "render "comments/form" calls to the base of the Actor show view
  4. Finally, add the nested comments resource route for actors in your routes file, just as you did with movies.

Conclusion

I hope you enjoyed this post and that you find this useful in your own projects. Here is a link to the Github project with a full working Rails Application example.