-
Notifications
You must be signed in to change notification settings - Fork 41
Embedded Nested Polymorphic Comments
As a newbie working on my first Rails app, I looked for tutorials, snippets, or screencasts that could help me implement what I thought was a very commonplace design pattern – a list of comments with an Add Comment form attached to the bottom of several models’ show pages (like articles and podcast episodes). I found lots of references that covered some of the related concepts (including make_resourceful
), but none that implemented exactly what I describe above. So I had to do a bit of guess-work, which is overwhelming for a beginner already struggling with getting up to speed on Ruby, ActiveRecord, routing, controllers, views, REST, etc.
I managed to get it working, and want to post the recipe I cobbled together to help others on a similar journey reach their destination quicker. The make_resourceful
Google group seems to be dead, so I’m posting it on this wiki. I hope more experienced Rails developers will come by and review this, as it is highly improbable that a beginner would produce a best-practice solution to this problem.
Let’s review our design requirements.
There are three models: Article
, Podcast
, and Comment
. We want both Article
and Podcast
to be have a one-to-many relationship with Comment
so that many users can post many comments on all article and podcast pages. We want to do this polymorphically (meaning any comment record may point to either a article or a podcast) rather than create two separate comment tables, one each for article and podcast.
Let’s review the situation, action-by-action:
-
index
: We don’t need this, since the list of comments will always be in the context of the parent model – the list of comments belonging to a specific article or podcast. (The Rails term for this concept is ‘scope’.) This will be implemented by a partial template, not a controller action. -
show
: We don’t need this, since comments will only need to be viewed in list form. We won’t have individual pages that just show the specific comment. -
new
: We don’t need this, since the “Add Comment” form is going to be embedded in theshow
page for the parent model (article or podcast). -
create
: We do need this, as the “Add Comment” form will need to post the comment to some URL. -
edit
: To keep things simple, we won’t both implementing the ability to edit comments, only create and destroy them. This is a fairly common limitation, and perfectly acceptable. -
update
: Likewise, we won’t need this. -
destroy
: We will need this, as admins will want the ability to delete offensive comments (mostly spam).
So, we only need two routes and two actions: create
and destroy
. Furthermore, we’ll want them nested so that the context of the parent model is available to the create
action without passing it as a hidden field in the form (ugly). In other words, our routes will look like this:
/:parent_model/:parent_model_id/comments
/:parent_model/:parent_model_id/comments/:comment_id/destroy
Example:
/articles/12/comments
/articles/12/comments/57/destroy
(Technically, destroy
doesn’t need to be nested since comment_id
alone is enough to umabiguously delete the comment, but why implement a half-and-half design?)
To keep things DRY, we want to implement a partial template that will display the list of comments for the context object (article or podcast) along with a form to add comments. We can then render that partial from the show
page of the two parent models. The partial will need…
- …to know the context object in order to display it’s comments
- ..to generate the URLs for the context-appropriate nested routes for the
destroy
action for each comment (you would only display these links to an admin, of course) - ..to generate the URL for the context-appropriate nested route for the
create
action, for the “Add Comment” form
So, the three relevant templates are:
/app/views/articles/show.html.erb
/app/views/podcasts/show.html.erb
/app/views/shared/_comments.html.erb
If you’re new, first get up to speed on the basic concepts and the examples that already exist. Let’s walk through the core concepts.
A resource who’s routes are nested below the routes of the parent resource. If the parent resource was Article
, and comments were nested below article, then comment routes would look like /articles/12/comments
and /articles/12/comments/57/edit
. References:
Railscast 139 describes a very simple version of our scenario. He has Article
and Comment
models, implements a basic one-to-many relationship, and demonstrates how to nest the routes, so you can see the comments for article 12 at /articles/12/comments.
(Note: The term ‘Nested’ can also refer to nesting a form for one model inside another, so that you could update, say, a Doctor and the names of his Patients all in one giant form. That’s not the kind of nesting talked about on this page – though if you’re interested, check out Railscasts 196 and 197)
The term I’m using to describe the fact that I’m putting the index
and new
functionality directly into the show
page of the parent object, rather than creating separate routes, controller actions, and templates.
A relationship who’s participants are determined dynamically instead of statically. In ActiveRecord, this is implemented by using a compound foreign key (like parent_id
and parent_type
) instead of a single-field foreign key (like article_id
) that can only refer to one table. References:
Railscast 154 describes almost exactly the same scenario as ours – polymorphic nested comments – but creates a full set of routes, actions, and templates for the comments rather than embedding them. (The implementation details and side-effects of embedding are different and challenging enough to be an obstacle to a novice like myself.) He writes a short function (the “secret sauce”) that helps determine which model that parent object belongs to (Article
or Podcast
, in our case). It’s great, but I have an aversion to dropping fancy bits of code like that into my projects. I like to have the fancy stuff neatly tucked away in well-named and preferably well maintained plugins. :)
I had been searching a long time and was despairing of finding that one tool or reference that would finally get me within mental-jumping distance of my goal… until I found make_resourceful
. It rocks. If you don’t know about it – if you got here from a web search – go check it out right now. This is the wiki for it, afterall. Just scroll up and click on Source.
Since I’m already pimping Railscast so hard, might as well be consistent:
make_resourceful
is great for all projects, but let’s focus on how it specifically helps us close the gap. It already includes the same functionality that the “secret sauce” function from Railscast 154 added – the ability to disambiguate polymorphic parent contexts. In fact, it has enough built-in intelligence to automatically provide sensible default logic for all actions, including polymorphic relationships in combination with nested routes. In fact, the code to implement @make_resourceful@__ ’s defaults, including nested routes and polymorphic relationships, is less than the generic scaffold code produced by Rails for the simpler, general case__.
make_resourceful
handles all the logic for you, and also provides you with the proper abstractions to use to implement changes to the logic when you need to. We’ll cover those in the next section.
Let’s walk through the implementation from the bottom of the stack upwards.
/config/routes.rb
map.resources :articles, :podcasts do |item|
item.resources :comments, :only => [:create, :destroy]
end
The first line sets up the routes for our Article
and Podcast
resources, and the second line (inside the block) sets up nested routes for the Comment
resource beneath the parent resources, but only for the create
and destroy
actions.
When implementing a polymorphic relationship in ActiveRecord, you have to give a name to the abstract model that Comment
is linking to, which is then dynamically resolved at runtime to either Article
or Podcast
on the fly. A common convention is to use the name “commentable”, but I don’t like using that as a noun, so I chose the generic name “item”. In this context, articles and podcasts are items, and items have comments.
/app/models/article.rb
has_many :comments, :as => :item
/app/models/podcast.rb
has_many :comments, :as => :item
/app/models/comment.rb
belongs_to :item, :polymorphic => true
Standard way of declaring associations in ActiveRecord models.
/app/controllers/articles_controller.erb
/app/controllers/podcasts_controller.rb
make_resourceful do
actions :all
end
That’s literally all we need for the article and podcast controllers. Note that those three lines replace the dozens of lines that the scaffolding provided which explicitly defined methods for all seven REST actions.
/app/controllers/comments_controller.rb
make_resourceful do
belongs_to :article, :podcast
actions :create, :destroy
response_for :create, :create_fails, :destroy, :destroy_fails do
redirect_to parent_object
end
end
That’s literally all we need for the comments controller (kind of). (Again, this replaces the generic code.) The belongs_to
directive tells make_resourceful
that we’re using nested routes (I guess it can’t figure that out automatically yet). The actions
directive limits the actions that are handled. The response_for
directive let’s us provide custom response logic for a set of actions/events, and requires further explanation.
make_resourceful
defines events for each of the seven REST actions which you can hook into with response_for
and a few other methods (notably before
and after
). It also defines :action_fails events for the three modifier actions (create
, update
, destroy
) so you can define different responses in those situations. Now, we need to take a short detour to first review the default logic before we change it to work with our embedded comment design.
The default logic in both the scaffolding code that Rails generates as well as inside make_resouceful
is that after the create
action is done, you either redirect to the show
action if it succeeded or render the new
template if it failed (so that the user can see the errors and try again). For example, if a user were trying to add a comment to an article, they would first get to the new
page (probably from a link on the article’s show
page), which is here:
/articles/12/comments/new
They would then click the submit button, which would POST to:
/articles/12/comments
This would route to the create
action. If the create succeeds, it will redirect you to the show
page of the newly-created comment:
/articles/12/comments/57
If it fails, it won’t redirect you anywhere, but instead simply render the new
template, which would have access to the object that was created but couldn’t be saved. This object contains a record of the error messages and can be displayed by your template. You’ll often see the line <% form.error_messages %>
in Rails-generated scaffolding and in code examples which is one way to display them (if you use Formtastic, it does it for you with a flourish).
In our scenario, however, there is no comments/new
page. The form is embedded in the show
page of the parent object (article or podcast). So the user would start here:
/articles/12
They would then click the submit button, which would POST to:
/articles/12/comments
This would route to the create
action. If the create succeeds, it will redirect you back to the show
page of the parent object which contains both the list of comments and the “Add Comment” form:
/articles/12
If it fails, it would also redirect you back to the show
page of the parent object, because we don’t want to write code in our CommentsController
that tries to correctly render the show
page of the parent object (article or podcast) – that would be insane. You don’t want your code that tightly coupled.
Let’s review the response_for
block that was shown above:
response_for :create, :create_fails, :destroy, :destroy_fails do
redirect_to parent_object
end
Now we can understand what this is for. After all actions, either create
or destroy
, whether it succeeds or not, the response is to redirect to the parent object. Luckily, make_resourceful
provides the aptly named parent_object
method to make this easy.
/app/views/articles/show.html.erb
<%= render :partial => "shared/comments", :locals => { :item => @article } %>
/app/views/podcasts/show.html.erb
<%= render :partial => "shared/comments", :locals => { :item => @podcast } %>
In the show
template for each of the parent objects, we add a call to render the comments partial template. We pass in a local variable called ‘item’ that we set to the parent object.
/app/views/shared/_comments.html.erb
<% if item.comments.empty? %>
No comments to display.
<% else %>
<% for comment in item.comments %>
<%= comment.body %>
<% end %>
<% end %>
Add Comment
<% form_for [item, Comment.new()] do |f| %>
<%= f.input :body %>
<%= f.submit %>
<% end %>
Here we simply list all comments, if any, then create a form to add new comments. The Rails form_for
method accepts an array of ActiveRecord objects which it will use to construct a proper nested URL – in this case, something like:
/articles/12/comments
It knows how to do this because we configured nested routes in the /config/routes.rb
file. We create an empty Comment object to pass it simply to let it know the model class to build the route for.
That’s it! We’re done! Well… except for one problem…
The usual way in Rails to handle displaying error messages that result from submitting a form is to rerender the template the user just came from and pass it the object that we failed to save. The object contains a record of the error messages which can then be displayed by the template.
The problem is, we’ve broken that pattern. When the create
action in the CommentsController
fails, it doesn’t render the article or podcast’s show
page, it does a redirect to it… and we lose the object containing our precious error messages.
The fallback I chose was to use the flash instead. (To learn about the flash feature, see the Rails Docs.) So, instead of the combined response_for
block we used before, we can instead do this:
response_for :create do
flash[:success] = "Comment posted."
redirect_to parent_object
end
response_for :create_fails do
flash[:error] = current_object.errors.full_messages.to_s
redirect_to parent_object
end
response_for :destroy do
flash[:success] = "Comment deleted."
redirect_to parent_object
end
The success messages are fairly straightforward. For the create_fails
event I used the current_object
method provided by make_resourceful
to access the error messages, convert them to one big string, and stick them in the flash. That way they’ll persist through the browser redirect and be available when the show
action is called on the parent object.
You should probably be displaying all flash messages already in your layout template, since this is generally a site-wide feature not specific to our example here. If so, we’re now done.
It’s pretty common to also have your comment model associated with your user model so that if a user is logged in when they post a comment, the comment’s user_id
field will get set to the user’s id. Where do we put that code?
make_resourceful
provides hooks to run additional bits of code at predefined points in it’s internal action logic. We’ll use one of those. Assuming you have defined two helper methods called current_user
and logged_in?
, add this bit inside your make_resourceful do
block in /app/controllers/comments_controller.rb
:
before :create do
current_object.user = current_user if logged_in?
end