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.
  • 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 on in 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.
  • To sort the tasks, we can load the starred and unstarred tasks separately and then merge them using the + operator of arrays.
  • For ensuring authorization, we do not need to do anything. In the code we wrote earlier, no params except title and 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.0]
2  def change
3    add_column :tasks, :status, :integer, default: 0, 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 lines :

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

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, :user_id, :progress, :status)
3end

Update index action to send starred status

Now let us update our index action to retrieve the sorted list of tasks using the method we have discussed earlier. Our index method would look something like this:

1# previous code...
2def index
3  tasks = policy_scope(Task)
4
5  pending_starred = tasks.pending.starred.order('updated_at DESC')
6  pending_unstarred = tasks.pending.unstarred
7  pending_tasks = (pending_starred + pending_unstarred).as_json(
8    include: { user: { only: %i[name id] } }
9  )
10
11  completed_starred = tasks.completed.starred.order('updated_at DESC')
12  completed_unstarred = tasks.completed.unstarred
13  completed_tasks = completed_starred + completed_unstarred
14
15  render status: :ok, json: {
16    tasks: {
17      pending: pending_tasks,
18      completed: completed_tasks
19    }
20  }
21end
22# 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  belongs_to :user
3  enum progress: { pending: 0, completed: 1 }
4  enum status: { unstarred: 0, starred: 1 }
5  has_many :comments, dependent: :destroy
6  validates :title, presence: true
7
8  private
9
10  def self.inorder_of(progress)
11    starred = send(progress).starred.order('updated_at DESC')
12    unstarred = send(progress).unstarred.order('updated_at DESC')
13    starred + unstarred
14  end
15end

The inorder_of 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.

Here, the send method is provided by Ruby, which helps in calling a function from a string or symbol. The class method or scope, is often scoped to self. Thus send(progress) means, tasks.send(progress).

And why exactly are we invoking send here? Because, remember, ActiveRecord::Enum adds a lot of instance methods to the enum type variable. Calling tasks.completed, is actually invoking a completed method on tasks. Thus to dynamically invokes these instance methods, we are using send.

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  render status: :ok, json: {
5    tasks: {
6      pending: tasks.inorder_of(:pending).as_json(
7        include: { user: { only: %i[name id] } }
8      }),
9      completed: tasks.inorder_of(:completed)
10    }
11  }
12end
13# 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({ slug, payload: { task: { progress } } });
29      await fetchTasks();
30    } catch (error) {
31      logger.error(error);
32    }
33  };
34
35  const destroyTask = async (slug) => {
36    try {
37      await tasksApi.destroy(slug);
38      await fetchTasks();
39    } catch (error) {
40      logger.error(error);
41    }
42  };
43
44  const showTask = (slug) => {
45    history.push(`/tasks/${slug}/show`);
46  };
47
48  const starTask = async (slug, status) => {
49    try {
50      const toggledStatus = status === "starred" ? "unstarred" : "starred";
51      await tasksApi.update({
52        slug,
53        payload: { task: { status: toggledStatus } },
54      });
55      await fetchTasks();
56    } catch (error) {
57      logger.error(error);
58    }
59  };
60
61  useEffect(() => {
62    fetchTasks();
63  }, []);
64
65  if (loading) {
66    return (
67      <div className="w-screen h-screen">
68        <PageLoader />
69      </div>
70    );
71  }
72
73  if (all(either(isNil, isEmpty), [pendingTasks, completedTasks])) {
74    return (
75      <Container>
76        <h1 className="my-5 text-xl leading-5 text-center">
77          You have not created or been assigned any tasks 🥳
78        </h1>
79      </Container>
80    );
81  }
82
83  return (
84    <Container>
85      {!either(isNil, isEmpty)(pendingTasks) && (
86        <Table
87          data={pendingTasks}
88          destroyTask={destroyTask}
89          showTask={showTask}
90          handleProgressToggle={handleProgressToggle}
91          starTask={starTask}
92        />
93      )}
94      {!either(isNil, isEmpty)(completedTasks) && (
95        <Table
96          type="completed"
97          data={completedTasks}
98          destroyTask={destroyTask}
99          handleProgressToggle={handleProgressToggle}
100        />
101      )}
102    </Container>
103  );
104};
105
106export default Dashboard;

In Table.jsx, forward the starTask function to TableRow component.

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

Now let's commit these changes:

1git add -A
2git commit -m "Added starring feature to tasks"
⌘K
    to navigateEnterto select Escto close
    Previous
    Next