Creating a nested comment system in Ruby on Rails

JonthumbPosted by Jonathan Weyermann on January 16, 2018 at 12:00 AM
Comments

One of the most classic things to build in Ruby on Rails is a blog app. In fact, this site is built with Ruby on Rails. Each blog Post object instance can have multiple Comments attached to it. While this is very simple and functional, comments on blogs often have additional functionality: The ability to respond to a specific comment, and thus create a nested comment structure. Thus I've recently begun exploring how I could add this to my own blog. Here are my results:

class PostsController < ApplicationController
  expose :post, find_by: :slug
  expose(:posts) { Post.public }
  expose(:user) { post.user }
end

This is my PostsController. It uses Decent Exposure to simplify the controller actions. On this controller, there are show and index actions, but they're not explicitly written out because the expose applies to all controller actions. The post uses Friendly Id to show by slug not not by id (for prettier urls), and therefore I've set the post to find_by slug. posts is for the index view, and public is a method in the post model that determines which posts should be shown (in my case, based on a specific date, and whether I've set them to published).


class CommentsController < PostsController

  def create
    comment = post.comments.create(comment_params)

    if comment.valid? && comment.save
      flash[:notice] = 'Comment Added'
      session.delete(:comment)
      redirect_to post_path(post, anchor: 'comment')
    else
      session[:comment] = comment
      flash[:alert] = Array(comment.errors).to_sentence
      redirect_to new_post_comment_path(post, comment_id: params["comment"]["reply_comment"], anchor: 'comment')
    end
  end

  private

  def comment_params
    params.require(:comment).permit(:name, :email, :body, :post_id, :reply_comment)
  end
end

The comments controller inherits from the post controller (the comments are also route nested beneath the posts ). Depending on whether validations pass, I create the comment and display appropriate error messaging.


class Comment < ActiveRecord::Base
  belongs_to :post

  validates_presence_of :name, :body
  validates_email_format_of :email, :message => 'please enter a valid email'

  def sub_comments
    Comment.where(reply_comment: id)
  end
end

The important part of the comment model is sub_comments. We look for the active record attribute reply_comment to find the comments that reply to this comment. This helps us know where to place the comment in the view.

we set the the reply_comment attribute as a hidden attribute inside a _comments.html.erb partial

<div class="comments">
  <h2 class="page-header"><%= post.comments.length %> Comments</h2>
  <%= render partial: 'partials/comment', collection: post.root_comments %>

  <div class="well add-comment">
    <h2 class="page-header"><%= comment_descriptor %></h2>
    <a name="comment"></a>
    <% if flash[:notice] %>
      <div class="alert alert-success"><%= flash[:notice] %></div>
    <% end %>
    <% if flash[:alert] %>
      <div class="alert alert-danger"><%= flash[:alert] %></div>
    <% end %>
    <%= simple_form_for [post, post.comments.build(session[:comment]) ], html: { class: 'form-horizontal', data: { toggle: 'validator' }} do |f| %>
      <%= f.error :base, error_method: :to_sentence %>
      <%= f.hidden_field :reply_comment, value: params[:comment_id] %>
      <%= f.input :name, required: true, input_html: {maxlength: 60 } %>
      <%= f.input :email, error: 'Please specify a valid email', required: true, type: 'email', input_html: { maxlength: 100 } %>
      <%= f.input :body, required: true, label: "Comment", type: 'text', input_html: { rows: '8', maxlength: 3600 } %>
      <%= f.button :submit, "Submit Comment", class: 'btn btn-primary' %>
    <% end %>
  </div>
</div>

the partial is called from a  <%= render 'partials/comments' %> inside the post/show. This partial represents all comments and the reply form. It renders a collection comment partials which represent the individual comments.

here is the comment partial. the comment_id is passed from the current comment when 'reply' is clicked

<div class="comment">
  <div class="row">
    <div class="col-sm-2 col-xs-3">
      <%= image_tag("user.jpg") %>
    </div>
  <div class="col-sm-8 col-xs-7">
    <div class="well">
      <h4><%= comment.name %><span>/<%= comment.created_at.to_time.strftime('%B %e at %l:%M %p') %></span></h4>
        <%= comment.body %>
      </div>
    </div>
    <div class="col-xs-2">
      <%= link_to "Reply", "#{post_path(post.slug,comment_id: comment.id)}#comment" %>
    </div>
  </div>
  <div class="row">
    <div class="col-xs-1">
    </div>
    <div class="col-xs-11">
      <%= render partial: 'partials/comment', collection: comment.sub_comments %>
    </div>
  </div>
</div>

this partial is rendered once for every comment that is added to a post. You'll notice that sub_comments are rendered as a sub-collection of the same comment partial - It can recursively render itself as long as there are comments replying to comments.


Root comments are all comments that don't have don't have a reply comment attached - They are not replying to anyone and are at highest level of the comment structure

class Post < ActiveRecord::Base

  ...

  def root_comments
    comments.where(reply_comment: nil)
  end
end




The result is what's visible in the opening screenshot. 

User

OBHeZRaIxUkimvQNovember 6 2019 at 11:06 AM

OnsdcxXbzVIh
Reply
User

FhbutqJdNovember 6 2019 at 11:06 AM

zbnONtBGsqJ
Reply

Add Comment