Search
⌘K
    to navigateEnterto select Escto close

    Marking a task as complete

    Features

    We will be adding a feature to mark a task as "complete".

    These are the expected changes:

    • Tasks will have two possible states: pending or completed. A task can be in any one of these states at a time. Let us call this state the progress of the task from now on.

    • Progress of every task will be pending by default when created.

    • Pending and completed tasks should be listed separately. Since completed tasks need not require any special attention, they can be listed at bottom of the page in dim colors.

    • On the tasks list page, there should be a checkbox for every task to switch their progress. Checked means the task is completed and unchecked means pending.

    • When the progress of a task is changed, it should immediately move to its designated list. In other words, a completed task should not stay in the pending task's list once its checkbox is checked.

    • The task delete button should only be visible for completed tasks.

    • A delete button should be present in the show task page which should allow users to delete pending tasks.

    • The assignee name need not be shown for completed tasks.

    • Both task assignee and task owner should be allowed to update the task's progress.

    Our tasks list page will look like this when the feature implementation is complete:

    Tasks progress feature

    Technical design

    Implementation of this feature can be done like so:

    • We need to add a new integer column named progress in the tasks table. We will use 0 to denote pending tasks and 1 to denote the completed ones.

    • It seems awful to assign meanings to numbers, memorize them, and then use it all across our project. But Rails already has solved this problem for us. We can use ActiveRecord::Enum to reference these numbers using names and operate on them fully utilizing the expressiveness of Ruby.

    • In the index action of the task controller, we will filter pending and completed tasks separately and will return them in the response.

    • We will add a Jbuilder for index action in TasksController to declare and render a JSON of completed and pending tasks.

    • Since we will reuse update action to change the progress of tasks, we need a finer control over the authorization now. That is, only the creator should be allowed to change the title and assignee of the task. But both creator and assignee should be allowed to change its progress.

    • For controlling authorization, we will create a before_action callback which checks who is updating which field and throws an error in case the operation is not allowed.

    • Towards the bottom of our tasks list page, we will add a new table to display the list of completed tasks.

    • We will conditionally render the delete button and assignee name based on the progress of the task.

    • To add more flexibility, we will also add the ability for the user to delete a task from the ShowTask component too. We will create a deleteTask function to delete the task even if the task is pending.

    Now let us start implementing the feature.

    Storing progress in DB

    Let's create a migration to add a column called progress to Tasks table:

    1bundle exec rails g migration AddProgressToTasks

    Open db/migrate/add_progress_to_tasks.rb file and add following lines:

    1class AddProgressToTasks < ActiveRecord::Migration[6.1]
    2  def change
    3    add_column :tasks, :progress, :integer, default: 0, null: false
    4  end
    5end

    Now let's run the migration:

    1bundle exec rails db:migrate

    Since the progress attribute will always consist of a predefined set of values, which are pending(0) and completed(1), we can make use of an enum data type.

    As discussed earlier, we will be making use of ActiveRecord::Enum for the same. It allows us to declare an enum attribute where the values map to integers in the database, but can be queried by name.

    ActiveRecord::Enum adds a lot of instances methods to the enum variable, which will help us easily retrieve data. For example:

    1task.completed? # => true
    2task.progress   # => "completed"
    

    To know more about the usage of enums, you can refer to the Using Enums in Rails chapter.

    Now let's define an enum in models/tasks.rb. Add the following line:

    1class Task < ApplicationRecord
    2  enum progress: { pending: 0, completed: 1 }
    3
    4  # previous code
    5
    6  private
    7
    8    # previous code
    9end

    By default all the tasks will be marked as pending, since we have already set the default value for the progress column as 0 in the migration that we wrote.

    To update the progress of a task, we will be making use of the already existing update action in tasks_controller.rb.

    As we already know, we should first permit the parameter progress in task_params method.

    Let's update the task_params method as shown below:

    1def task_params
    2  params.require(:task).permit(:title, :assigned_user_id, :progress)
    3end

    Now let's update the index action of tasks_controller to to send both pending and completed tasks separately in the response as shown below:

    1def index
    2  tasks = policy_scope(Task)
    3  @pending_tasks = tasks.pending.as_json(include: { assigned_user: { only: %i[name id] } })
    4  @completed_tasks = tasks.completed
    5end

    So far we have been using the as_json method to load the association data. The problem with using as_json to load assigned_user here is that for each pending_task a new query will be sent to the database to load its associated assigned_user.

    This is called an n+1 query issue where n number of additional queries are made to fetch some data that could be fetched in one query. To learn more about the n+1 query issue and how to avoid it you can refer to the in-depth n+1 queries and memoization chapter.

    We can avoid n+1 queries by using the includes method to fetch the assigned_user like so:

    1def index
    2  tasks = policy_scope(Task)
    3  @pending_tasks = tasks.pending.includes(:assigned_user)
    4  @completed_tasks = tasks.completed
    5end

    Specifying the association name in includes method will preload the association data for all pending tasks and no additional queries would be required to get the assigned_user details. To read about the includes method and how it works in-depth refer to the n+1 queries and memoization chapter.

    Now let's add a Jbuilder for index action to declare a JSON response structure for tasks.

    Run the following command to create the index.json.jbuilder file:

    1touch ./app/views/tasks/index.json.jbuilder

    Add the following lines of code inside the app/views/tasks/index.json.jbuilder file:

    1json.tasks do
    2  json.pending @pending_tasks do |pending_task|
    3    json.id pending_task.id
    4    json.title pending_task.title
    5    json.slug pending_task.slug
    6    json.progress pending_task.progress
    7    json.status pending_task.status
    8    json.assigned_user do
    9      json.extract! pending_task.assigned_user,
    10        :id,
    11        :name
    12    end
    13  end
    14
    15  json.completed @completed_tasks
    16end

    Finally we need to update our task factory to include a value for progress. Add the following line to test/factories/task.rb:

    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    progress {'pending'}
    9  end
    10end

    Partials in Jbuilder

    Attributes like id, title, slug and assigned_user in the tasks.pending JSON are also present in the JSON structure we had declared inside the show.json.jbuilder. Instead of writing the same code again we should declare a partial JSON structure which will contain the common attributes of a task object and then we can import that partial JSON in index.json.jbuilder and show.json.jbuilder.

    Jbuilder lets us declare partial JSONs. To declare a partial JSON structure for task, create a new file called _task.json.jbuilder using the following command:

    1touch app/views/tasks/_task.json.jbuilder

    Name of a partial JSON file is always prefixed with an underscore and it should match with the model name. In this case, the partial JSON will render a JSON of task attributes hence it is named after the Task model.

    Add the following lines of code inside the _task.json.jbuilder file:

    1json.id task.id
    2json.title task.title
    3json.slug task.slug
    4json.assigned_user do
    5  json.extract! task.assigned_user,
    6    :id,
    7    :name
    8end

    We have added the common attributes from show.json.jbuilder and index.json.jbuilder in the partial _task.json.jbuilder file. Now import the partial JSON file inside show.json.jbuilder. To do so, fully update the show.json.jbuilder with the following lines of code:

    1json.task do
    2  json.partial! "tasks/task", task: @task
    3
    4  json.comments @comments do |comment|
    5    json.extract! comment,
    6      :id,
    7      :content,
    8      :created_at
    9  end
    10
    11  json.task_owner do
    12    json.extract! @task.task_owner,
    13      :name
    14  end
    15end

    Similarly, fully update the index.json.jbuilder like so:

    1json.tasks do
    2  json.pending @pending_tasks do |pending_task|
    3    json.partial! "tasks/task", task: pending_task
    4    json.extract! pending_task,
    5      :progress,
    6      :status
    7  end
    8
    9  json.completed @completed_tasks
    10end

    Jbuilder provides a partial! method which accepts the relative path of the partial Jbuilder file and a variable which contains the object whose attributes are to be used to build the JSON. This variable can be accessed inside the partial Jbuilder file.

    In this case, the relative path of the partial Jbuilder file is tasks/task and the variable is task which contains the @task object. task can be accessed inside the partial _task.json.jbuilder file. To learn more about the partial! method you can refer to the official documentation for Jbuilder.

    Fine tuning authorization

    Now, we need to allow both creator and assignee to update the progress of tasks. More importantly, we need to disallow the assignee from updating any other fields and permit only the creator.

    Here in this case, the title, task_owner_id and assigned_user_id should be restricted attributes. That is, only the creator of the task should be allowed to modify their values.

    Conversely, progress attribute will be unrestricted. Both the creator and assignee should be able to change its value.

    We have two options in this situation:

    1. Instead of using task_params method to filter the params, use pundit's strong parameters style. This way, we will conditionally filter the request data from pundit policy class before accessing it from the update action. We will remove everything except the :progress parameter if the request is from the assignee.

      • The plus point of this approach is that it delegates all the authorization-related tasks to the pundit policy itself. The controller will remain lean.

      • The problem with this approach is its unpredictability. It silently ignores the disallowed params and executes the update action with the remaining ones. It won't throw an error even if all supplied params were filtered out.

      • So, from the user's point of view, saving changes would seem to be successful since no error message is shown. But the task's attributes won't change. The user might suspect this as a bug. Ideally, we should send a forbidden status if user is trying to modify unauthorized parameters.

    2. The second way is to add a before_action callback for update action. From there, if the request is from the assignee and if the request params contains any restricted attributes (title or user_id), throw authorization error.

      • The advantages and disadvantages of this approach are the opposite of the first method.

      • Advantage is that we will be able to handle the response based on the task params and send back an appropriate message in case of authorization errors.

      • Disadvantage is that we need to code the authorization logic in our controller itself.

    For the first method, its drawbacks overshadow its advantages. Therefore, let us proceed with the second approach.

    First, we need to grant access to update action for both assignee and creator in our pundit policy. Open app/policies/task_policy.rb and update the following lines:

    1  # ... previous code ...
    2  def show?
    3    task.task_owner_id == user.id || task.assigned_user_id == user.id
    4  end
    5
    6  # The condition for edit policy is the same as that of the show.
    7  # Hence, we can simply call `show?` inside the edit? policy here.
    8  def edit?
    9    show?
    10  end
    11
    12  # Similar in the case for update? policy.
    13  def update?
    14    show?
    15  end
    16  # ... previous code ...

    Now, open app/models/task.rb and define the attributes that are restricted for assignee:

    1class Task < ApplicationRecord
    2  RESTRICTED_ATTRIBUTES = %i[title task_owner_id assigned_user_id]
    3  # ... previous code ...
    4end

    Now, in app/controllers/tasks_controller.rb, add the following lines:

    1class TasksController < ApplicationController
    2  after_action :verify_authorized, except: :index
    3  after_action :verify_policy_scoped, only: :index
    4  before_action :authenticate_user_using_x_auth_token
    5  before_action :load_task, only: %i[show update destroy]
    6  before_action :ensure_authorized_update_to_restricted_attrs, only: %i[update]
    7
    8  # ...previous code...
    9
    10  private
    11
    12    def task_params
    13      params.require(:task).permit(:title, :assigned_user_id, :progress, :status)
    14    end
    15
    16    def ensure_authorized_update_to_restricted_attrs
    17      is_editing_restricted_params = Task::RESTRICTED_ATTRIBUTES.any? { |a| task_params.key?(a) }
    18      is_not_owner = @task.task_owner_id != @current_user.id
    19      if is_editing_restricted_params && is_not_owner
    20        handle_authorization_error
    21      end
    22    end
    23
    24  # ...previous code...

    The beauty of writing clean and fluent code is that there isn't a requirement for another person to explain what the method does. People will be able to figure it out on their own because the naming is very accurate. It's almost like reading an English sentence!

    The overall gist of the above method is that if the request is not from the owner of the task and if it is trying to edit the restricted attributes, then we will raise handle_authorization_error before the action update is even executed.

    Since the request encounters an error in the before_action callback, the update action won't run. The error message will be returned as the response.

    Adding toggle for pending/completed tasks

    Let us now move on to the frontend. We need to make a new list to show completed tasks and we need to create a toggle mechanism in the UI to let a user toggle the progress of a task as completed or pending.

    Fully replace the content of app/javascript/src/components/Dashboard/index.jsx, with the following code:

    1import React, { useState, useEffect } from "react";
    2import { all, isNil, isEmpty, either } from "ramda";
    3import tasksApi from "apis/tasks";
    4import Container from "components/Container";
    5import PageLoader from "components/PageLoader";
    6import Table from "components/Tasks/Table/index";
    7
    8const Dashboard = ({ history }) => {
    9  const [pendingTasks, setPendingTasks] = useState([]);
    10  const [completedTasks, setCompletedTasks] = useState([]);
    11  const [loading, setLoading] = useState(true);
    12
    13  const fetchTasks = async () => {
    14    try {
    15      const response = await tasksApi.list();
    16      setPendingTasks(response.data.tasks.pending);
    17      setCompletedTasks(response.data.tasks.completed);
    18    } catch (error) {
    19      logger.error(error);
    20    } finally {
    21      setLoading(false);
    22    }
    23  };
    24
    25  const destroyTask = async slug => {
    26    try {
    27      await tasksApi.destroy(slug);
    28      await fetchTasks();
    29    } catch (error) {
    30      logger.error(error);
    31    }
    32  };
    33
    34  const handleProgressToggle = async ({ slug, progress }) => {
    35    try {
    36      await tasksApi.update({ slug, payload: { task: { progress } } });
    37      await fetchTasks();
    38    } catch (error) {
    39      logger.error(error);
    40    } finally {
    41      setLoading(false);
    42    }
    43  };
    44
    45  const showTask = slug => {
    46    history.push(`/tasks/${slug}/show`);
    47  };
    48
    49  useEffect(() => {
    50    fetchTasks();
    51  }, []);
    52
    53  if (loading) {
    54    return (
    55      <div className="w-screen h-screen">
    56        <PageLoader />
    57      </div>
    58    );
    59  }
    60
    61  if (all(either(isNil, isEmpty), [pendingTasks, completedTasks])) {
    62    return (
    63      <Container>
    64        <h1 className="my-5 text-xl leading-5 text-center">
    65          You have not created or been assigned any tasks 🥳
    66        </h1>
    67      </Container>
    68    );
    69  }
    70
    71  return (
    72    <Container>
    73      {!either(isNil, isEmpty)(pendingTasks) && (
    74        <Table
    75          data={pendingTasks}
    76          destroyTask={destroyTask}
    77          showTask={showTask}
    78          handleProgressToggle={handleProgressToggle}
    79        />
    80      )}
    81      {!either(isNil, isEmpty)(completedTasks) && (
    82        <Table
    83          type="completed"
    84          data={completedTasks}
    85          destroyTask={destroyTask}
    86          handleProgressToggle={handleProgressToggle}
    87        />
    88      )}
    89    </Container>
    90  );
    91};
    92
    93export default Dashboard;

    Fully replace the content of Table/index.jsxwith the following lines of code:

    1import React from "react";
    2import TableHeader from "./TableHeader";
    3import TableRow from "./TableRow";
    4
    5const Table = ({
    6  type = "pending",
    7  data,
    8  destroyTask,
    9  showTask,
    10  handleProgressToggle,
    11  starTask
    12}) => {
    13  return (
    14    <div className="flex flex-col mt-10 ">
    15      <div className="my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
    16        <div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
    17          <div className="overflow-hidden border-b border-gray-200 shadow md:custom-box-shadow">
    18            <table className="min-w-full divide-y divide-gray-200">
    19              <TableHeader type={type} />
    20              <TableRow
    21                data={data}
    22                destroyTask={destroyTask}
    23                showTask={showTask}
    24                type={type}
    25                handleProgressToggle={handleProgressToggle}
    26                starTask={starTask}
    27              />
    28            </table>
    29          </div>
    30        </div>
    31      </div>
    32    </div>
    33  );
    34};
    35
    36export default Table;

    Here, we are passing type and handleProgressToggle as props to TableRow. The type denotes the current progress, that is pending or completed.

    Now, inside TableHeader.jsx, fully replace with the following content:

    1import React from "react";
    2import { compose, head, join, juxt, tail, toUpper } from "ramda";
    3
    4const TableHeader = ({ type }) => {
    5  const getTitleCase = compose(join(""), juxt([compose(toUpper, head), tail]));
    6
    7  const title = `${getTitleCase(type)} Tasks`;
    8
    9  return (
    10    <thead>
    11      <tr>
    12        <th className="w-1"></th>
    13        <th
    14          className="px-6 py-3 text-xs font-bold
    15        leading-4 tracking-wider text-left text-bb-gray-600
    16        text-opacity-50 uppercase bg-gray-50"
    17        >
    18          {title}
    19        </th>
    20        {type === "pending" && (
    21          <th
    22            className="px-6 py-3 text-sm font-bold leading-4
    23          tracking-wider text-left text-bb-gray-600
    24          text-opacity-50 bg-gray-50"
    25          >
    26            Assigned To
    27          </th>
    28        )}
    29        {type === "completed" && (
    30          <>
    31            <th style={{ width: "164px" }}></th>
    32            <th
    33              className="pl-6 py-3 text-sm font-bold leading-4
    34            tracking-wider text-center text-bb-gray-600
    35            text-opacity-50 bg-gray-50"
    36            >
    37              Delete
    38            </th>
    39          </>
    40        )}
    41        {type === "pending" && (
    42          <th
    43            className="pl-6 py-3 text-sm font-bold leading-4
    44          tracking-wider text-center text-bb-gray-600
    45          text-opacity-50 bg-gray-50"
    46          >
    47            Starred
    48          </th>
    49        )}
    50      </tr>
    51    </thead>
    52  );
    53};
    54
    55export default TableHeader;

    In the TableHeader, we are conditionally rendering table headers using the && operator. Such blocks will be rendered only if the condition preceding it is truthy.

    But that approach has an edge case. If the condition preceding the JSX evaluates to 0, which is falsy, the JSX won't be rendered. But react will display 0 in the UI where we expected no JSX or block to be rendered.

    Therefore be careful when using && to render conditional blocks when the condition statement is a function/variable that may not strictly be of type boolean.

    Also, if the grouped statements are too long or too complex, then it ought to be moved out into a separate component within that file, so as to improve readability.

    Now, we are going to use a library called classnames in TableRow.jsx. It is a JavaScript library for conditionally joining CSS classes together.

    React terms these joined attributes as classNames.

    We use this library extensively in most of our projects and thus will be using the same throughout this book.

    Install classnames package by running the following command:

    1yarn add classnames

    Now, inside TableRow.jsx, fully replace with the following content:

    1import React from "react";
    2import classnames from "classnames";
    3import PropTypes from "prop-types";
    4
    5import Tooltip from "components/Tooltip";
    6
    7const TableRow = ({
    8  type = "pending",
    9  data,
    10  destroyTask,
    11  showTask,
    12  handleProgressToggle
    13}) => {
    14  const isCompleted = type === "completed";
    15  const toggledProgress = isCompleted ? "pending" : "completed";
    16
    17  return (
    18    <tbody className="bg-white divide-y divide-bb-gray-600">
    19      {data.map(rowData => (
    20        <tr key={rowData.id}>
    21          <td className="px-6 py-4 text-center">
    22            <input
    23              type="checkbox"
    24              checked={isCompleted}
    25              className="ml-6 w-4 h-4 text-bb-purple border-gray-300
    26               rounded form-checkbox focus:ring-bb-purple cursor-pointer"
    27              onChange={() =>
    28                handleProgressToggle({
    29                  slug: rowData.slug,
    30                  progress: toggledProgress
    31                })
    32              }
    33            />
    34          </td>
    35          <td
    36            className={classnames(
    37              "block w-64 px-6 py-4 text-sm font-medium leading-8 text-bb-purple capitalize truncate",
    38              {
    39                "cursor-pointer": !isCompleted
    40              },
    41              { "text-opacity-50": isCompleted }
    42            )}
    43            onClick={() => !isCompleted && showTask(rowData.slug)}
    44          >
    45            <Tooltip content={rowData.title} delay={200} direction="top">
    46              <div className="truncate max-w-64 ">{rowData.title}</div>
    47            </Tooltip>
    48          </td>
    49          {!isCompleted && (
    50            <td
    51              className="px-6 py-4 text-sm font-medium leading-5
    52             text-bb-gray-600 whitespace-no-wrap"
    53            >
    54              {rowData.assigned_user.name}
    55            </td>
    56          )}
    57          {isCompleted && (
    58            <>
    59              <td style={{ width: "164px" }}></td>
    60              <td className="px-6 py-4 text-center cursor-pointer">
    61                <i
    62                  className="text-2xl text-center text-bb-border
    63                  transition duration-300 ease-in-out
    64                  ri-delete-bin-5-line hover:text-bb-red"
    65                  onClick={() => destroyTask(rowData.slug)}
    66                ></i>
    67              </td>
    68            </>
    69          )}
    70        </tr>
    71      ))}
    72    </tbody>
    73  );
    74};
    75
    76TableRow.propTypes = {
    77  data: PropTypes.array.isRequired,
    78  type: PropTypes.string,
    79  destroyTask: PropTypes.func,
    80  showTask: PropTypes.func,
    81  handleProgressToggle: PropTypes.func
    82};
    83
    84export default TableRow;

    After making the above mentioned changes, we will be able to see two tables in the dashboard. One shows the list of completed tasks and other one shows the list of pending tasks.

    The handleProgressToggle function allows a user to toggle the progress of a task between completed/pending.

    A user can toggle between the two states of the task by clicking on the input checkbox part of each TableRow.

    Previously we had added the ability for the user to delete completed tasks. But in order to be more flexible, we can add the same delete feature in our ShowTask component and allow deletion of even pending tasks.

    Let us add another delete button inside the ShowTask component. This will allow the user to delete a task even if the task is pending.

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

    Users can now delete pending tasks if they wish to on the show task page.

    Now let's commit these changes:

    1git add -A
    2git commit -m "Added progress to tasks"
    Previous
    Next