I2: Adding Comments

Most blogs allow the reader to interact with the content by posting comments. Let's add some simple comment functionality.

Designing the Comment Model

First, we need to brainstorm what a comment is...what kinds of data does it have...

  • It's attached to an article
  • It has an author name
  • It has a body

With that understanding, let's create a Comment model. Switch over to your terminal and enter this line:

$ bin/rails generate model Comment author_name:string body:text article:references

We've already gone through what files this generator creates, we'll be most interested in the migration file and the comment.rb.

Setting up the Migration

Open the migration file that the generator created, db/migrate/some-timestamp_create_comments.rb. Let's see the fields that were added:

t.string :author_name
t.text :body
t.references :article, index: true, foreign_key: true

t.timestamps null: false

Once that's complete, go to your terminal and run the migration:

$ bin/rake db:migrate

Relationships

The power of SQL databases is the ability to express relationships between elements of data. We can join together the information about an order with the information about a customer. Or in our case here, join together an article in the articles table with its comments in the comments table. We do this by using foreign keys.

Foreign keys are a way of marking one-to-one and one-to-many relationships. An article might have zero, five, or one hundred comments. But a comment only belongs to one article. These objects have a one-to-many relationship -- one article connects to many comments.

Part of the big deal with Rails is that it makes working with these relationships very easy. When we created the migration for comments we started with a references field named article. The Rails convention for a one-to-many relationship:

  • the objects on the "many" end should have a foreign key referencing the "one" object.
  • that foreign key should be titled with the name of the "one" object, then an underscore, then "id".

In this case one article has many comments, so each comment has a field named article_id.

Following this convention will get us a lot of functionality "for free." Open your app/models/comment.rb and check it out:

class Comment < ActiveRecord::Base
  belongs_to :article
end

The reason this belongs_to field already exists is because when we generated the Comment model, we included this line: article:references. What that does is tell Rails that we want this model to reference the Article model, thus creating a one-way relationship from Comment to Article. You can see this in action in our migration on the line t.references :article.

A comment relates to a single article, it "belongs to" an article. We then want to declare the other side of the relationship inside app/models/article.rb like this:

class Article < ActiveRecord::Base
  has_many :comments
end

Unlike how belongs_to :article was implemented for us on the creation of the Comment model because of the references, the has_many :comments relationship must be entered in manually.

Now an article "has many" comments, and a comment "belongs to" an article. We have explained to Rails that these objects have a one-to-many relationship.

Testing in the Console

Let's use the console to test how this relationship works in code. If you don't have a console open, go to your terminal and enter rails console from your project directory. If you have a console open already, enter the command reload! to refresh any code changes.

Run the following commands one at a time and observe the output:

$ a = Article.first
$ a.comments
$ Comment.new
$ a.comments.new
$ a.comments

When you called the comments method on object a, it gave you back a blank array because that article doesn't have any comments. When you executed Comment.new it gave you back a blank Comment object with those fields we defined in the migration.

But, if you look closely, when you did a.comments.new the comment object you got back wasn't quite blank -- it has the article_id field already filled in with the ID number of article a. Additionally, the following (last) call to a.comments shows that the new comment object has already been added to the in-memory collection for the a article object.

Try creating a few comments for that article like this:

$ c = a.comments.new
$ c.author_name = "Daffy Duck"
$ c.body = "I think this article is thhh-thhh-thupid!"
$ c.save
$ d = a.comments.create(author_name: "Chewbacca", body: "RAWR!")

For the first comment, c, I used a series of commands like we've done before. For the second comment, d, I used the create method. new doesn't send the data to the database until you call save. With create you build and save to the database all in one step.

Now that you've created a few comments, try executing a.comments again. Did your comments all show up? When I did it, only one comment came back. The console tries to minimize the number of times it talks to the database, so sometimes if you ask it to do something it's already done, it'll get the information from the cache instead of really asking the database -- giving you the same answer it gave the first time. That can be annoying. To force it to clear the cache and lookup the accurate information, try this:

$ a.reload
$ a.comments

You'll see that the article has associated comments. Now we need to integrate them into the article display.

Displaying Comments for an Article

We want to display any comments underneath their parent article. Open app/views/articles/show.html.erb and add the following lines right before the link to the articles list:

<h3>Comments</h3>
<%= render partial: 'articles/comment', collection: @article.comments.reverse %>

This renders a partial named "comment" and that we want to do it once for each element in the collection @article.comments. We saw in the console that when we call the .comments method on an article we'll get back an array of its associated comment objects. This render line will pass each element of that array one at a time into the partial named "comment". Now we need to create the file app/views/articles/_comment.html.erb and add this code:

<div>
  <h4>Comment by <%= comment.author_name %></h4>
  <p class="comment"><%= comment.body %></p>
</div>

Display one of your articles where you created the comments, and they should all show up.

Web-Based Comment Creation

Good start, but our users can't get into the console to create their comments. We'll need to create a web interface.

Building a Comment Form Partial

