Learn Ruby on Rails Book

Adding comments to tasks

Features

In this chapter, we'll add a feature to Display and Add Comments on the Task's show page. We are going to make the following changes in our application:

  • Both assignee and the creator can add comments on their tasks.
  • All added comments will be displayed in descending order of the time at which they were added. i.e. the most recently added comment will be shown at the top.
  • The comments list will include the date and time at which the comment was posted.

Here is how the feature will look like when implemented: Comments feature

Technical design

The implementation of this feature is fairly straightforward:

  • We will be creating a new model, named Comment, having these fields: a non-empty text content, a reference to the User who made the comment, and a reference to its parent Task.
  • We will open an API route for creating comments.
  • We don't need a separate API for listing comments. We can include the comments in Task's show action.
  • In frontend, we will add a new component, named Comments, to display the comments section. This will include a textarea to enter comment content, a button to post it via API and a list to display the previously created comments.
  • We will include the Comments component at the bottom of the ShowTask component as per the design.

Let's now implement the feature.

Creating Comment model

We'll create a new model, Comment. Run the following command in the terminal:

1bundle exec rails generate model Comment content:text task:references user:references

Here we have generated the migration script as well Comment model.

By passing argument content:text, our migration file will be pre-populated with the script to add content to the comments table having data type as text.

Now if we observe, every comment would belong to a Task and as well to the User who is adding the comment. Passing task:references and user:references as the arguments to the generate model command automatically adds the required associations and foreign key constraints.

This is how the comment.rb file would look after the required associations are added:

1class Comment < ApplicationRecord
2  belongs_to :user # Each comment belongs to a single user
3  belongs_to :task # Each comment belongs to a single task
4end

Now open the last migration file under the db/migrate folder. It should be as follows:

1class CreateComments < ActiveRecord::Migration[6.1]
2  def change
3    create_table :comments do |t|
4      t.text :content
5      t.references :task, null: false, foreign_key: true
6      t.references :user, null: false, foreign_key: true
7      t.timestamps
8    end
9  end
10end

t.references :user adds column user_id to the comments table and by passing option foreign_key: true, a foreign key constraint is added.

Run the migration using rails db:migrate for the changes to take effect:

1bundle exec rails db:migrate

Now, let's add the presence and length validations to the content field the same way we did in Task's title field:

1class Comment < ApplicationRecord
2  belongs_to :user # Each comment belongs to a single user
3  belongs_to :task # Each comment belongs to a single task
4
5  validates :content, presence: true, length: { maximum: 120 }
6end

Now similarly we have to make changes in the Task and User models to introduce associations for comments. Note that each task can have many comments:

1class Task < ApplicationRecord
2  belongs_to :user
3  has_many :comments, dependent: :destroy
4  validates :title, presence: true, length: { maximum: 50 }
5end
1class User < ApplicationRecord
2  VALID_EMAIL_REGEX = /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i.freeze
3
4  has_many :comments, dependent: :destroy
5  has_many :tasks, dependent: :destroy, foreign_key: :user_id
6
7  has_secure_password
8  has_secure_token :authentication_token
9
10  validates :email, presence: true,
11                    uniqueness: true,
12                    length: { maximum: 50 },
13                    format: { with: VALID_EMAIL_REGEX }
14  validates :password, presence: true, confirmation: true, length: { minimum: 6 }
15  validates :password_confirmation, presence: true, on: :create
16
17  before_save :to_lowercase
18
19  private
20
21    def to_lowercase
22      email.downcase!
23    end
24end

dependent: :destroy is a callback which makes sure that when a task is deleted, all the comments added to it are deleted as well. Similarly, the same callback is passed in the User model, which would delete all the comments by a user when the user is deleted.

Adding tests for the Comment model

To test comments, we can make use of factory-bot as we did in the previous chapter. First, we need factories to generate tasks and comments.

Create test/factories/task.rb and populate it with the following content:

