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

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

As you know - pages are Server Components. This means we can fetch data directly from our database, and pass it down to the client components.

To fetch data, we define a function loadTasksData - 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.

export async function loadTasksData(params: { organizationUid: string; pageIndex: number; perPage: number; query?: string; }) { const client = getSupabaseServerClient(); const { organizationUid, perPage, pageIndex, query } = params; const { data: tasks, error, count, } = await getTasks(client, { organizationUid, pageIndex, perPage, query, }); if (error) { console.error(error); return { tasks: [], count: 0, }; } return { tasks, count: count ?? 0, }; }

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.

Now, we will define the page TasksPage, retrieve the data from the server component, and pass it down to the client components.

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/dashboard/[organization]/tasks/page.tsx
import { use } from 'react'; import { PlusCircleIcon, RectangleStackIcon, } from '@heroicons/react/24/outline'; import getSupabaseServerClient from '~/core/supabase/server-client'; import { getTasks } from '~/lib/tasks/queries'; import Trans from '~/core/ui/Trans'; import { withI18n } from '~/i18n/with-i18n'; import AppHeader from '~/app/dashboard/[organization]/components/AppHeader'; import AppContainer from '~/app/dashboard/[organization]/components/AppContainer'; export const metadata = { title: 'Tasks', }; interface TasksPageParams { params: { organization: string; }; searchParams: { page?: string; query?: string; }; } function TasksPage({ params, searchParams }: TasksPageParams) { const pageIndex = Number(searchParams.page ?? '1') - 1; const perPage = 8; const { tasks, count } = use( loadTasksData({ organizationUid: params.organization, pageIndex, perPage, query: searchParams.query || '', }), ); const pageCount = Math.ceil(count / perPage); return ( <> <AppHeader> <span className={'flex space-x-2'}> <RectangleStackIcon className="w-6" /> <span> <Trans i18nKey={'common:tasksTabLabel'} /> </span> </span> </AppHeader> <AppContainer> </AppContainer> </> ); } export async function loadTasksData(params: { organizationUid: string; pageIndex: number; perPage: number; query?: string; }) { const client = getSupabaseServerClient(); const { organizationUid, perPage, pageIndex, query } = params; const { data: tasks, error, count, } = await getTasks(client, { organizationUid, pageIndex, perPage, query, }); if (error) { console.error(error); return { tasks: [], count: 0, }; } return { tasks, count: count ?? 0, }; } export default withI18n(TasksPage);

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. SearchTaskInput: an input that allows the user to search for tasks
  3. CreateTaskModal: a modal 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

We will refresh the page when the user changes the page index, and we will pass the data to the DataTable component. As you can see we still don't have defined the TABLE_COLUMNS constant, but we will do that in the next section.

Since it's a client component, we define the TasksTable component in a separate file at src/app/dashboard/[organization]/tasks/components/TasksTable.tsx.

