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 assignee and the creator 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 lines:

    1class Task < ApplicationRecord
    2  belongs_to :user
    3  enum progress: { pending: 0, completed: 1 }
    4  has_many :comments, dependent: :destroy
    5  validates :title, presence: true
    6end

    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, :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: {user:  { only: %i[name id] } })
    4  @completed_tasks = tasks.completed
    5end

    Before moving on, let's briefly take a look at the methods we have used in the above code.

    as_json is a method that returns a hash of attributes present in the object. The hash will only consist of keys in string format rather than symbol format. Thus we'd have query using the string itself. Example:

    1user_name = @pending_tasks[0]["user"]["name"]

    We can invoke the as_json method on an object without passing any arguments. If we do not pass any arguments, then returned hash will include all the object's attributes.

    We are calling the as_json method on a collection of task objects which will return a hash of all attributes for each of the task objects.

    We have also passed an include statement to the as_json method. Passing include inside the as_json method includes a nested hash of the object's associations as a key in the hash. The key is named after the name of association. In our case, we are including the user association for each of the task objects.

    At last, we have passed the only method that limits the attributes included in the returned hash to the attributes which are passed in the only method.

    Note that only has been nested inside the user association hash, hence it will only limit the user's attributes and return the name and id of associated user for each task.

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

    Run the following command:

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

    Inside app/views/tasks/index.json.jbuilder, add the following lines of code:

    1json.tasks do
    2  json.pending @pending_tasks
    3  json.completed @completed_tasks
    4end

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