1# frozen_string_literal: true
2
3FactoryBot.define do
4  factory :task do
5    user
6    title { Faker::Lorem.sentence }
7    progress { 'pending' }
8    status { 'unstarred' }
9  end
10end

Also, create test/factories/comment.rb and populate it with the following code:

1# frozen_string_literal: true
2
3FactoryBot.define do
4  factory :comment do
5    user
6    task
7    content { Faker::Lorem.paragraph }
8  end
9end

You might have noticed that we are not assigning values for some fields. Also, keen readers might have realized that these fields are model associations. And you are right. This is how we define associations in a factory.

FactoryBot is capable of automatically generating associations for entities. So if we generate an instance of comment, an instance of user and task will be auto-generated, given their factories are defined. Therefore we don't need to manually define users and tasks and assign them to it dynamically.

Now, populate test/model/comment_test.rb with the following content:

1require 'test_helper'
2
3class CommentTest < ActiveSupport::TestCase
4  def setup
5    @comment = build(:comment)
6  end
7
8  # embed new test cases here...
9end

Let's test whether our comment validations are correct or not:

1def test_comment_should_be_invalid_without_content
2  @comment.content = ''
3  assert @comment.invalid?
4end
5
6def test_comment_content_should_not_exceed_maximum_length
7  @comment.content = 'a' * 200
8  assert @comment.invalid?
9end

Now since we have tested the validations, let's make sure that a valid comment is getting created. For such test scenarios where we need to check whether the DB data has been created/updated, we can make use of assert_difference, where we check the difference between previous count and current count of items in the table:

1def test_valid_comment_should_be_saved
2  assert_difference 'Comment.count' do
3    @comment.save
4  end
5end

The above test validates that after saving the comment count gets increased by 1.

Now let's test whether the association between User and Comment are properly validated or not:

1def test_comment_should_not_be_valid_without_user
2  @comment.user = nil
3  assert @comment.invalid?
4end

And similarly, association between Task and Comment:

1def test_comment_should_not_be_valid_without_task
2  @comment.task = nil
3  assert @comment.invalid?
4end

Adding route for Comments

Now let's add routes for create action of Comments:

1Rails.application.routes.draw do
2  # ---Previous Routes---
3  resources :comments, only: :create
4end

only: [:create] specifies to create only one route which would be for the create action. Hence, a route of the format /comments is created which would send a POST request to the create action of the comments controller.

Adding Comments controller

Let's add a controller for comments:

1bundle exec rails generate controller Comments

We'll only be adding the create action in our controller as we have defined a route only for this action:

1class CommentsController < ApplicationController
2  before_action :load_task
3  before_action :authenticate_user_using_x_auth_token
4
5  def create
6    comment = @task.comments.new(comment_params)
7    comment.user = current_user
8    if comment.save
9      render status: :ok, json: {}
10    else
11      render status: :unprocessable_entity,
12             json: { errors: comment.errors.full_messages.to_sentence }
13    end
14  end
15
16  private
17
18  def load_task
19    @task = Task.find(comment_params[:task_id])
20  end
21
22  def comment_params
23    params.require(:comment).permit(:content, :task_id)
24  end
25end

Adding Comments to the task show page

We'll make a slight change to the show action in the Tasks controller to load all the comments of the loaded task so that we can render those comments on the task's show page. Modify show in tasks controller as follows:

1def show
2  authorize @task
3  comments = @task.comments.order('created_at DESC')
4  task_creator = User.find(@task.creator_id).name
5  render status: :ok, json: { task: @task, assigned_user: @task.user,
6                              comments: comments, task_creator: task_creator }
7end

Here, we are using order('created_at DESC') since we need to display the latest comments first.

Now let's add React code for the Comment section.

Let's create the comments API first:

1touch app/javascript/src/apis/comments.js

Open the file and add the following code to it:

1import axios from "axios";
2
3const create = (payload) => axios.post(`/comments`, payload);
4
5const commentsApi = {
6  create,
7};
8
9export default commentsApi;

To list the comments, add an index.js file:

