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