- Published on
Data-table in Next.js with Useable Features
- Authors
- Name
- Karlo Karlović
Motivation
Building on top of my series in using efficient use of space and intuitive navigation in modern web apps for great user experience, we are next going to talk about a critical but often misunderstood element - the Data Table. A double sidebar layout can be an effective solution, particularly for apps that require extensive navigation options or additional contextual information - especially apps that are designed to be data and quick to use.
Design Approach
Designing the actually useful Data Table involves several key considerations:
- Usability: Data Tables are critical at displaying useful information, and allowing the user to do things with that data. That might include just viewing the data, but it also might include manipulating that data.
- Customisability: As with the sidenav, the component should be flexible enough to accommodate various use cases, from simple tables to complex data types and options.
As with my whole philosophy, not everything needs to be a phone app - web apps are just fine. BUT I am thinking about adding a third consideration - Responsiveness.1
- Added 'Create task' within the header row
- Onclick, show sheet component with form content
- Shadcn tasks example as the base component, which in turn uses TanStack Table
- Added vertical boarder guide lines
- Made each row padding less
- Truncated uuid for each task (will give unique project related serial numbers in future iterations)
Here is it in action!
Code Implementation
- In
dashboard/[company]/tasks/page.tsx
, return<DataTable>
with column data, actual data from the database, companyId, andform={<CreateTaskButton userId={user.id} />}
- On 'Create task' click, initiate a shadcn
Sheet
component- This sheet component was inspired by WebDevCody's 'wdc-saas-starter-kit'
interactive-overlay.tsx
element
- This sheet component was inspired by WebDevCody's 'wdc-saas-starter-kit'
- The interactive sheet component loads
CreateTaskForm
- Manages the zod schema:
createTaskSchema
- Loads the actual form
- zodResolver is used to initiate and load default state of the form
<form onsubmit={form.handleSubmit(onSubmit)}>
is executed when the user submits the form. This will also validate the
- Manages the zod schema:
- Submitted form content is passed to
createTasksAction
viauseServerAction
function from thezsa-react
package- Will toast the user based on success or error
createTasksAction
ensures that the user is authenticated (again) and checks the submitted data via zod again- Passes the submitted form data asynchronously to
createTasksUseCase
- Passes the submitted form data asynchronously to
- The
createTasksUseCase
is where the business logic would sit. This then executes thecreateTasks
action in the database createTasksUseCase
is executed, ensures what was passed into it matches the Drizzle generatedInsertTask
schema, and inserts the value into the tasks table in the db.
The mermaid code will look something like the below (see Appendix A for actual code):
Walkthrough
Below has the code that was used to implement it.2
(semi pseudocode)
Some code have been taken out for brevity. I will probably open-source the project when it's closer to completion (or in a useful state).
// dashboard/[company]/tasks/page.tsx
import { assertAuthenticated } from '@/lib/session'
import { columns } from './columns'
import { DataTable } from '@/components/data-table/data-table'
import { CreateTaskButton } from './create-task-button'
import { getTasksFromUserUseCase } from '@/app/use-cases/tasks'
export default async function Tables({
params,
}: {
params: {
company: string
}
}) {
const user = await assertAuthenticated()
const tasks = await getTasksFromUserUseCase(user.id)
const company = params.company
return (
<>
<DataTable
columns={columns}
data={tasks}
form={<CreateTaskButton userId={user.id} />}
companyId={company}
/>
</>
)
}
// dashboard/[company]/tasks/create-task-button.tsx
'use client'
import { InteractiveOverlay } from '@/components/interactive-overlay'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
import { CreateTaskForm } from './create-task-form'
import { PlusCircledIcon } from '@radix-ui/react-icons'
export function CreateTaskButton({ userId }: { userId: string }) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<InteractiveOverlay
title={'Create Task'}
description={'Fill in the form below to create a task.'}
isOpen={isOpen}
setIsOpen={setIsOpen}
form={<CreateTaskForm userId={userId} />}
/>
<Button
className="mr-2 h-8 px-2"
onClick={() => {
setIsOpen(true)
}}
>
<PlusCircledIcon className="mr-2 h-4 w-4" />
Create task
</Button>
</>
)
}
// dashboard/[company]/tasks/create-task-form.tsx
"use client";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { useContext } from "react";
import { z } from "zod";
import { ToggleContext } from "@/components/interactive-overlay";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form, ... } from "@/components/ui/form";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Textarea } from "@/components/ui/textarea";
import { LoaderButton } from "@/components/loader-button";
import { useServerAction } from "zsa-react";
import { createTasksAction } from "./actions";
import { Select, ... } from "@/components/ui/select";
// 1. What does the structure of the form need to look like?
const createTaskSchema = z.object({
title: z.string().min(1),
status: z.string().min(1),
priority: z.string().min(1),
label: z.string().min(1),
});
export function CreateTaskForm({ userId }: { userId: string }) {
const { setIsOpen: setIsOverlayOpen } = useContext(ToggleContext); // this is coming from the interacive-overlay
const { toast } = useToast();
// 4. Actually move data into the db and return what happened
// 5. the action is the thing doing the work
const { execute, error, isPending } = useServerAction(createTasksAction, {
onSuccess() {
toast({
title: "Success",
description: "Task created successfully.",
});
setIsOverlayOpen(false);
},
onError() {
toast({
title: "Uh oh",
variant: "destructive",
description: "Something went wrong creating your task.",
});
},
});
// 2. initiate the form and set default values
const form = useForm<z.infer<typeof createTaskSchema>>({
resolver: zodResolver(createTaskSchema),
defaultValues: {
title: "",
status: "",
priority: "",
label: "",
},
});
// 3. Handle the submission of the form
const onSubmit: SubmitHandler<z.infer<typeof createTaskSchema>> = (
values,
event
) => {
execute({
title: values.title,
status: values.status,
priority: values.priority,
label: values.label,
});
};
return (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4 flex-1 px-2"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (...)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (...)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (...)}
/>
<FormField
control={form.control}
name="label"
render={({ field }) => (...)}
/>
{error && (
<Alert variant="destructive">
<AlertTitle>Error creating task</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
<LoaderButton isLoading={isPending}>Create Task</LoaderButton>
</form>
</Form>
</>
);
}
// dashboard/[company]/tasks/actions.ts
'use server'
import { authenticatedAction } from '@/lib/safe-action'
import { createTasksUseCase, deleteTasksUseCase } from '@/app/use-cases/tasks'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
export const createTasksAction = authenticatedAction
.createServerAction()
.input(
z.object({
title: z.string().min(1).max(255),
status: z.string().min(1).max(255),
priority: z.string().min(1).max(255),
label: z.string().min(1).max(255),
})
)
.handler(async ({ input: { title, status, priority, label }, ctx: { user } }) => {
await createTasksUseCase(user, {
title,
status,
priority,
label,
})
revalidatePath(`/dashboard/tasks`)
})
// app/use-cases/tasks.ts
import { createTasks } from '../data-access/tasks'
import { AuthenticationError } from '../util'
import { UserSession } from './types'
// will obvisouly need to add more business logic in here. Can the user add tasks here?
// Does the user have permission to add it to the particular project (not implemented yet)
export async function createTasksUseCase(
authenticatedUser: UserSession,
{
title,
status,
priority,
label,
}: {
title: string
status: string
priority: string
label: string
}
) {
await createTasks({
title,
status,
priority,
label,
userId: authenticatedUser.id,
createdAt: new Date(),
})
}
// app/data-access/tasks.ts
export async function createTasks(newTask: InsertTasks) {
return await database.insert(tasks).values(newTask)
}
Next Steps
- Dynamic faceted filter based on tables datatypes
- Save different views/filters
- Advanced filter manager
- Feed user data into user configurable interactive graphs
- Allowing bulk actions for ticked items.
- Open up rows to reveal more details about that particular row, without leaving the page. May be achived by some modal. Row subtasks/nesting.
- Row grouping
- Keyboard navigation
- Drag and drop reordring.
- Export to Excel/CSV
- Efficiently infinite scroll
- Editable table cells
- Calculated columns
- Server-side sorting and filtering
- Row level confitional formatting
- Global search bar
- Adaptive row hieght
Appendix
Appendix A
flowchart TB
Start([User initiates task creation]) --> tasks/page.tsx
tasks/page.tsx -- user.id --> CreateTaskButton
subgraph tasks/create-task-button.tsx
CreateTaskButton -- user.id --> CreateTaskForm-Component
end
subgraph tasks/create-task-form.tsx
CreateTaskForm-Component -- user.id --> submit
submit -- actualFormFields --> A[onSubmit: SubmitHandler]
createTaskSchema.zod --> A
A -- z.infer typeof createTaskSchema --> useServerAction-function
end
subgraph tasks/actions.ts
useServerAction-function --> createTaskAction
createTaskAction -- success/error --> Toast
end
subgraph use-cases/tasks.tsx
createTaskAction -- validateInput \ user, {title, description} --> createTaskUseCase
createTaskUseCase -- validation --> createTaskUseCase
end
subgraph data-access/tasks.ts
createTaskUseCase -- newTask: InsertTask --> createTask
end
createTask -- Drizzle SQL query --> DB[(Database)]
DB -- result --> createTask
createTask -- success/error --> createTaskUseCase
createTaskUseCase -- result --> createTaskAction
Toast --> End([Task created or Error shown])
style Start fill:#ff,stroke:#333,stroke-width:2px
style End fill:#ff,stroke:#333,stroke-width:2px
style DB fill:#ff,stroke:#333,stroke-width:2px