src/app/dashboard/[organization]/tasks/components/TasksTable.tsx
function TasksTable( props: React.PropsWithChildren<{ pageIndex: number; pageCount: number; tasks: Task[]; }>, ) { const router = useRouter(); const pathname = usePathname(); return ( <DataTable onPaginationChange={({ pageIndex }) => { router.push(`${pathname}?page=${pageIndex + 1}`); }} pageIndex={props.pageIndex} pageCount={props.pageCount} data={props.tasks} columns={TABLE_COLUMNS} /> ); }

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'} href={'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 href={'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 [, startTransition] = useTransition(); const csrfToken = useCsrfToken(); return ( <DropdownMenuItem onSelect={(e) => e.preventDefault()}> <ConfirmDeleteTaskModal task={task.name} onConfirm={() => { startTransition(async () => { await deleteTaskAction({ taskId: task.id, csrfToken }); }); }} > <span className={'text-red-500'}>Delete Task</span> </ConfirmDeleteTaskModal> </DropdownMenuItem> ); } function UpdateStatusMenuItem({ task, }: React.PropsWithChildren<{ task: Task; }>) { const [isPending, startTransition] = useTransition(); const csrfToken = useCsrfToken(); const action = task.done ? 'Mark as Todo' : 'Mark as Done'; return ( <DropdownMenuItem disabled={isPending} onSelect={(e) => e.preventDefault()} onClick={() => { startTransition(async () => { await updateTaskAction({ csrfToken, task: { id: task.id, done: !task.done, }, }); }); }} > {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.

'use client'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { useTransition } from 'react'; import { formatDistance } from 'date-fns'; import { ColumnDef } from '@tanstack/react-table'; import { EllipsisVerticalIcon } from '@heroicons/react/24/outline'; import 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 { deleteTaskAction, updateTaskAction } from '~/lib/tasks/actions'; import useCsrfToken from '~/core/hooks/use-csrf-token'; const TABLE_COLUMNS: ColumnDef<Task>[] = [ { header: 'Name', cell: ({ row }) => { const task = row.original; return ( <Link className={'hover:underline'} href={'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 href={'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; tasks: Task[]; }>, ) { const router = useRouter(); const pathname = usePathname(); return ( <DataTable onPaginationChange={({ pageIndex }) => { router.push(`${pathname}?page=${pageIndex + 1}`); }} pageIndex={props.pageIndex} pageCount={props.pageCount} data={props.tasks} columns={TABLE_COLUMNS} /> ); } function DeleteTaskMenuItem({ task }: { task: Task }) { const [, startTransition] = useTransition(); const csrfToken = useCsrfToken(); return ( <DropdownMenuItem onSelect={(e) => e.preventDefault()}> <ConfirmDeleteTaskModal task={task.name} onConfirm={() => { startTransition(async () => { await deleteTaskAction({ taskId: task.id, csrfToken }); }); }} > <span className={'text-red-500'}>Delete Task</span> </ConfirmDeleteTaskModal> </DropdownMenuItem> ); } function UpdateStatusMenuItem({ task, }: React.PropsWithChildren<{ task: Task; }>) { const [isPending, startTransition] = useTransition(); const csrfToken = useCsrfToken(); const action = task.done ? 'Mark as Todo' : 'Mark as Done'; return ( <DropdownMenuItem disabled={isPending} onSelect={(e) => e.preventDefault()} onClick={() => { startTransition(async () => { await updateTaskAction({ csrfToken, task: { id: task.id, done: !task.done, }, }); }); }} > {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 Search input

Now, let's build the search input. This input will allow the user to search for tasks by name.

We will use a text input to refresh the current route using a query parameter query:

'use client'; import { usePathname, useRouter } from 'next/navigation'; import { FormEventHandler, useCallback } from 'react'; import { TextFieldInput } from '~/core/ui/TextField'; function SearchTaskInput({ query, }: React.PropsWithChildren<{ query: Maybe<string>; }>) { const router = useRouter(); const pathName = usePathname(); const onSubmit: FormEventHandler<HTMLFormElement> = useCallback( (event) => { event.preventDefault(); const formData = new FormData(event.currentTarget); const query = formData.get('query') as string; const url = new URL(pathName, window.location.origin); url.searchParams.set('query', query); const path = url.pathname + url.search; router.push(path); }, [pathName, router], ); return ( <form className={'w-full max-w-sm'} onSubmit={onSubmit}> <TextFieldInput defaultValue={query} name={'query'} className={'w-full'} placeholder={'Search for task...'} /> </form> ); } export default SearchTaskInput;

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.

src/app/dashboard/[organization]/tasks/components/TaskForm.tsx
'use client'; import type { FormEventHandler } from 'react'; import { useCallback, useTransition } from 'react'; import toaster from 'react-hot-toast'; import TextField from '~/core/ui/TextField'; import Button from '~/core/ui/Button'; import If from '~/core/ui/If'; import Label from '~/core/ui/Label'; import Textarea from '~/core/ui/Textarea'; import useCurrentOrganization from '~/lib/organizations/hooks/use-current-organization'; import { createTaskAction } from '~/lib/tasks/actions'; import useCsrfToken from '~/core/hooks/use-csrf-token'; const TaskForm: React.FC = () => { const [isMutating, startTransition] = useTransition(); const organization = useCurrentOrganization(); const organizationId = organization?.id as number; const csrfToken = useCsrfToken(); 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) { toaster.error('Task name must be at least 3 characters long'); return; } const task = { organizationId, name, dueDate, description, done: false, }; startTransition(async () => { await createTaskAction({ task, csrfToken }); }); }, [csrfToken, organizationId], ); return ( <form className={'flex flex-col'} onSubmit={onCreateTask}> <div className={'flex flex-col space-y-4 w-full'}> <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 variant={'flat'} loading={isMutating}> <If condition={isMutating} 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;

Building the Create Task modal

We will leverage the Modal component to display the form in a modal. This component is defined in src/core/ui/Modal.tsx.

src/app/dashboard/[organization]/tasks/components/CreateTaskModal.tsx
import Modal from '~/core/ui/Modal'; import TaskForm from './TaskForm'; function CreateTaskModal(props: React.PropsWithChildren) { return ( <Modal heading={`Create Task`} Trigger={props.children}> <TaskForm /> </Modal> ); } export default CreateTaskModal;

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.

app/dashboard/[organization]/tasks/page.tsx
function TasksTableContainer({ tasks, pageCount, pageIndex, query, }: React.PropsWithChildren<{ tasks: Task[]; pageCount: number; pageIndex: 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> <SearchTaskInput query={query} /> </div> <TasksTable 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> ); }

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

app/dashboard/[organization]/tasks/page.tsx
<AppContainer> <If condition={!count}> <TasksEmptyState /> </If> <TasksTableContainer pageIndex={pageIndex} pageCount={pageCount} tasks={tasks} query={searchParams.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/dashboard/[organization]/tasks/page.tsx
import { use } from 'react'; import { PlusCircleIcon, RectangleStackIcon, } from '@heroicons/react/24/outline'; import getSupabaseServerClient from '~/core/supabase/server-client'; import { getTasks } from '~/lib/tasks/queries'; import Trans from '~/core/ui/Trans'; import { withI18n } from '~/i18n/with-i18n'; import AppHeader from '~/app/dashboard/[organization]/components/AppHeader'; import AppContainer from '~/app/dashboard/[organization]/components/AppContainer'; import Heading from '~/core/ui/Heading'; import Button from '~/core/ui/Button'; import If from '~/core/ui/If'; import type Task from '~/lib/tasks/types/task'; import TasksTable from '~/app/dashboard/[organization]/tasks/components/TasksTable'; import SearchTaskInput from '~/app/dashboard/[organization]/tasks/components/SearchTaskInput'; import CreateTaskModal from '~/app/dashboard/[organization]/tasks/components/CreateTaskModal'; export const metadata = { title: 'Tasks', }; interface TasksPageParams { params: { organization: string; }; searchParams: { page?: string; query?: string; }; } function TasksPage({ params, searchParams }: TasksPageParams) { const pageIndex = Number(searchParams.page ?? '1') - 1; const perPage = 8; const { tasks, count } = use( loadTasksData({ organizationUid: params.organization, pageIndex, perPage, query: searchParams.query || '', }), ); const pageCount = Math.ceil(count / perPage); 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 pageIndex={pageIndex} pageCount={pageCount} tasks={tasks} query={searchParams.query} /> </AppContainer> </> ); } export async function loadTasksData(params: { organizationUid: string; pageIndex: number; perPage: number; query?: string; }) { const client = getSupabaseServerClient(); const { organizationUid, perPage, pageIndex, query } = params; const { data: tasks, error, count, } = await getTasks(client, { organizationUid, pageIndex, perPage, query, }); if (error) { console.error(error); return { tasks: [], count: 0, }; } return { tasks, count: count ?? 0, }; } export default withI18n(TasksPage); function TasksTableContainer({ tasks, pageCount, pageIndex, query, }: React.PropsWithChildren<{ tasks: Task[]; pageCount: number; pageIndex: 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> <SearchTaskInput query={query} /> </div> <TasksTable 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> ); }

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