Search
⌘K
    to navigateEnterto select Escto close

    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 owner 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.

    • We will update the Jbuilder for show action in TasksController to include the comments data in tasks JSON.

    • 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. Declare a constant for the maximum length of the content like so:

    1module Constants
    2  is_sqlite_db = ActiveRecord::Base.connection_db_config.configuration_hash[:adapter] == "sqlite3"
    3  DB_REGEX_OPERATOR = is_sqlite_db ? "REGEXP" : "~*"
    4  MAX_TASK_TITLE_LENGTH = 125
    5  MAX_NAME_LENGTH = 255
    6  MAX_EMAIL_LENGTH = 255
    7  MAX_COMMENT_LENGTH = 511
    8end

    Now add the following line of code in the Comment model:

    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: Constants::MAX_COMMENT_LENGTH }
    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  has_many :comments, dependent: :destroy
    3
    4  # previous code
    5
    6  private
    7
    8    # previous code
    9end
    1class User < ApplicationRecord
    2  has_many :comments, dependent: :destroy
    3
    4  # previous code
    5  private
    6
    7    # previous code
    8end

    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    association :assigned_user, factory: :user
    6    association :task_owner, factory: :user
    7    title { Faker::Lorem.sentence[0..49] }
    8  end
    9end

    The dummy records that we are creating using factory-bot are based on the database models in our application. Models can also contain associations with other models. For thorough testing of the application we also need to create the associated records.

    For example, in this case, while creating a dummy task record, we also need to create the associated assigned_user and task_owner records. We can do so using the factory-bot by adding the association name and specifying the factory name which should be used to create the associated records.

    Hence, in the task factory we have added the association names and specified the user factory to be used to create those records.

    Note that, we are only selecting the first 50 characters generated by the Faker bot for task title so that the validation for title length holds true.

    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/models/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' * (Constants::MAX_COMMENT_LENGTH + 1)
    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  constraints(lambda { |req| req.format == :json }) do
    3    # ---Previous Routes---
    4    resources :comments, only: :create
    5  end
    6
    7  root "home#index"
    8  get '*path', to: 'home#index', via: :all
    9end

    Here only: :create specifies to create only one route which would be for the create action. Hence, a route of the format /comments is created, to which we can send a POST request and Rails will redirect it 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.merge(user: current_user))
    7    if comment.save
    8      render status: :ok, json: {}
    9    else
    10      render status: :unprocessable_entity,
    11        json: { error: comment.errors.full_messages.to_sentence }
    12    end
    13  end
    14
    15  private
    16
    17    def load_task
    18      @task = Task.find_by(id: comment_params[:task_id])
    19      unless @task
    20        render status: :not_found, json: { error: t("not_found", entity: "Task") }
    21      end
    22    end
    23
    24    def comment_params
    25      params.require(:comment).permit(:content, :task_id)
    26    end
    27end

    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 action in tasks controller as follows:

    1def show
    2  authorize @task
    3  @comments = @task.comments.order('created_at DESC')
    4end

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

    We need to update the Jbuilder for show action to include comments along with other task details.

    To do so update app/views/tasks/show.json.jbuilder view with the following lines of code:

    1json.task do
    2  json.extract! @task,
    3    :id,
    4    :slug,
    5    :title
    6
    7  json.assigned_user do
    8    json.id @task.assigned_user.id
    9    json.name @task.assigned_user.name
    10  end
    11
    12 json.comments @comments do |comment|
    13    json.extract! comment,
    14      :id,
    15      :content,
    16      :created_at
    17  end
    18
    19  json.task_owner do
    20    json.extract! @task.task_owner,
    21      :name
    22  end
    23end

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

    Now, fully replace ShowTask.jsx with the following content:

    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";
    9
    10const ShowTask = () => {
    11  const { slug } = useParams();
    12  const [task, setTask] = useState([]);
    13  const [pageLoading, setPageLoading] = useState(true);
    14  const [newComment, setNewComment] = useState("");
    15  const [loading, setLoading] = useState(false);
    16
    17  let history = useHistory();
    18
    19  const updateTask = () => {
    20    history.push(`/tasks/${task.slug}/edit`);
    21  };
    22
    23  const fetchTaskDetails = async () => {
    24    try {
    25      const response = await tasksApi.show(slug);
    26      setTask(response.data.task);
    27    } catch (error) {
    28      logger.error(error);
    29    } finally {
    30      setPageLoading(false);
    31    }
    32  };
    33
    34  const handleSubmit = async event => {
    35    event.preventDefault();
    36    setLoading(true);
    37    try {
    38      await commentsApi.create({
    39        comment: { content: newComment, task_id: task.id }
    40      });
    41      fetchTaskDetails();
    42      setNewComment("");
    43    } catch (error) {
    44      logger.error(error);
    45    } finally {
    46      setLoading(false);
    47    }
    48  };
    49
    50  useEffect(() => {
    51    fetchTaskDetails();
    52  }, []);
    53
    54  if (pageLoading) {
    55    return <PageLoader />;
    56  }
    57
    58  return (
    59    <Container>
    60      <div className="flex justify-between text-bb-gray-600 mt-10">
    61        <h1 className="pb-3 mt-5 mb-3 text-lg leading-5 font-bold">
    62          {task?.title}
    63        </h1>
    64        <div className="bg-bb-env px-2 mt-2 mb-4 rounded">
    65          <i
    66            className="text-2xl text-center transition duration-300
    67             ease-in-out ri-edit-line hover:text-bb-yellow"
    68            onClick={updateTask}
    69          ></i>
    70        </div>
    71      </div>
    72      <h2
    73        className="pb-3 mb-3 text-md leading-5 text-bb-gray-600
    74       text-opacity-50"
    75      >
    76        <span>Assigned To : </span>
    77        {task?.assigned_user.name}
    78      </h2>
    79      <h2 className="pb-3 mb-3 text-md leading-5 text-bb-gray-600 text-opacity-50">
    80        <span>Created By : </span>
    81        {task?.task_owner?.name}
    82      </h2>
    83      <Comments
    84        comments={task?.comments}
    85        setNewComment={setNewComment}
    86        handleSubmit={handleSubmit}
    87        newComment={newComment}
    88        loading={loading}
    89      />
    90    </Container>
    91  );
    92};
    93
    94export default ShowTask;

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

    Inside the Comments component we are using textarea which is an HTML form element to allow a user to add a comment. HTML input elements such as input, textarea and select maintain a state of their own which updates based on the user input.

    Whereas in React, the state is a property of the components. Hence it is better to combine the state of textarea with the React state which will make React's state the single source of truth.

    This way, React handles the responsibility of rendering the textarea element and controlling it's value thus making textarea a controlled component.

    To do so, we have passed the value of newComment from showTask component's state to textarea element's value prop and setNewComment function as a callback function to the onChange prop of textarea element.

    Now, any change in the textarea input will invoke the setNewComment function which will update ShowTask component's state and set the value of newComment. Updated value of newComment will be reflected in the textarea input.

    Once the submit button is clicked, handleSubmit function inside ShowTask.jsx will handle the comment post request.

    The post request will be sent 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', assigned_user_id: user_id, task_owner_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"
    Previous
    Next