Building the Tasks page

Let's build the page that lists all the tasks for a given organization.

Okay, now that we have built the schema, queries and mutations for the tasks, let's build the page that lists all the tasks for a given organization.

In this page we will build a table using the DataTable component in the template (built using Tanstack Table) and display the tasks data. From a dropdown, we will be able to view a task, mark it as done/undone, or delete it.

Let's get started!


Fetching the Tasks data using the Remix Loader

First, let's build the foundation of our page.

To fetch data, we define the loader function, the special hook that Remix uses to fetch data for the page. Don't worry about writing this down yet, as we have a full example below. What matters is understanding the concept and how to fetch data from the Supabase database.

app/routes/_app.tasks._index.tsx
export async function loader(args: LoaderArgs) { const request = args.request; const url = new URL(request.url); const searchParams = url.searchParams; const page = searchParams.get('page') ?? '1'; const query = searchParams.get('query') ?? ''; const client = getSupabaseServerClient(request); const data = await getCurrentOrganization(client); const pageSize = 8; const pageIndex = Number(page) - 1; const organizationId = data?.organization.id; if (!organizationId) { return new Response('Not found', { status: 404, }); } const { data: tasks, count, error, } = await getTasks(client, { organizationId: data.organization.id, pageIndex, query, }); if (error) { console.error(error); return json({ tasks: [], count: 0, pageCount: 0, query, pageIndex, pageSize, }); } const pageCount = count ? Math.ceil(count / pageSize) : 0; return json({ tasks, count, query, pageCount, pageIndex, pageSize, }); }

As you can see - we defined a Supabase client, and passed it to the getTasks function, which is a query we defined in the previous chapter.

Defining the Remix action handler

Now, let's define the action handler. This handler will be used to create a new task.

app/routes/_app.tasks._index.tsx
export async function action(args: ActionArgs) { const client = getSupabaseServerClient(args.request); const body = await args.request.json(); const ok = () => json({ success: true }); switch (args.request.method) { case 'POST': return createTask(client, body).then(ok); case 'DELETE': return deleteTask(client, body).then(ok); case 'PUT': return updateTask(client, body).then(ok); } return throwNotFoundException(); }

Now, we will define the page TasksPage.

For the time being - we don't display the tasks data, but we will do that in the next section - since the Table component requires a bit of work.

app/routes/_app.tasks._index.tsx
import type { ActionArgs, MetaFunction } from '@remix-run/node'; import { json } from '@remix-run/node'; import { Form, useLoaderData } from '@remix-run/react'; import type { LoaderArgs } from '@remix-run/server-runtime'; import { PlusCircleIcon, RectangleStackIcon, } from '@heroicons/react/24/outline'; import { Trans } from 'react-i18next'; import AppHeader from '~/components/AppHeader'; import AppContainer from '~/components/AppContainer'; import getSupabaseServerClient from '~/core/supabase/server-client'; import { getTasks } from '~/lib/tasks/queries'; import getCurrentOrganization from '~/lib/server/organizations/get-current-organization'; import If from '~/core/ui/If'; import Heading from '~/core/ui/Heading'; import Button from '~/core/ui/Button'; import Modal from '~/core/ui/Modal'; import TextField from '~/core/ui/TextField'; import type Task from '~/lib/tasks/types/task'; import TasksTable from '~/components/tasks/TasksTable'; import TaskForm from '~/components/tasks/TaskForm'; import { createTask, deleteTask, updateTask } from '~/lib/tasks/mutations'; import { throwNotFoundException } from '~/core/http-exceptions'; export const meta: MetaFunction = () => { return [ { title: 'Tasks', }, ]; }; export async function loader(args: LoaderArgs) { const request = args.request; const url = new URL(request.url); const searchParams = url.searchParams; const page = searchParams.get('page') ?? '1'; const query = searchParams.get('query') ?? ''; const client = getSupabaseServerClient(request); const data = await getCurrentOrganization(client); const pageSize = 8; const pageIndex = Number(page) - 1; const organizationId = data?.organization.id; if (!organizationId) { return new Response('Not found', { status: 404, }); } const { data: tasks, count, error, } = await getTasks(client, { organizationId: data.organization.id, pageIndex, query, }); if (error) { console.error(error); return json({ tasks: [], count: 0, pageCount: 0, query, pageIndex, pageSize, }); } const pageCount = count ? Math.ceil(count / pageSize) : 0; return json({ tasks, count, query, pageCount, pageIndex, pageSize, }); } function TasksPage() { const { tasks, count, pageCount, query, pageIndex, pageSize } = useLoaderData<typeof loader>(); return ( <> <AppHeader> <span className={'flex space-x-2'}> <RectangleStackIcon className="w-6" /> <span> <Trans i18nKey={'common:tasksTabLabel'} /> </span> </span> </AppHeader> <AppContainer> </AppContainer> </> ); } export default TasksPage; function CreateTaskModal(props: React.PropsWithChildren) { return ( <Modal heading={`Create Task`} Trigger={props.children}> <TaskForm /> </Modal> ); } export async function action(args: ActionArgs) { const client = getSupabaseServerClient(args.request); const body = await args.request.json(); const ok = () => json({ success: true }); switch (args.request.method) { case 'POST': return createTask(client, body).then(ok); case 'DELETE': return deleteTask(client, body).then(ok); case 'PUT': return updateTask(client, body).then(ok); } return throwNotFoundException(); }