1mkdir -p app/javascript/src/components/Comments
2touch app/javascript/src/components/Comments/index.jsx

Inside Comments/index.jsx add the following contents:

1import React from "react";
2
3import Button from "components/Button";
4
5const Comments = ({ comments, loading, setNewComment, handleSubmit }) => {
6  return (
7    <>
8      <form onSubmit={handleSubmit} className="mb-16">
9        <div className="sm:grid sm:grid-cols-1 sm:gap-1 sm:items-start">
10          <label
11            className="block text-sm font-medium
12            text-nitro-gray-800 sm:mt-px sm:pt-2"
13          >
14            Comment
15          </label>
16          <textarea
17            placeholder="Ask a question or post an update"
18            rows={3}
19            className="flex-1 block w-full p-2 border border-bb-border
20            rounded-md shadow-sm resize-none text-bb-gray-600
21            focus:ring-bb-purple focus:border-bb-purple sm:text-sm"
22            onChange={(e) => setNewComment(e.target.value)}
23          ></textarea>
24        </div>
25        <Button type="submit" buttonText="Comment" loading={loading} />
26      </form>
27      {comments?.map((comment, index) => (
28        <div
29          key={comment.id}
30          className="px-8 py-3 my-2 leading-5 flex justify-between
31          border border-bb-border text-md rounded"
32        >
33          <p className="text-bb-gray-600" key={index}>
34            {comment.content}
35          </p>
36          <p className="text-opacity-50 text-bb-gray-600">
37            {new Date(comment.created_at).toLocaleString()}
38          </p>
39        </div>
40      ))}
41    </>
42  );
43};
44
45export default Comments;

Now, replace with the following contents inside ShowTask.jsx:

1import React, { useEffect, useState } from "react";
2import { useParams, useHistory } from "react-router-dom";
3
4import tasksApi from "apis/tasks";
5import commentsApi from "apis/comments";
6import Container from "components/Container";
7import PageLoader from "components/PageLoader";
8import Comments from "components/Comments";
9import { getFromLocalStorage } from "helpers/storage";
10
11const ShowTask = () => {
12  const { slug } = useParams();
13  const [taskDetails, setTaskDetails] = useState([]);
14  const [assignedUser, setAssignedUser] = useState([]);
15  const [comments, setComments] = useState([]);
16  const [pageLoading, setPageLoading] = useState(true);
17  const [taskCreator, setTaskCreator] = useState("");
18  const [newComment, setNewComment] = useState("");
19  const [loading, setLoading] = useState(false);
20  const [taskId, setTaskId] = useState("");
21
22  let history = useHistory();
23
24  const destroyTask = async () => {
25    try {
26      await tasksApi.destroy(taskDetails.slug);
27    } catch (error) {
28      logger.error(error);
29    } finally {
30      history.push("/");
31    }
32  };
33
34  const updateTask = () => {
35    history.push(`/tasks/${taskDetails.slug}/edit`);
36  };
37
38  const fetchTaskDetails = async () => {
39    try {
40      const response = await tasksApi.show(slug);
41      setTaskDetails(response.data.task);
42      setAssignedUser(response.data.assigned_user);
43      setComments(response.data.comments);
44      setTaskCreator(response.data.task_creator);
45      setTaskId(response.data.task.id);
46    } catch (error) {
47      logger.error(error);
48    } finally {
49      setPageLoading(false);
50    }
51  };
52
53  const handleSubmit = async (event) => {
54    event.preventDefault();
55    try {
56      await commentsApi.create({
57        comment: { content: newComment, task_id: taskId },
58      });
59      fetchTaskDetails();
60      setLoading(false);
61    } catch (error) {
62      logger.error(error);
63      setLoading(false);
64    }
65  };
66
67  useEffect(() => {
68    fetchTaskDetails();
69  }, []);
70
71  if (pageLoading) {
72    return <PageLoader />;
73  }
74
75  return (
76    <Container>
77      <div className="flex justify-between text-bb-gray-600 mt-10">
78        <h1 className="pb-3 mt-5 mb-3 text-lg leading-5 font-bold">
79          {taskDetails?.title}
80        </h1>
81        <div className="bg-bb-env px-2 mt-2 mb-4 rounded">
82          <i
83            className="text-2xl text-center transition duration-300
84             ease-in-out ri-delete-bin-5-line hover:text-bb-red mr-2"
85            onClick={destroyTask}
86          ></i>
87          <i
88            className="text-2xl text-center transition duration-300
89             ease-in-out ri-edit-line hover:text-bb-yellow"
90            onClick={updateTask}
91          ></i>
92        </div>
93      </div>
94      <h2
95        className="pb-3 mb-3 text-md leading-5 text-bb-gray-600
96       text-opacity-50"
97      >
98        <span>Assigned To : </span>
99        {assignedUser?.name}
100      </h2>
101      <h2 className="pb-3 mb-3 text-md leading-5 text-bb-gray-600 text-opacity-50">
102        <span>Created By : </span>
103        {taskCreator}
104      </h2>
105      <Comments
106        comments={comments}
107        setNewComment={setNewComment}
108        handleSubmit={handleSubmit}
109        loading={loading}
110      />
111    </Container>
112  );
113};
114
115export default ShowTask;

