Rails blog comments with AJAX

Basic implementation of blog post comments posed little challenge and differed little from creation of Users and Posts resources. The only wrinkle came from my first use of nested resources, with Comments being a nested resource of Posts. It took a bit to get used to named routes such as

new_post_comment

in place of the familiar

new_comment

or modifications to Rails helpers like

<%= form_for [@post, @new_comment] do |f| %>

So far, it seems that while nesting resources makes perfect sense theoretically and logically, the increased awkwardness that comes with their practical use is not justified. Perhaps this is simply a matter of becoming more familiar with the altered syntax.

Addition of AJAX to the comments, however, was more challenging. The payoff here is clear: being able to post new comments and delete existing ones without page reloads is not only faster but feels more natural – when it works, that is. With that caveat in mind, one would be well served by firing up the Rails server and keeping its window visible, particularly while implementing the relevant JavaScript. Often, when scripts fail to post or delete a comment, absolutely nothing happens in the browser window. The server log, however, will show whether the comment was created/destroyed (i.e. the controller action was reached and executed), as well as display any errors encountered by JavaScript or Rails while attempting to re-render a part of the page (e.g. a view template was not found).

1. remote: true

The remote: true argument needs to be added to any Rails helper tag that is expected to function asynchronously. In my case, it was the form for creating new comments

<%= form_for [@post, @new_comment], remote: true do |f| %>

# @new_comment is defined in app/controllers/posts_controller.rb:
.
.
def show
  @post = Post.find(params[:id])
  @comments = @post.comments
  @new_comment = @post.comments.new
end
.
.

and the link to delete existing comments (only available to logged in blog writers)

<% if signed_in? %>
  <%= link_to 'Delete', post_comment_path(comment.post, comment), method: :delete, remote: true %>
<% end %>

(it actually looks like this when cluttered with things not relevant to this topic:)

<% if signed_in? %>
  <%= link_to '', post_comment_path(comment.post, comment), method: :delete, data: { confirm: 'Delete comment?'}, remote: true, class: 'glyphicon glyphicon-trash text-danger', id: "#{comment.id}_delete", title: 'Delete comment', rel: 'tooltip' %>
<% end %>

2. Changes to controller

The relevant controller actions need to be modified to include the instructions for executing appropriate JavaScript upon success/failure. In this case, the create and destroy methods are altered. The original actions are implemented as follows:

class CommentsController < ApplicationController
  .
  .
  def create
    @post = Post.find(params[:post_id])
    @comment = @post.comments.build(comment_params) # strong parameters
    if @comment.save
      flash[:success] = 'Comment posted.'
      redirect_to @post
    else
      render @post
    end
  end
  .
  .
  def destroy
    @comment = Comment.find(params[:id])
    @post = @comment.post
    @comment.destroy
    flash[:success] = 'Comment deleted.'
    redirect_to @post
  end
  .
  .
end

As shown above, the code works fine without the remote: true option in the relevant form and link, or if JavaScript is disabled in client browser. However, with JavaScript turned on and the remote option in place, clicking the ‘Delete’ or ‘Post comment’ buttons will elicit absolutely no visible response from the app. The reason behind this is that e.g. redirect_to @post is an HTML response and not a JavaScript one, which currently does not exist. To remedy the situation:

class CommentsController < ApplicationController
  .
  .
  def create
    @post = Post.find(params[:post_id])
    @comment = @post.comments.build(comment_params) # strong parameters
    if @comment.save
      respond_to do |format|
        format.html do
          flash[:success] = 'Comment posted.'
          redirect_to @post
        end
        format.js # JavaScript response
      end
    end
  end
  .
  .
  def destroy
    @comment = Comment.find(params[:id])
    @post = @comment.post
    @comment.destroy
    respond_to do |format|
      format.html do
        flash[:success] = 'Comment deleted.'
        redirect_to @post
      end
      format.js # JavaScript response
    end
  end
  .
  .
end

For now, the code to handle an unsuccessful comment save has been removed. Within the new respond_to loops, only one event will fire – either format.html or format.js.

3. JavaScript

The HTML response is still coded right within the controller, but the JavaScript response defers to a separate file, whose default name is the same as that of the controller action: here, app/views/comments/create.js.erb and destroy.js.erb.

var comments_list = $('#comments-list');
comments_list.append('<%= escape_javascript(render partial: @comment) %>');
comments_list.find('div:last-child').addClass('last-comment');
$('#comments-count').html('This article has <%= escape_javascript(pluralize(@post.comments.count, 'comment')) %>');
// The following is not final implementation
$('#comment_author').val('');
$('#comment_email').val('');
$('#comment_content').val('');

The code shown above identifies the #comments-list div, and then appends to it the view partial rendered by Rails for the @comment just created and saved by the Comments controller. To add a little styling, the last comment gets a #last-comment id, which allows it to be highlighted by CSS. A further embellishment is the live update to the count of the article’s comments. The second half of the code enters blank values into the new comment form, effectively resetting it. (As noted, this is not the final implementation.)

Comment destruction is handled even more succinctly:

$('#comments-list').html('<%= escape_javascript(render partial: @post.comments) %>');
$('#comments-count').html('This article has <%= escape_javascript(pluralize(@post.comments.count, 'comment')) %>');

Here, Rails renders the partial view for all of the current post’s comments (naturally, sans the one that was just deleted), and JavaScript then replaces the existing contents of the #comments-list div with the newly rendered view.

4. Better handling of the new comment form