To complete this page, we need 3 supporting components that are defined in external files:

  1. TasksTable: a table that displays the tasks data
  2. TaskForm: a form that allows the user to create a new task

We will define these components in the next sections.

Building the Tasks table

Now that we have the data, let's build the table that displays it.

To define the table, we use the DataTable component, which is a wrapper around the Tanstack Table component. This component is a bit complex, so we will break it down into smaller pieces.

The component TasksTable accepts the following props:

  1. pageIndex - the current page index
  2. pageCount - the total number of pages
  3. tasks - the tasks data

Defining the table columns

Now, let's define the table columns. We will define the following columns:

  1. Name - the name of the task, which is a link to the task page
  2. Description - the description of the task, which is truncated to 50 characters
  3. Due Date - the due date of the task, which is formatted using the date-fns library
  4. Actions - a dropdown menu with the following actions:
  5. View Task - a link to the task page
  6. Mark as Done - a button that marks the task as done
  7. Delete Task - a button that deletes the task

Let's define the TABLE_COLUMNS constant. As you can see, we use the ColumnDef type from the Tanstack Table library. We define the header and cell properties, which are the header and cell components of the table.

const TABLE_COLUMNS: ColumnDef<Task>[] = [ { header: 'Name', cell: ({ row }) => { const task = row.original; return ( <Link className={'hover:underline'} to={'/tasks/' + task.id}> {task.name} </Link> ); }, }, { header: 'Description', id: 'description', cell: ({ row }) => { const task = row.original; const length = task.description?.length ?? 0; return ( <span className={'truncate max-w-[50px]'}> {length > 0 ? task.description : '-'} </span> ); }, }, { header: 'Due Date', id: 'dueDate', cell: ({ row }) => { const task = row.original; const dueDate = formatDistance(new Date(task.dueDate), new Date(), { addSuffix: true, }); return ( <If condition={task.done} fallback={<span className={'capitalize'}>{dueDate}</span>} > <div className={'inline-flex'}> <Badge size={'small'} color={'success'}> Done </Badge> </div> </If> ); }, }, { header: '', id: 'actions', cell: ({ row }) => { const task = row.original; return ( <div className={'flex justify-end'}> <DropdownMenu> <DropdownMenuTrigger asChild> <IconButton> <EllipsisVerticalIcon className="w-5" /> </IconButton> </DropdownMenuTrigger> <DropdownMenuContent collisionPadding={{ right: 20, }} > <DropdownMenuItem> <Link to={'/tasks/' + row.original.id}>View Task</Link> </DropdownMenuItem> <UpdateStatusMenuItem task={task} /> <DeleteTaskMenuItem task={task} /> </DropdownMenuContent> </DropdownMenu> </div> ); }, }, ];