Now the comments can be seen in the task show page.

In the Comments component we are using textarea to allow a user to add a comment. The function setNewComment is handling the value inside the textarea and handleSubmit function inside ShowTask.jsx is handling the comment post request.

When we click on the submit button, the post request will be send to the comments_path and on that route comments_controller's create action will handle that request.

Now a user can comment on the particular task.

delete vs destroy methods

delete will only delete the current object record from the database but not its associated records from the database.

destroy will delete the current object record along with its associated records from the database.

Let's understand more, by doing an example from the Rails console. We will be using Rails sandbox console so that all changes we do in the example rolls back when we exit the console.

Open Rails sandbox console using the below command:

1bundle exec rails console --sandbox

First let's create a new task:

1irb(main):001:0> user_id = User.first.id
2irb(main):002:0> task = Task.create(title: 'Read Ruby articles', user_id: user_id, creator_id: user_id)

We have taken id of the first user in user_id variable and used it while creating the task.

Now create a new comment for this task. Remember while creating the comment we are setting task_id as the id of the above created task, which we can access through task.id:

1irb(main):003:0> Comment.create(content: 'nice', task_id: task.id, user_id: user_id)

Let's try to delete this task using delete method:

1irb(main):004:0> Task.find_by_id(task.id).delete

Running the above command will throw the following error:

1SQLite3::ConstraintException: FOREIGN KEY constraint failed (ActiveRecord::InvalidForeignKey)
2/Users/ruby/gems/3.0.0/gems/sqlite3-1.4.2/lib/sqlite3/statement.rb:108:in `step': FOREIGN KEY constraint failed (SQLite3::ConstraintException)

We are getting the above error because the task we have created above is associated with the comment. delete will not delete associated records from the database.

If we want to delete this task then first we have to delete its associated comment from the comments table.

Here we can use the destroy method since it will also delete the associated records from the database.

1irb(main):005:0> Task.find_by_id(task.id).destroy

Once the above command is run, the task will be deleted successfully along with its associated comment.

The usage of delete or destroy matters, based on the use case and context.

If multiple parent objects share common children object, then calling destroy on specific parent object will destroy the children objects which are shared among all parents.

So use the delete method when no association is there for the entity to be deleted. In other cases where we have to destroy the associations too, use the destroy method.

destroy also runs callbacks on the model while delete doesn't. For example before_destroy and after_destroy callbacks.

Similarly we have delete_all and destroy_all methods. These methods behave the same as delete and destroy methods but the only difference is that they execute on all records matching conditions instead of one record.

Let's run the following command to exit from the console:

1irb(main):006:0> exit

Let's commit changes made in this chapter:

1git add -A
2git commit -m "Added comments to tasks"
⌘K
    to navigateEnterto select Escto close
    Previous
    Next