How to create a comment and reply system in Ruby on Rails

James Garcia
2020-10-30 14:12:11 UTC

This is a tutorial about building a simple comment and reply system with Ruby on Rails. I was looking for a way to build this without a gem. Most tutorials use gems such as closure_tree, or ancestry. This seemed more than what I wanted. I was able to get some guidance from Nick Haskins the author of Playbook Thirty-nine and StackOverflow. You can find the completed project here.

Let’s Start

We start off by creating a standard Ruby on Rails app. Create a folder called “commentapp”. Open your terminal and run the following commands. mkdir commentapp Change into the “commentapp” folder cd commentapp Create the new rails app run rails new . Open the your code editor and open the “commentapp” folder. I am using VS Code. Test that the app was created successfully. In the terminal run. rails s Open the browser and navigate to http://localhost:3000/

default rail page

Add Bootstrap

We are going to add Bootstrap for styling. This is an optional step. In your terminal run. yarn add bootstrap Open app/javascript folder and create a new folder called “stylesheets”. Inside the “stylesheets” folder create a file called “application.scss”. Open the “application.scss” file and add.

// app/javascript/stylesheets/application.scss
@import "bootstrap/scss/bootstrap";

Open application.js file and add.

// app/javascript/packs/application.js
import "../stylesheets/application.scss";

Open application.html.erb and add.

# app/views/layouts/application.html.erb
<%= stylesheet_pack_tag 'application' %>

Add Posts

Next we will scaffold the Posts. Open terminal and run. rails g scaffold post title:string body:text This will create several files and update the routes file for Post. We will only be working with a few of these files. The scaffold has created a migration file that will be used to create a table for the Post model. This located in db/migrate folder.

# db/migrate/<timestamp>_create_posts.rb
class CreatePosts < ActiveRecord::Migration[6.0]
  def change
    create_table :posts do |t|
      t.string :title
      t.text :body

      t.timestamps
    end
  end
end

This will create a Post table with columns for title and body. For the migration to take effect we will need to run. rails db:migrate If we run the app now we will still get the default “Yay! You’re on Rails!” page. We need to change the root path to be the Posts index page. This can be done by updating the routes file.

# config/routes.rb
root to: 'posts#index'

Start the rails server, if you stopped it, and then refresh http://localhost:3000/ now the page will show

post index

Click on “New Post” and create a new post to give the app some content. At this point, if you click Create Post button without entering a Title or Body it will create an empty post. To not allow this to happen we need to add validation to the Post model.

# app/models/post.rb
class Post < ApplicationRecord
  validates :title, :body, presence: true
end

I will add some styling with Bootstrap to some of the Post views. Before we can add styles we should delete the stylesheet that was generated when we created the scaffold. Unfortunately this stylesheet will be recreated each time we generate a scaffold. So if styles do not look correct when using Bootstrap it could be that the scaffolds.scss needs to be removed. Navigate to app/assets/stylesheets and delete scaffolds.scss file. We will start to add styles to the application.html.erb page.

# app/views/layouts/application.html.erb
  <body>
    <div class="container">
      <%= yield %>
    </div>
  </body>

Post index page.

# app/views/posts/index.html.erb
<p id="notice"><%= notice %></p>

<h1>Posts</h1>

<%= link_to 'New Post', new_post_path, class: 'btn btn-primary' %>
<hr>
<% @posts.each do |post| %>
  <div class="card">
    <div class="card-body">
      <h5 class="card-title"><%= link_to post.title, post_path(post) %></h5> # changed title to a link to show the post
      <p class="card-text"><%= truncate(post.body, length: 60) %></p>
      <%= link_to 'Edit', edit_post_path(post), class: 'card-link' %>
    </div>
  </div>
<% end %>

new post index

Post form partial

# app/views/posts/_form.html.erb
<%= form_with(model: post, local: true) do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation">
      <h4 class="text-danger"><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h4>

      <ul class="list-group">
        <% post.errors.full_messages.each do |message| %>
          <li class="list-group-item list-group-item-danger"><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <%= form.label :title %>
    <%= form.text_field :title, class: 'form-control' %>
  </div>

  <div class="form-group">
    <%= form.label :body %>
    <%= form.text_area :body, class: 'form-control' %>
  </div>

  <%= form.submit "Save Post", class: 'btn btn-primary' %>