Defining the dropdown menu items

Now, let's define the dropdown menu items. Some of them are more complex, so we defined them into smaller components.

function DeleteTaskMenuItem({ task }: { task: Task }) { const fetcher = useFetcher(); return ( <DropdownMenuItem onSelect={(e) => e.preventDefault()}> <ConfirmDeleteTaskModal task={task.name} onConfirm={() => { fetcher.submit(task.id, { method: 'DELETE', encType: 'application/json', }); }} > <span className={'text-red-500'}>Delete Task</span> </ConfirmDeleteTaskModal> </DropdownMenuItem> ); } function UpdateStatusMenuItem({ task, }: React.PropsWithChildren<{ task: Task; }>) { const fetcher = useFetcher(); const submitting = fetcher.state === 'submitting'; const action = task.done ? 'Mark as Todo' : 'Mark as Done'; return ( <DropdownMenuItem disabled={submitting} onSelect={(e) => e.preventDefault()} onClick={() => { return fetcher.submit( { id: task.id, done: !task.done, }, { method: 'PUT', encType: 'application/json', }, ); }} > {action} </DropdownMenuItem> ); }

Defining the Confirm Delete Task modal

Finally, we need to define the Modal component to confirm the deletion of a task. This component is defined in src/core/ui/Modal.tsx.

function ConfirmDeleteTaskModal({ children, onConfirm, task, }: React.PropsWithChildren<{ task: string; onConfirm: () => void; }>) { return ( <Modal heading={`Deleting Task`} Trigger={children}> <div className={'flex flex-col space-y-4'}> <div className={'text-sm flex flex-col space-y-2'}> <p> You are about to delete the task <b>{task}</b> </p> <p>Do you want to continue?</p> </div> <div className={'flex justify-end space-x-2'}> <Button variant={'flat'} color={'danger'} onClick={onConfirm}> Yep, delete task </Button> </div> </div> </Modal> ); }

Full code for the Tasks table

Perfect - below is the full code for the Tasks table. We will now import it into the page, and display the data.