The lazy option would be to add a "New Comment" link to the article show page. A user would read the article, click the link, go to the new comment form, enter their comment, click save, and return to the article.

But, in reality, we expect to enter the comment directly on the article page. Let's look at how to embed the new comment form onto the article show.

Just above the "Back to Articles List" in the articles show.html.erb:

<%= render partial: 'comments/form' %>

This is expecting a file app/views/comments/_form.html.erb, so create the app/views/comments/ directory with the _form.html.erb file, and add this starter content:

<h3>Post a Comment</h3>
<p>(Comment form will go here)</p>

Look at an article in your browser to make sure that partial is showing up. Then we can start figuring out the details of the form.

In the ArticlesController

First look in your articles_controller.rb for the new method.

Remember how we created a blank Article object so Rails could figure out which fields an article has? We need to do the same thing before we create a form for the Comment.

But when we view the article and display the comment form we're not running the article's new method, we're running the show method. So we'll need to create a blank Comment object inside that show method like this:

@comment = Comment.new
@comment.article_id = @article.id

Due to the Rails' mass-assignment protection, the article_id attribute of the new Comment object needs to be manually assigned with the id of the Article. Why do you think we use Comment.new instead of @article.comments.new?

Improving the Comment Form

Now we can create a form inside our comments/_form.html.erb partial like this:

<h3>Post a Comment</h3>

<%= form_for [ @article, @comment ] do |f| %>
  <p>
    <%= f.label :author_name %><br/>
    <%= f.text_field :author_name %>
  </p>
  <p>
    <%= f.label :body %><br/>
    <%= f.text_area :body %>
  </p>
  <p>
    <%= f.submit 'Submit' %>
  </p>
<% end %>

Trying the Comment Form

Save and refresh your web browser and you'll get an error like this:

NoMethodError in Articles#show
Showing app/views/comments/_form.html.erb where line #3 raised:
undefined method `article_comments_path' for #

The form_for helper is trying to build the form so that it submits to article_comments_path. That's a helper which we expect to be created by the router, but we haven't told the router anything about Comments yet. Open config/routes.rb and update your article to specify comments as a sub-resource.

resources :articles do
  resources :comments
end

Then refresh your browser and your form should show up. Try filling out the comments form and click SUBMIT -- you'll get an error about uninitialized constant CommentsController.

Did you figure out why we aren't using @article.comments.new? If you want, edit the show action and replace @comment = Comment.new with @comment = @article.comments.new. Refresh the browser. What do you see?

For me, there is an extra empty comment at the end of the list of comments. That is due to the fact that @article.comments.new has added the new Comment to the in-memory collection for the Article. Don't forget to change this back.

Creating a Comments Controller

Just like we needed an articles_controller.rb to manipulate our Article objects, we'll need a comments_controller.rb.

Switch over to your terminal to generate it:

$ bin/rails generate controller comments

Writing CommentsController.create

The comment form is attempting to create a new Comment object which triggers the create action. How do we write a create?

You can cheat by looking at the create method in your articles_controller.rb. For your comments_controller.rb, the instructions should be the same just replace Article with Comment.

There is one tricky bit, though! We need to assign the article id to our comment like this:

def create
  @comment = Comment.new(comment_params)
  @comment.article_id = params[:article_id]

  @comment.save

  redirect_to article_path(@comment.article)
end

def comment_params
  params.require(:comment).permit(:author_name, :body)
end

After Creation

As a user, imagine you write a witty comment, click save, then what would you expect? Probably to see the article page, maybe automatically scrolling down to your comment.

At the end of our create action in CommentsController, how do we handle the redirect? Instead of showing them the single comment, let's go back to the article page:

redirect_to article_path(@comment.article)

Recall that article_path needs to know which article we want to see. We might not have an @article object in this controller action, but we can find the Article associated with this Comment by calling @comment.article.

Test out your form to create another comment now -- and it should work!

Cleaning Up

We've got some decent comment functionality, but there are a few things we should add and tweak.

Comments Count

Let's make it so where the view template has the "Comments" header it displays how many comments there are, like "Comments (3)". Open up your article's show.html.erb and change the comments header so it looks like this:

<h3>Comments (<%= @article.comments.size %>)</h3>

Form Labels

The comments form looks a little silly with "Author Name". It should probably say "Your Name", right? To change the text that the label helper prints out, you pass in the desired text as a second parameter, like this:

<%= f.label :author_name, "Your Name"  %>

Change your comments/_form.html.erb so it has labels "Your Name" and "Your Comment".

Add a Timestamp to the Comment Display

We should add something about when the comment was posted. Rails has a really neat helper named distance_of_time_in_words which takes two dates and creates a text description of their difference like "32 minutes later", "3 months later", and so on.

You can use it in your _comment.html.erb partial like this:

<p>Posted <%= distance_of_time_in_words(comment.article.created_at, comment.created_at) %> later</p>

With that, you're done with I2!

Time to Save to GitHub Again!

Now that the comments feature has been added push it up to GitHub:

$ git add .
$ git commit -m "finish blog comments feature"
$ git push