DEV Community

James Garcia
James Garcia

Posted on • Updated on

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

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";
Enter fullscreen mode Exit fullscreen mode

Open application.js file and add.

// app/javascript/packs/application.js
import "../stylesheets/application.scss";
Enter fullscreen mode Exit fullscreen mode

Open application.html.erb and add.

# app/views/layouts/application.html.erb
<%= stylesheet_pack_tag 'application' %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add the following to the Comments model.

# app/models/comments.rb
belongs_to :post
validates :body, presence: true
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %>")
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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? %>
Enter fullscreen mode Exit fullscreen mode

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

Top comments (16)

Collapse
 
wcurzor profile image
Weltone Curzor

hello such great idea but i got the problem below with some part of your code

Collapse
 
wcurzor profile image
Weltone Curzor

these particular problem is just when i hit the reply link

NoMethodError in Comments#new
Showing E:/Projects/app/app/views/comments/_form.html.erb where line #1 raised:

undefined method `comments_path' for #ActionView::Base:0x00000000026a70

Did you mean? font_path

Extracted source (around line #1):

<%= form_with(model: comment, local: true) do |form| %>

<% if comment.errors.any? %>



<%= pluralize(comment.errors.count, "error") %> prohibited this comment from being saved:

  <ul>
Collapse
 
jamgar profile image
James Garcia

@wcurzor my suggestion is to start looking over the Replies section of the article to see if you have everything as the article. One thing to check if you have the remote: true set in the file below. That would be the only "gotcha" that I can see.

# 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>
Enter fullscreen mode Exit fullscreen mode

Here is the completed app.

Collapse
 
godfreymutebi profile image
Mutebi Godfrey

great

Collapse
 
godfreymutebi profile image
Mutebi Godfrey

ee

Thread Thread
 
godfreymutebi profile image
Mutebi Godfrey

m

Thread Thread
 
godfreymutebi profile image
Mutebi Godfrey

mm

Collapse
 
Sloan, the sloth mascot
Comment deleted
 
godfreymutebi profile image
Mutebi Godfrey

l

Thread Thread
 
godfreymutebi profile image
Mutebi Godfrey

l

Collapse
 
jamgar profile image
James Garcia

Thank you @wcurzor As for the error, I will look into it.

Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
hurabafzal2 profile image
hurabafzal2

i wanted to convert whole system to ajax firstly the comment field is not getting empty after reply and the replies to comments are not showing on the page at first but when i refresh the page they are shown

Collapse
 
godfreymutebi profile image
Mutebi Godfrey

any idea on how to reply the reply button with a form using turbo_frames in rails rails 7

Collapse
 
jamgar profile image
James Garcia

I will need to revise this post to use turbo. Sorry don’t have the answer right now.

Collapse
 
godfreymutebi profile image
Mutebi Godfrey

recent