app/components/tasks/TasksTable.tsx
import { Link, useFetcher, useSearchParams } from '@remix-run/react'; import { formatDistance } from 'date-fns'; import type { ColumnDef } from '@tanstack/react-table'; import { EllipsisVerticalIcon } from '@heroicons/react/24/outline'; import type Task from '~/lib/tasks/types/task'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '~/core/ui/Dropdown'; import DataTable from '~/core/ui/DataTable'; import IconButton from '~/core/ui/IconButton'; import Badge from '~/core/ui/Badge'; import If from '~/core/ui/If'; import Modal from '~/core/ui/Modal'; import Button from '~/core/ui/Button'; import { useCallback } from 'react'; const TABLE_COLUMNS: ColumnDef<Task>[] = [ { header: 'Name', cell: ({ row }) => { const task = row.original; return ( <Link className={'hover:underline'} to={'/tasks/' + task.id}> {task.name} </Link> ); }, }, { header: 'Description', id: 'description', cell: ({ row }) => { const task = row.original; const length = task.description?.length ?? 0; return ( <span className={'truncate max-w-[50px]'}> {length > 0 ? task.description : '-'} </span> ); }, }, { header: 'Due Date', id: 'dueDate', cell: ({ row }) => { const task = row.original; const dueDate = formatDistance(new Date(task.dueDate), new Date(), { addSuffix: true, }); return ( <If condition={task.done} fallback={<span className={'capitalize'}>{dueDate}</span>} > <div className={'inline-flex'}> <Badge size={'small'} color={'success'}> Done </Badge> </div> </If> ); }, }, { header: '', id: 'actions', cell: ({ row }) => { const task = row.original; return ( <div className={'flex justify-end'}> <DropdownMenu> <DropdownMenuTrigger asChild> <IconButton> <EllipsisVerticalIcon className="w-5" /> </IconButton> </DropdownMenuTrigger> <DropdownMenuContent collisionPadding={{ right: 20, }} > <DropdownMenuItem> <Link to={'tasks/' + row.original.id}>View Task</Link> </DropdownMenuItem> <UpdateStatusMenuItem task={task} /> <DeleteTaskMenuItem task={task} /> </DropdownMenuContent> </DropdownMenu> </div> ); }, }, ]; function TasksTable( props: React.PropsWithChildren<{ pageIndex: number; pageCount: number; pageSize: number; tasks: Task[]; }>, ) { const [, setSearchParams] = useSearchParams(); const onPaginationChange = useCallback( ({ pageIndex }: { pageIndex: number }) => { const page = (pageIndex + 1).toString(); setSearchParams({ page }); }, [setSearchParams], ); return ( <DataTable onPaginationChange={onPaginationChange} pageIndex={props.pageIndex} pageCount={props.pageCount} pageSize={props.pageSize} data={props.tasks} columns={TABLE_COLUMNS} /> ); } function DeleteTaskMenuItem({ task }: { task: Task }) { const fetcher = useFetcher(); return ( <DropdownMenuItem onSelect={(e) => e.preventDefault()}> <ConfirmDeleteTaskModal task={task.name} onConfirm={() => { fetcher.submit(task.id, { method: 'DELETE', encType: 'application/json', }); }} > <span className={'text-red-500'}>Delete Task</span> </ConfirmDeleteTaskModal> </DropdownMenuItem> ); } function UpdateStatusMenuItem({ task, }: React.PropsWithChildren<{ task: Task; }>) { const fetcher = useFetcher(); const submitting = fetcher.state === 'submitting'; const action = task.done ? 'Mark as Todo' : 'Mark as Done'; return ( <DropdownMenuItem disabled={submitting} onSelect={(e) => e.preventDefault()} onClick={() => { return fetcher.submit( { id: task.id, done: !task.done, }, { method: 'PUT', encType: 'application/json', }, ); }} > {action} </DropdownMenuItem> ); } function ConfirmDeleteTaskModal({ children, onConfirm, task, }: React.PropsWithChildren<{ task: string; onConfirm: () => void; }>) { return ( <Modal heading={`Deleting Task`} Trigger={children}> <div className={'flex flex-col space-y-4'}> <div className={'text-sm flex flex-col space-y-2'}> <p> You are about to delete the task <b>{task}</b> </p> <p>Do you want to continue?</p> </div> <div className={'flex justify-end space-x-2'}> <Button variant={'flat'} color={'danger'} onClick={onConfirm}> Yep, delete task </Button> </div> </div> </Modal> ); } export default TasksTable;

Building the Create Task Form

To create a task - we will use a simple form that will be displayed in a modal - which we will define later.

app/components/tasks/TaskForm.tsx
import type { FormEventHandler } from 'react'; import { useCallback } from 'react'; import { toast } from 'sonner'; import TextField from '~/core/ui/TextField'; import Button from '~/core/ui/Button'; import If from '~/core/ui/If'; import Textarea from '~/core/ui/Textarea'; import Label from '~/core/ui/Label'; import useCurrentOrganization from '~/lib/organizations/hooks/use-current-organization'; import type Task from '~/lib/tasks/types/task'; function TaskForm({ onSubmit, submitting, }: React.PropsWithChildren<{ onSubmit: (task: Omit<Task, 'id'>) => unknown; submitting?: boolean; }>) { const organization = useCurrentOrganization(); const organizationId = organization?.id as number; const onCreateTask: FormEventHandler<HTMLFormElement> = useCallback( async (event) => { event.preventDefault(); const target = event.currentTarget; const data = new FormData(target); const name = data.get('name') as string; const description = data.get('description') as string; const dueDate = (data.get('dueDate') as string) || getDefaultDueDate(); if (name.trim().length < 3) { toast.error('Task name must be at least 3 characters long'); return; } const task = { organizationId, name, dueDate, description, done: false, }; onSubmit(task); }, [onSubmit, organizationId], ); return ( <form onSubmit={onCreateTask}> <div className={'flex flex-col space-y-4'}> <TextField.Label> Name <TextField.Input required name={'name'} placeholder={'ex. Launch on IndieHackers'} /> </TextField.Label> <Label> Description <Textarea name={'description'} className={'h-32'} placeholder={'Describe the task...'} /> </Label> <TextField.Label> Due date <TextField.Input name={'dueDate'} type={'date'} /> <TextField.Hint> Leave empty to set the due date to tomorrow </TextField.Hint> </TextField.Label> <div className={'flex justify-end'}> <Button loading={submitting}> <If condition={submitting} fallback={<>Create Task</>}> Creating Task... </If> </Button> </div> </div> </form> ); } function getDefaultDueDate() { const date = new Date(); date.setDate(date.getDate() + 1); date.setHours(23, 59, 59); return date.toDateString(); } export default TaskForm;

The form above will emit a onSubmit event, which we will be handled by the container component - and will submit the data to the action function.

Adding the Tasks table to the page

Now that we have the table, let's add it to the page. We will define a further component named TasksTableContainer which will wrap the table, and add a search input and a button to create a new task.

function TasksTableContainer({ tasks, pageCount, pageIndex, pageSize, query, }: React.PropsWithChildren<{ tasks: Task[]; pageCount: number; pageIndex: number; pageSize: number; query?: string; }>) { return ( <div className={'flex flex-col space-y-4'}> <div className={'flex space-x-4 justify-between items-center'}> <div className={'flex'}> <CreateTaskModal> <Button color={'transparent'}> <span className={'flex space-x-2 items-center'}> <PlusCircleIcon className={'w-4'} /> <span>New Task</span> </span> </Button> </CreateTaskModal> </div> <Form className={'w-full max-w-sm'} method={'GET'}> <TextField.Input defaultValue={query} name={'query'} className={'w-full'} placeholder={'Search for task...'} /> </Form> </div> <TasksTable pageSize={pageSize} pageIndex={pageIndex} pageCount={pageCount} tasks={tasks} /> </div> ); }

Finally, we can add these components to the page by adding them as children of the <AppContainer> component.

<AppContainer> <If condition={!count}> <TasksEmptyState /> </If> <TasksTableContainer pageSize={pageSize} pageIndex={pageIndex} pageCount={pageCount} tasks={tasks} query={query} /> </AppContainer>

Check the full source code below.

Full Source Code for the Tasks page

Perfect - below is the full source code for the Tasks page. We will now add it to the dashboard.

