Storing starred status in DB

Let's create a migration to add a column called status to 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

As in the previous section, we will be using the update action tasks_controller to star and unstar a task. So 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

The first thought that would come to your to our mind would be to add the logic to retrieve the starred and unstarred tasks for each of the tasks progress, within the controller itself. But the rails ideology is to keep the controllers as skinny as possible. That is why we often delegate the logic into concerns, helpers etc. Here we can make use of a model scope to organize the tasks in the format that we want. Let's add a class method over a scope(since scopes are used for one liners only), to the tasks 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.organize(progress)
11      starred = send(progress).starred.order('updated_at DESC')
12      unstarred = send(progress).unstarred
13      starred + unstarred
14    end
15end

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 to further organize the pending and completed tasks based on its status. Add the following content:

1def index
2  tasks = policy_scope(Task)
3  render status: :ok, json: {
4    tasks: {
5      pending: tasks.organize(:pending).as_json(include: {
6        user: {
7          only: [:name, :id]
8        }
9      }),
10      completed: tasks.organize(:completed)
11    }
12  }
13end

Adding a toggle to star/unstar tasks

We will be allowing a user to toggle a task as starred/unstarred by clicking on an icon on the dashboard. To do so, add the following contents to app/javascript/src/components/Dashboard/index.jsx:

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;

The starTask function is used to toggle the status of the task as starred/unstarred. Now, inside, Table.jsx, replace with the following contents:

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
17            align-middle sm:px-6 lg:px-8">
18          <div className="overflow-hidden border-b
19              border-gray-200 shadow md:custom-box-shadow">
20            <table className="min-w-full divide-y divide-gray-200">
21              <TableHeader type={type} />
22              <TableRow
23                data={data}
24                type={type}
25                destroyTask={destroyTask}
26                showTask={showTask}
27                handleProgressToggle={handleProgressToggle}
28                starTask={starTask}
29              />
30            </table>
31          </div>
32        </div>
33      </div>
34    </div>
35  );
36};
37
38export default Table;

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

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              "px-6 py-4 text-sm font-medium leading-5
37                  whitespace-no-wrap text-bb-purple",
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;

Now, a user can star/unstar a task by clicking on the icon on the dashboard.

Now let's commit these changes:

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