In this chapter, we'll add the feature to Display and Add Comments on a Task's show page.
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.
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
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.
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
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"