<% end %>

New post form

# app/views/posts/new.html.erb
<div class="card my-5">
  <div class="card-body">
    <h1>New Post</h1>

    <%= render 'form', post: @post %>

    <%= link_to 'Back', posts_path, class: 'btn btn-light mt-2' %>
  </div>
</div>

new post form

Post edit form

# app/views/posts/edit.html.erb
<div class="card my-5">
  <div class="card-body">
    <h1>Edit Post</h1>

    <%= render 'form', post: @post %>

    <div class="mt-2">
      <%= link_to 'Show', @post, class: 'btn btn-info mr-2' %>
      <%= link_to 'Destroy', @post, method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger mr-2' %> # moved the destroy button from index to here (optional)
      <%= link_to 'Back', posts_path, class: 'btn btn-light' %>
    </div>
  </div>
</div>

Post show

# app/views/posts/show.html.erb
<div class="card my-5">
  <div class="card-body">
    <p id="notice"><%= notice %></p>

    <h1 class="text-center"><%= @post.title %></h1>
    <hr >
    <div class="my-4">
      <%= simple_format(@post.body) %>
    </div>

    <%= link_to 'Back', posts_path, class: 'btn btn-light' %>
  </div>
</div>

Comments

Next we will scaffold the Comments. Open terminal and run. rails g scaffold comment body:text post_id:integer parent_id:integer Again this will create several files, but we will only be working with a few. One thing to note is to delete the scaffolds.scss if you are using Bootstrap or some other CSS library. For the migration to take effect we will need to run. rails db:migrate We will need to update the routes to nest the Comment within Post. Open the routes.rb file and make the following changes. This will cause the comments route to become similar to ../posts/1/comments/1.

# config/routes/rb
Rails.application.routes.draw do
  resources :posts do
    resources :comments
  end
  ...
end

Changes will need to be made to the Comment and Post models. With this change Comments belong to a Post and Post can have many Comments. Add the following to the Post model.

# app/models/post.rb
has_many :comments

Add the following to the Comments model.

# app/models/comments.rb
belongs_to :post
validates :body, presence: true

If you try going to any of the Comments routes they will not work as expected because of us nesting Comment within Post. To get this back to a working app we will first add a new comments form to the Post show.html.erb page.

# app/views/posts/show.html.erb
...
<div class="card my-1"> # place this card blow the post body.
  <div class="card-body">
    <p class="font-weight-bold">Comments</p>
    <%= form_with(model: [@post, @post.comments.build]) do |f| %>
      <div class="form-group">
        <%= f.label 'New comment' %>
        <%= f.text_area :body, class: 'form-control' %>
      </div>
      <%= f.submit 'Submit', class: 'btn btn-primary' %>
    <% end %>
  </div>
</div>

comment form

Now we need to update the Comments controller Create action.

# app/controllers/comments_controller.rb
def create
    @post = Post.find(params[:post_id])
    @comment = @post.comments.new(comment_params)

    respond_to do |format|
      if @comment.save
        format.html { redirect_to @post, notice: 'Comment was successfully created.' } # changed the redirect to @post
...

First we find the post by using the post_id that is sent with the params. Next update the @comment variable to create a new comment that will belong to the post. If you try to create a comment it will be created, but you will not know unless you check the database. To fix this we need to update the post show.html.erb. Below the Comments card add the following.

# app/show.html.erb
...
<%= render @post.comments %>

The above line will iterate over each comment using a comment partial. We will need to create the comment partial. Open app/views/comments and create a _comment.html.erb file. Add the following to the partial.

# app/views/comments/_comment.html.erb
<div class="card">
  <div class="card-body">
    <%= comment.body %>
  </div>
</div>

Add a comment and it will be added to the bottom of the post. You will notice that an empty card will appear below the new comment. For some reason a new comment is automatically built, but not created.

new comment

We need to wrap the comment partial within an unless condition only to show saved comments.

# app/views/comments/_comment.html.erb
<% unless !comment.persisted? %>
  <div class="card">
    ...
  </div>
<% end %>

Replies

We are now able to create post and comments so the next item to cover is replies. With my approach we will not be scaffolding replies. Replies are basically comments so we will reuse comments and make some additions. First let’s make changes to the Comment model.

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to  :post
  belongs_to  :parent, class_name: 'Comment', optional: true
  has_many    :replies, class_name: 'Comment', foreign_key: :parent_id, dependent: :destroy

  validates :body, presence: :true
end

We are creating a self-joining association with the Comment model. You can read more about it here in the Rails Guides. This will allow us to store replies in the comments table and will use the parent id column to store the comment id that the reply is associated with. Next, we will update the comment partial to add a reply link and a loop to render the replies.

# app/views/comments/_comment.html.erb
      <%= comment.body %>
      <%= link_to 'reply', new_post_comment_path(@post, parent_id: comment.id), remote: true, class: 'd-block' %>
      </div>
  </div>
  <% if comment.replies.any? %>
    <% comment.replies.each do |reply| %>
      <%= render partial: 'comments/reply', locals: { reply: reply } %>
    <% end %>
  <% end %>

We add the reply link with remote: true to let rails know this an Ajax call and we want to respond with JavaScript. The JavaScript will be used to insert a reply form on the Show page without refreshing the page. In the link we are passing params for post id and the parent id. Next we check the comment has any replies and if so loop through them using the reply partial. We will have to create the reply partial. Add the _reply.html.erb file in app/views/comments folder.

# app/views/comments/_reply.html.erb
<div class="card ml-5">
  <div class="card-body">
    <%= reply.body %>
  </div>
</div>

This will create another card below the comment and will have a left margin to push it over and looks like it belongs to the comment. We will not be able to see that until we can add a reply, which we can work on next. We will add a form partial for the replies. Add _reply_form.html.erb file in app/views/comments folder.

# app/views/comments/_reply_form.html.erb
<div class="card ml-5">
  <div class="card-body">
    <%= form_with(model: [@post, @comment]) do |f| %>
      <%= f.hidden_field :parent_id %>
      <div class="form-group">
        <%= f.label 'Reply' %>
        <%= f.text_area :body, class: 'form-control' %>
      </div>
      <%= f.submit 'Submit', class: 'btn btn-primary' %>
      <%= link_to 'Cancel', post_path(@post), class: 'btn btn-secondary' %>
    <% end %>
  </div>
</div>

We add a hidden field for the parent id, which will be filled from our New action in the Comments controller. The rest is pretty standard. Now that we have the form created we need to update the New action in the Comments controller.

# app/controllers/comments_controller.rb
  def new
    @post = Post.find(params[:post_id])
    @comment = @post.comments.new(parent_id: params[:parent_id])
  end

This will build a new reply, which is really a new comment to pass to the reply form. Since we are passing the parent id it will fill the hidden field in the reply form. Because this comment has a parent id we know that it is a reply. Let’s move on before I start confusing myself. Since the reply link is an Ajax call the New action, we just updated, knows to respond to a JavaScript file with the same name as the action. Add a new.js.erb file to app/views/comments folder.

# app/views/comments/new.js.erb
document.querySelector("#reply-form-<%= @comment.parent_id %>").innerHTML = ("<%= j render 'reply_form', comment: @comment %>")

This will find the div with the ID of reply-form-<parent_id> and insert the reply form into the div. We need to create the div in the _comment.html.erb partial. Add the following to the _comment.html.erb just below the reply loop.

# app/views/comments/_comment.html.erb
  <div id="reply-form-<%= comment.id %>"></div>

This will be a placeholder for the reply form to be inserted. We dynamically build the id using the comment id, which will be the parent id for the reply. That is why in the new.js.erb we are using the @comment.parent_id. If you click on the reply link a reply form should appear below the comment. Once you have replies it will place the form below the last reply for the comment. Add a reply to a comment.

reply form

After creating a reply it will be added below the comment that it is associated with, but will also appear as a comment. That is because a reply is stored in the Comment database. To fix this we will need to update the _comment.html.erb partial to include another condition.

# app/views/comments/_comment.html.erb
<% unless comment.parent_id || !comment.persisted? %>

So now if a comment has a parent_id (meaning it is a reply) or it is not a saved comment then it will not show in the list of Comments only in the replies of the of the comment.

I hope this was helpful.

Credits Playbook Thirty-nine by Nick Haskins StackOverflow