The code, as shown above, works until someone attempts to submit an invalid comment (e.g. one without any content, whose presence is validated by the Comments model). The original HTML response, as shown at the beginning, handled unsuccessful saves by re-rendering the view for @post, which included the [@post, @new_comment] form, which in turn rendered the app/views/shared/_error_messages.html.erb partial. The partial displayed error messages associated with the @new_comment, thus providing some feedback as to why the comment submission failed:

# app/views/posts/show.html.erb:
.
<div id="comment-form">
  <%= render 'comments/comment_form' %>
</div>
.

# app/views/comments/_comment_form.html.erb
<%= form_for [@post, @new_comment], remote: true do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
 .

# app/views/shared/_error_messages.html.erb
<% if object.errors.any? %>
  <p>This form has <%= pluralize(object.errors.count, 'problem') %></p>
  <ul>
    <% object.errors.full_messages.each do |msg| %>
      <li><%= msg %></li>
    <% end %>
  </ul>
<% end %>

The new code currently has no provision for the situation where comment submission fails. That is, the if statement in the create action of the Comments controller has no else clause. It is easy enough to put back the original code for the HTML response, but without the JavaScript option to back it up, it won’t do much good in most cases.

Restoring full original functionality requires just a bit of forward thinking or, if thinking isn’t cutting it, some trial and error with an eye on the Rails server logs. Overall, the solution is simple enough – views/comments/create.js.erb can include the following:

$('#comment-form').html('<%= escape_javascript(render('comments/comment_form')) %>');

Thus, JavaScript will replace the existing form with a newly rendered one, and clearing individual form fields with $(‘#comment_author).val(”); etc. becomes unnecessary. The only wrinkle here is that the @new_comment instance variable was originally defined in the Posts controller, as shown at the top of this article. However, comment creation – successful or not – happens in the Comments controller, where @new_comment is an unknown entity. Of course, it is easy enough to define it here too:

  def create
    @post = Post.find(params[:post_id])
    @comment = @post.comments.build(comment_params)
    .
    if @comment.save
      @new_comment = @post.comments.new

Unlike successful comment creation, the only thing that needs to happen when this process fails is the re-rendering of the new comment form, which will now display explanations for the failure in the _error_messages.html.erb partial. Here, then, the form needs to be rendered not a for brand new blank comment but for the comment whose creation just failed. Thus,

 def create
   @post = Post.find(params[:post_id])
   @comment = @post.comments.build(comment_params)
   .
   if @comment.save
     @new_comment = @post.comments.new
    .
   else
     @new_comment = @comment
     .

Finally, when comment creation fails, the JavaScript in create.js.erb, which appends the new comment to the list and modifies CSS, is no longer appropriate. The only thing JS needs to do here is update the contents of the #comment-form div with a re-rendered _comment_form.html.erb. By default, the call to format.js within the create action is routed to the namesake create.js.erb. However, this can be overridden by passing { render action: ‘file_name’ } to the call. And so, we end up with the single-line failed_create.js.erb

$('#comment-form').html('<%= escape_javascript(render('comments/comment_form')) %>');

and the final version of create.js.erb

var comments_list = $('#comments-list');
comments_list.append('<%= escape_javascript(render partial: @comment) %>');
comments_list.find('div:last-child').addClass('last-comment');
$('#comments-count').html('This article has <%= escape_javascript(pluralize(@post.comments.count, 'comment')) %>');
$('#comment-form').html('<%= escape_javascript(render('comments/comment_form')) %>');

which are called alternately from the final version of the create action in the Comments controller:

 def create
   @post = Post.find(params[:post_id])
   @comment = @post.comments.build(comment_params)
   @comment.quother = true if signed_in?
   if @comment.save
     @new_comment = @post.comments.new
     respond_to do |format|
       format.html do
         flash[:success] = 'Your comment has been posted.'
         redirect_to @post
       end
       format.js
     end
   else
     @new_comment = @comment
     respond_to do |format|
       format.html { render @post }
       format.js { render action: 'failed_save' }
     end
   end
 end

#

Advertisements
Tagged , , ,

5 thoughts on “Rails blog comments with AJAX

  1. Dane says:

    Do you have the full application source on Github?

  2. Diana says:

    Hello, great tutorial. I had a look at the github repo, whats the purpose of rendering the comment partial as instead of . I did try to use @comments, but i faced an issue whereby the ajax submission works and the comment is rendered. However, once i reload the post show page, the user association to the comment does not seem to be recognized and i can’t render the author of the comment as .

    • Diana says:

      Oh seems that i can’t enter code here. i meant comment partial: @post.reload.comment, instead of @comment. I tried to render partial as @comments, but this @comment.user.name statement will not work in the partial

      • Nikita says:

        Thanks for the question – it was interesting to come back to this app 4 years after I last touched it 🙂

        The root cause of the issue you see when using `@comments` is that the `@new_comment` instantiated in `PostsController#show` is included as one of the `@comments`. Since all of its fields are null, rendering the partial blows up. This is very counter-intuitive to me as the `@comments` instance variable is assigned before the new comment is instantiated. I haven’t used Rails much lately, so can’t say if this is a bug or a feature, and whether it’s specific to Rails 4.0.2 used by the app.

        Calling `@post.reload.comments` excludes the unpersisted comment, but this is a very poor fix as the view ends up making a direct and unnecessary db call. Instead, you can include the following in PostsController#show: `@comments = post.comments.reject { |c| c.id.nil? }`.

        Best of luck!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Advertisements
%d bloggers like this: