Learn Ruby on Rails Book

Adding comments to Tasks

In this chapter, we'll add the feature to Display and Add Comments on a Task's show page.

Introducing 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 for the Comment model. By passing content:text, we add a column 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. Hence, that's the reason we pass task:references and user:references which adds the required associations and foreign key constraints rather than to make those changes manually.

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.0]
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.

There are other files generated as well by the above migration but let's not worry about them right 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

And run the migration using rails db:migrate for the changes to take effect.

1bundle exec rails db:migrate

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 Comment model

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 model/table, like so:

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 comemnt 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

We add a nested route for the comments as we are going to allow to add a comment from an existing task's show page.

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. To do so, run the following command:

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;

So one issue that we have to take care is to make sure that, only the owner can edit a task. Currently we will be using front-end authorization, since we already have the current logged in user id(in local-storage) and creator id of the task. But in the upcoming chapters, we will be adding pundit policy for the same.

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

Now let's commit these changes:

1git add -A
2git commit -m "Added comments to tasks"