Back
Chapters

Starring tasks

Search icon
Search Book
โŒ˜K

Features

Let us introduce a new feature to let users star and unstar tasks. Starred tasks are of higher importance than unstarred ones. This helps users organize their important tasks easily.

These are the requirements of the feature.

  • Starred tasks should always be displayed at the top. They should be sorted based on the time of starring. That is, the last starred task should be shown at the very top.

  • We need to show a star icon on every task row in the list. Clicking on this icon should toggle the starred/unstarred status of the task.

  • No success notification should be displayed when a task's status is updated.

  • Both the assignee and creator should be able to star/unstar a task.

  • Both starred and unstarred tasks will have star-shaped icons attached to them. Starred tasks should have a bright yellow filling and the unstarred ones will be left transparent with white borders. This will let users identify starred tasks quickly.

Task starring feature

Technical design

To implement this feature, we need to introduce the following changes:

On the backend

  • Add a new column in tasks table to store the starred status of the task.

  • Add a new enum attribute called status in the Task model with two possible values: starred and unstarred.

  • Permit the status parameter when retrieving the Action Controller parameters for task, such that we can persist latest status to database.

  • For pending as well as completed tasks, we will load the starred and unstarred tasks separately and sort them, then merge them using the + operator of arrays.

  • For the purpose of sorting tasks, we will use the order method of ActiveRecord class which accepts one or more than one column names of a table along with the order direction. It returns a collection of records which are sorted on the basis of the columns passed as argument, in the direction specified.

  • For ensuring authorization, we do not need to do anything. In the code we wrote earlier, no params except title and assigned_user_id are restricted. So status will be accessible to both creator and assignee.

On the frontend

  • Add a new column in the list view displaying the starred status icon. CSS classes are applied to this icon based on the value of the task's status.

  • Add a new function in the Dashboard component to toggle starred state of a task. This function will be using the update API like the handleProgressToggle function.

  • Pass this function as props to the TableRow component and use it as the click handler of the star icon.

We are now ready to start coding. Let us dive in.

Storing starred status in DB

Let's create a migration to add a column called status to the tasks table:

1bundle exec rails g migration AddStatusToTasks

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

1class AddStatusToTasks < ActiveRecord::Migration[6.1]
2  def change
3    add_column :tasks, :status, :string, default: "unstarred", null: false
4  end
5end

Now let's run the migration:

1bundle exec rails db:migrate

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

1class Task < ApplicationRecord
2  enum status: { unstarred: "unstarred", starred: "starred" }
3
4  # previous code
5
6  private
7
8    # previous code
9end

Let's go ahead and permit a parameter called status. Let's update the task_params method as shown below:

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

Like we did for progress in the previous chapter, we also need to add a value for status to task factory. 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    status { 'unstarred' }
10  end
11end

Update index action to send starred status

Now let us update our index action to retrieve the sorted list of tasks using the order method we have discussed earlier.

1# previous code...
2def index
3  tasks = policy_scope(Task)
4
5  pending_starred = tasks.pending.starred.includes(:assigned_user).order('updated_at DESC')
6  pending_unstarred = tasks.pending.unstarred.includes(:assigned_user).order('updated_at DESC')
7  @pending_tasks = pending_starred + pending_unstarred
8
9  completed_starred = tasks.completed.starred.order('updated_at DESC')
10  completed_unstarred = tasks.completed.unstarred.order('updated_at DESC')
11  @completed_tasks = completed_starred + completed_unstarred
12end
13# previous code...

Now take a moment, and check if there's something that we can improve in the above mentioned index action.

Our index action is starting to get longer. Also, we are now repeating a similar block of code for both pending and completed tasks.

The Rails ideology is to keep the controllers as skinny as possible. That is why we often delegate the logic into concerns, helpers etc.

So let us move the repeating piece of code to the Task model:

1class Task < ApplicationRecord
2
3  # previous code
4
5  private
6
7    def self.of_status(progress)
8      if progress == :pending
9        starred = pending.starred.order("updated_at DESC")
10        unstarred = pending.unstarred.order("updated_at DESC")
11      else
12        starred = completed.starred.order("updated_at DESC")
13        unstarred = completed.unstarred.order("updated_at DESC")
14      end
15      starred + unstarred
16    end
17
18    # previous code
19end

Inside the of_status method, we are conditionally querying the tasks based on their progress. We can call this method in task controller's index action and pass the progress as an argument.

The of_status method returns the tasks in such a way that the starred ones precede the unstarred ones. Also, these tasks are sorted in decreasing order of their updated_at timestamp i.e the last updated task is returned first.

Let's update the index action in tasks_controller to make use of the class method that we wrote. Replace the index method with the following content:

1# previous code...
2def index
3  tasks = policy_scope(Task)
4  @pending_tasks = tasks.includes(:assigned_user).of_status(:pending)
5  @completed_tasks = tasks.of_status(:completed)
6end
7# previous code...

See, our index method is much simpler to read now.

Adding a toggle to star/unstar tasks

Now, let's add the method starTask. It would use the update API to toggle the status of the task.

Replace app/javascript/src/components/Dashboard/index.jsx with the following code:

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

We do not want to show a success notification when a task is starred/unstarred hence we have added a quiet query param in the API call for updating the task status like we did for task progress update API in the previous chapter.

We don't need to make any changes to the update action in the backend or to the update tasks API connector.

In app/javascript/src/components/Tasks/Table/index.jsx, forward the starTask function to TableRow component.

Replace content of app/javascript/src/components/Tasks/Table/index.jsx with the following 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
17          className="inline-block min-w-full py-2
18            align-middle sm:px-6 lg:px-8"
19        >
20          <div
21            className="overflow-hidden border-b
22              border-gray-200 shadow md:custom-box-shadow"
23          >
24            <table className="min-w-full divide-y divide-gray-200">
25              <TableHeader type={type} />
26              <TableRow
27                data={data}
28                type={type}
29                destroyTask={destroyTask}
30                showTask={showTask}
31                handleProgressToggle={handleProgressToggle}
32                starTask={starTask}
33              />
34            </table>
35          </div>
36        </div>
37      </div>
38    </div>
39  );
40};
41
42export default Table;

Note that, we will only be allowing the user to star/unstar tasks which are not yet completed. Thus, replace TableRow with the following code:

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

Start the Rails server and verify everything is working fine. Now, a user should be able to star/unstar tasks by clicking on the icon from their dashboard.

Now let's commit these changes:

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