Search
⌘K
    to navigateEnterto select Escto close

    Starring tasks

    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.

    • 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, :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 line:

    1class Task < ApplicationRecord
    2  enum status: { unstarred: 0, starred: 1 }
    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

    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.order('updated_at DESC')
    6  pending_unstarred = tasks.pending.unstarred.order('updated_at DESC')
    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.order('updated_at DESC')
    13  @completed_tasks = completed_starred + completed_unstarred
    14end
    15# 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.of_status(:pending).as_json(include: { assigned_user: { only: %i[name id] } })
    5  @completed_tasks = tasks.of_status(:completed)
    6end
    7# previous code...

    See, our index method is much simpler to read now. Before moving ahead let's discuss the helper methods we have used in index action.

    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.assigned_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 text-2xl hover:text-bb-yellow p-1",
    57                    {
    58                      "text-bb-border ri-star-line":
    59                        rowData.status !== "starred",
    60                    },
    61                    {
    62                      "text-white text-bb-yellow ri-star-fill":
    63                        rowData.status === "starred",
    64                    }
    65                  )}
    66                  onClick={() => starTask(rowData.slug, rowData.status)}
    67                ></i>
    68              </td>
    69            </>
    70          )}
    71          {isCompleted && (
    72            <>
    73              <td style={{ width: "164px" }}></td>
    74              <td className="pl-6 py-4 text-center cursor-pointer">
    75                <i
    76                  className="text-2xl text-center text-bb-border
    77                  transition duration-300 ease-in-out
    78                  ri-delete-bin-5-line hover:text-bb-red"
    79                  onClick={() => destroyTask(rowData.slug)}
    80                ></i>
    81              </td>
    82            </>
    83          )}
    84        </tr>
    85      ))}
    86    </tbody>
    87  );
    88};
    89
    90TableRow.propTypes = {
    91  data: PropTypes.array.isRequired,
    92  type: PropTypes.string,
    93  destroyTask: PropTypes.func,
    94  showTask: PropTypes.func,
    95  handleProgressToggle: PropTypes.func,
    96  starTask: 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 their dashboard.

    Now let's commit these changes:

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