Published on

Data-table in Next.js with Useable Features

Authors
  • avatar
    Name
    Karlo Karlović
    Twitter

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:

  1. 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.
  2. 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)
DataTable
DataTableAddTaskSheet

Here is it in action!

DataTableAddTaskSheetInAction

Code Implementation

  • In dashboard/[company]/tasks/page.tsx, return <DataTable> with column data, actual data from the database, companyId, and form={<CreateTaskButton userId={user.id} />}
  • On 'Create task' click, initiate a shadcn Sheet component
  • 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
  • Submitted form content is passed to createTasksAction via useServerAction function from the zsa-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
  • The createTasksUseCase is where the business logic would sit. This then executes the createTasks action in the database
  • createTasksUseCase is executed, ensures what was passed into it matches the Drizzle generated InsertTask 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):

CreatePost

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

Footnotes

  1. I will just make it responsive on the web app, and that can be the phone view.

  2. Not everything will be shown. Some files that were not included are: tasks/data/data.tsx, tasks/data/schema.tsx, components/interactive-overlay.tsx.