app/routes/_app.tasks._index.tsx
import type { ActionArgs, MetaFunction } from '@remix-run/node'; import { json } from '@remix-run/node'; import { Form, useFetcher, useLoaderData } from '@remix-run/react'; import type { LoaderArgs } from '@remix-run/server-runtime'; import { useCallback } from "react"; import { PlusCircleIcon, RectangleStackIcon, } from '@heroicons/react/24/outline'; import { Trans } from 'react-i18next'; import AppHeader from '~/components/AppHeader'; import AppContainer from '~/components/AppContainer'; import getSupabaseServerClient from '~/core/supabase/server-client'; import { getTasks } from '~/lib/tasks/queries'; import getCurrentOrganization from '~/lib/server/organizations/get-current-organization'; import If from '~/core/ui/If'; import Heading from '~/core/ui/Heading'; import Button from '~/core/ui/Button'; import Modal from '~/core/ui/Modal'; import TextField from '~/core/ui/TextField'; import type Task from '~/lib/tasks/types/task'; import TasksTable from '~/components/tasks/TasksTable'; import TaskForm from '~/components/tasks/TaskForm'; import { createTask, deleteTask, updateTask } from '~/lib/tasks/mutations'; import { throwNotFoundException } from '~/core/http-exceptions'; export const meta: MetaFunction = () => { return [ { title: 'Tasks', }, ]; }; export async function loader(args: LoaderArgs) { const request = args.request; const url = new URL(request.url); const searchParams = url.searchParams; const page = searchParams.get('page') ?? '1'; const query = searchParams.get('query') ?? ''; const client = getSupabaseServerClient(request); const data = await getCurrentOrganization(client); const pageSize = 8; const pageIndex = Number(page) - 1; const organizationId = data?.organization.id; if (!organizationId) { return new Response('Not found', { status: 404, }); } const { data: tasks, count, error, } = await getTasks(client, { organizationId: data.organization.id, pageIndex, query, }); if (error) { console.error(error); return json({ tasks: [], count: 0, pageCount: 0, query, pageIndex, pageSize, }); } const pageCount = count ? Math.ceil(count / pageSize) : 0; return json({ tasks, count, query, pageCount, pageIndex, pageSize, }); } function TasksPage() { const { tasks, count, pageCount, query, pageIndex, pageSize } = useLoaderData<typeof loader>(); return ( <> <AppHeader> <span className={'flex space-x-2'}> <RectangleStackIcon className="w-6" /> <span> <Trans i18nKey={'common:tasksTabLabel'} /> </span> </span> </AppHeader> <AppContainer> <If condition={!count}> <TasksEmptyState /> </If> <TasksTableContainer pageSize={pageSize} pageIndex={pageIndex} pageCount={pageCount} tasks={tasks} query={query} /> </AppContainer> </> ); } export default TasksPage; function TasksTableContainer({ tasks, pageCount, pageIndex, pageSize, query, }: React.PropsWithChildren<{ tasks: Task[]; pageCount: number; pageIndex: number; pageSize: number; query?: string; }>) { return ( <div className={'flex flex-col space-y-4'}> <div className={'flex space-x-4 justify-between items-center'}> <div className={'flex'}> <CreateTaskModal> <Button color={'transparent'}> <span className={'flex space-x-2 items-center'}> <PlusCircleIcon className={'w-4'} /> <span>New Task</span> </span> </Button> </CreateTaskModal> </div> <Form className={'w-full max-w-sm'} method={'GET'}> <TextField.Input defaultValue={query} name={'query'} className={'w-full'} placeholder={'Search for task...'} /> </Form> </div> <TasksTable pageSize={pageSize} pageIndex={pageIndex} pageCount={pageCount} tasks={tasks} /> </div> ); } function TasksEmptyState() { return ( <div className={'flex flex-col space-y-8 p-4'}> <div className={'flex flex-col'}> <Heading type={2}> <span className={'font-semibold'}> Hey, it looks like you don&apos;t have any tasks yet... 🤔 </span> </Heading> <Heading type={4}> Create your first task by clicking on the button below </Heading> </div> </div> ); } function CreateTaskModal(props: React.PropsWithChildren) { const fetcher = useFetcher(); const submitting = fetcher.state === 'submitting'; const onSubmit = useCallback( (task: Omit<Task, 'id'>) => { fetcher.submit(task, { method: 'POST', encType: 'application/json', }); }, [fetcher], ); return ( <Modal heading={`Create Task`} Trigger={props.children}> <TaskForm onSubmit={onSubmit} submitting={submitting} /> </Modal> ); } export async function action(args: ActionArgs) { const client = getSupabaseServerClient(args.request); const body = await args.request.json(); const ok = () => json({ success: true }); switch (args.request.method) { case 'POST': return createTask(client, body).then(ok); case 'DELETE': return deleteTask(client, body).then(ok); case 'PUT': return updateTask(client, body).then(ok); } return throwNotFoundException(); }

Subscribe to our Newsletter
Get the latest updates about React, Remix, Next.js, Firebase, Supabase and Tailwind CSS