Learn Ruby on Rails Book

Marking the progress of a task

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

Here the progress attribute will always consist of a predefined set of values, which are pending and completed. Thus in such scenarios, where the values are predefined, we can make use of an enum data type. 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. Also it 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 this blog

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. But for this to work we should first permit a parameter called progress. 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 to to send both pending and completed tasks separately in the json response. Update the index action in tasks_controller as shown below :

1  def index
2    tasks = policy_scope(Task)
3    pending_tasks = tasks.pending
4    completed_tasks = tasks.completed
5    render status: :ok, json: { tasks: { pending: pending_tasks, completed: completed_tasks } }
6  end

Adding toggle for pending/completed tasks

Let's now create a toggle mechanism in the UI, so that a user can toggle the progress of a task as completed or pending. We will also be listing out the completed and pending tasks as two different tables on the dashboard. Inside app/javascript/src/components/Dashboard/index.jsx, make the following changes:

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

Inside Table/index.jsx, add 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, 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 className="px-6 py-3 text-xs font-bold
14        leading-4 tracking-wider text-left text-bb-gray-600
15        text-opacity-50 uppercase bg-gray-50">
16          {title}
17        </th>
18        {type === "pending" && (
19          <th className="px-6 py-3 text-sm font-bold leading-4
20          tracking-wider text-left text-bb-gray-600
21          text-opacity-50 bg-gray-50">
22            Assigned To
23          </th>
24        )}
25        {type === "completed" && (
26          <>
27            <th style={{ width: "164px" }}></th>
28            <th className="pl-6 py-3 text-sm font-bold leading-4
29            tracking-wider text-center text-bb-gray-600
30            text-opacity-50 bg-gray-50">
31              Delete
32            </th>
33          </>
34        )}
35        {type === "pending" && (
36          <th className="pl-6 py-3 text-sm font-bold leading-4
37          tracking-wider text-center text-bb-gray-600
38          text-opacity-50 bg-gray-50">
39            Starred
40          </th>
41        )}
42      </tr>
43    </thead>
44  );
45};
46
47export default TableHeader;

In the TableHeader, we are conditionally rendering table headers using the && operator. This is often an effective way to group conditional jsx together. But 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, inside TableRow.jsx, 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              "px-6 py-4 text-sm font-medium leading-5 whitespace-no-wrap text-bb-purple",
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 className="px-6 py-4 text-sm font-medium leading-5
47             text-bb-gray-600 whitespace-no-wrap">
48              {rowData.assigned_user.name}
49            </td>
50          )}
51          {isCompleted && (
52            <>
53              <td style={{ width: "164px" }}></td>
54              <td className="px-6 py-4 text-center cursor-pointer">
55                <i
56                  className="text-2xl text-center text-bb-border
57                  transition duration-300 ease-in-out
58                  ri-delete-bin-5-line hover:text-bb-red"
59                  onClick={() => destroyTask(rowData.slug)}
60                ></i>
61              </td>
62            </>
63          )}
64        </tr>
65      ))}
66    </tbody>
67  );
68};
69
70TableRow.propTypes = {
71  data: PropTypes.array.isRequired,
72  type: PropTypes.string,
73  destroyTask: PropTypes.func,
74  showTask: PropTypes.func,
75  handleProgressToggle: PropTypes.func,
76};
77
78export 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.

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