Create Nested Polymorphic Comments with Rails and Ancestry Gem
04 Apr 2013I 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
2
3
4
5
6
7
8
9
10
11
12
13
14
class CreateComments < ActiveRecord::Migration
def change
create_table :comments do |t|
t.text :content
t.integer :commentable_id
t.string :commentable_type
t.string :ancestry
t.timestamps
end
add_index :comments, :ancestry
end
end
Add the has_ancestry
call to your model, like so:
1
2
3
class Comment < ActiveRecord::Base
has_ancestry
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
2
3
class Movie < ActiveRecord::Base
has_many :comments, :as => :commentable, :dependent => :destroy
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
2
3
4
5
6
7
module CommentsHelper
def nested_comments(comments)
comments.map do |comment, sub_comments|
content_tag(:div, render(comment), :class => "media")
end.join.html_safe
end
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
2
3
4
5
.media-body
%h4.media-heading=comment.user.name
%i= comment.created_at.strftime('%b %d, %Y at %H:%M')
%p= simple_format comment.content
= 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
2
3
4
5
6
7
8
9
10
11
12
#In the show view
= render "comments/form"
# In comments/_form partial
= form_for [@commentable, @comment] do |f|
= f.hidden_field :parent_id
%p
= f.label :content, "New comment"
%br
= f.text_area :content, :rows => 4
%p
= 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
require 'active_support/concern'
module FilmFan::Commentable
extend ActiveSupport::Concern
included do
before_filter :comments, only => [:show]
end
def comments
@commentable = find_commentable
@comments = @commentable.comments.arrange(:order => :created_at)
@comment = Comment.new
end
private
def find_commentable
return params[:controller].singularize.classify.constantize.find(params[:id])
end
end
# In MoviesController
class MoviesController < ApplicationController
include FilmFan::Commentable
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CommentsController < ApplicationController
def create
@commentable = find_commentable
@comment = @commentable.comments.build(params[:comment])
if @comment.save
flash[:notice] = "Successfully created comment."
redirect_to @commentable
else
flash[:error] = "Error adding comment."
end
end
private
def find_commentable
params.each do |name, value|
if name =~ /(.+)_id$/
return $1.classify.constantize.find(value)
end
end
nil
end
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
2
3
4
# start of partial ommitted....
.actions
= link_to "Reply", new_polymorphic_path([@commentable, Comment.new], :parent_id => comment)
= 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
2
3
4
5
6
7
8
class CommentsController < ApplicationController
def new
@parent_id = params.delete(:parent_id)
@commentable = find_commentable
@comment = Comment.new( :parent_id => @parent_id,
:commentable_id => @commentable.id,
:commentable_type => @commentable.class.to_s)
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
2
3
# In comments/new.html.haml
%h1 New Comment
= 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:
- Add “has_many :comments, :as => :commentable, :dependent => :destroy” to the Actor model
- Include the FilmFan::Commentable into the ActorsController
- Add the “nested_comments @comments” and “render “comments/form” calls to the base of the Actor show view
- Finally, add the nested comments resource route for actors in your routes file, just as you did with movies. </ol>
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.