Record grid
An app table as a data grid with search, filters, saved views and bulk actions on the selected rows. It loads its data on the server through the block's client.
pnpm dev:example) to see it.Add the components
terminal npx shadcn add badge button dialog input select @reui/data-grid @reui/filtersInstall the glue
Install the block with
npx better-supabase add saved-views. The glue items are copied into your app, so edit them as you need.None: the page calls the block's client on the server.
Write the screen
React and shadcn
View in the exampletask-grid.tsx const STATUS_FIELD = "status"; const PROJECT_FIELD = "project"; const ONLY_IS = [{ value: "is", label: "is" }]; /** The URL's status and project filters as a ReUI filter query. */ function toFilterQuery(query: TaskQuery): FilterQuery<string> { return createFilterQuery([ ...(query.status === null ? [] : [ createFilterRule({ id: STATUS_FIELD, path: [STATUS_FIELD], operator: "is", value: query.status, }), ]), ...(query.projectId === null ? [] : [ createFilterRule({ id: PROJECT_FIELD, path: [PROJECT_FIELD], operator: "is", value: query.projectId, }), ]), ]); } /** The one value a filter rule holds, or null once it is cleared. */ function valueOf(query: FilterQuery<string>, field: string): string | null { const condition = flattenFilterConditions(query).find( (each) => each.field === field, ); const value = v.safeParse(v.string(), condition?.values[0]); return value.success ? value.output : null; } /** * The search, the filters and the saved views every task screen shares. They * live in the URL, so a saved view is the screen plus its search string. */ export function TaskToolbar({ query, projects, views, canSaveViews, }: { query: TaskQuery; projects: readonly ProjectOption[]; views: readonly TaskViewOption[]; canSaveViews: boolean; }) { const t = useExtracted("projects"); const router = useRouter(); const pathname = usePathname(); const statusLabel = useStatusLabel(); const errorMessage = useErrorMessage(); const [navigating, startTransition] = useTransition(); const [search, setSearch] = useState(query.q); const removeView = useAction(deleteTaskView, { onError: (error) => { toast.error(errorMessage(error)); }, }); const go = (next: TaskQuery) => { startTransition(() => { router.replace(`${pathname}${taskSearch(next)}`); }); }; const fields: FilterField<string>[] = [ { id: STATUS_FIELD, label: t("Status"), type: "select", operators: ONLY_IS, options: TASK_STATUSES.map((status) => ({ value: status, label: statusLabel(status), })), }, { id: PROJECT_FIELD, label: t("Project"), type: "select", operators: ONLY_IS, options: projects.map((project) => ({ value: project.id, label: project.name, })), }, ]; const activeView = views.find( (view) => taskSearch(view.query) === taskSearch(query), ); return ( <div className="flex flex-wrap items-center gap-2"> <form className="flex items-center gap-2" onSubmit={(event) => { event.preventDefault(); go({ ...query, q: search.trim() }); }} > <Input type="search" className="w-56" value={search} placeholder={t("Search tasks")} aria-label={t("Search tasks")} onChange={(event) => { setSearch(event.target.value); }} /> <Button type="submit" size="icon-sm" variant="outline" aria-label={t("Search")} disabled={navigating} > <SearchIcon /> </Button> </form> <Filters size="sm" fields={fields} query={toFilterQuery(query)} showClear onQueryChange={(next) => { const status = TASK_STATUSES.find( (each) => each === valueOf(next, STATUS_FIELD), ); go({ ...query, status: status ?? null, projectId: valueOf(next, PROJECT_FIELD), }); }} /> <div className="ml-auto flex items-center gap-2"> {views.length === 0 ? null : ( <Select value={activeView?.id ?? null} items={views.map((view) => ({ value: view.id, label: view.name }))} onValueChange={(value) => { const view = views.find((each) => each.id === value); if (view) { setSearch(view.query.q); go(view.query); } }} > <SelectTrigger size="sm" className="w-44" aria-label={t("Saved views")} > <SelectValue placeholder={t("Saved views")} /> </SelectTrigger> <SelectContent> {views.map((view) => ( <SelectItem key={view.id} value={view.id}> {view.name} </SelectItem> ))} </SelectContent> </Select> )} {activeView ? ( <Button size="icon-sm" variant="outline" aria-label={t("Delete view {name}", { name: activeView.name })} disabled={removeView.pending} onClick={() => { void removeView.run({ id: activeView.id }); }} > <Trash2Icon /> </Button> ) : null} {canSaveViews ? <SaveTaskViewDialog query={query} /> : null} </div> </div> ); } function SaveTaskViewDialog({ query }: { query: TaskQuery }) { const t = useExtracted("projects"); const fieldId = useId(); const errorMessage = useErrorMessage(); const [open, setOpen] = useState(false); const [name, setName] = useState(""); const save = useAction(saveTaskView); const error = save.error === undefined ? null : errorMessage(save.error); return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger render={<Button size="sm" variant="outline" />}> <BookmarkIcon /> {t("Save view")} </DialogTrigger> <DialogContent> <form className="grid gap-6" onSubmit={(event) => { event.preventDefault(); void save.run({ name, ...query }).then((result) => { if (result.ok) { toast.success(t("View saved")); setName(""); setOpen(false); } }); }} > <DialogHeader> <DialogTitle>{t("Save view")}</DialogTitle> <DialogDescription> {t("Keeps the screen, the search and the filters.")} </DialogDescription> </DialogHeader> <Field data-invalid={error ? true : undefined}> <FieldLabel htmlFor={`${fieldId}-name`}>{t("Name")}</FieldLabel> <Input id={`${fieldId}-name`} value={name} required maxLength={80} autoComplete="off" onChange={(event) => { setName(event.target.value); }} /> {error ? <FieldError>{error}</FieldError> : null} </Field> <DialogFooter> <DialogClose render={<Button type="button" variant="outline" />}> {t("Cancel")} </DialogClose> <Button type="submit" disabled={save.pending}> {t("Save")} </Button> </DialogFooter> </form> </DialogContent> </Dialog> ); } type GridRow = TaskRow & { readonly projectName: string; readonly assigneeName: string; }; type GridCell = CellContext<DataGridFeatures, GridRow>; const SELECT_COLUMN = { id: "select", header: () => <DataGridTableRowSelectAll />, cell: ({ row }) => <DataGridTableRowSelect row={row} />, size: 40, } satisfies ColumnDef<DataGridFeatures, GridRow>; function TitleCell({ row }: GridCell) { return ( <Link href={`/projects/${row.original.projectId}`} className="font-medium hover:underline" > {row.original.title} </Link> ); } function StatusCell({ row }: GridCell) { return <TaskStatusBadge status={row.original.status} />; } /** * Tasks in a ReUI data grid. The checkboxes feed a bulk bar that sets the * status of the selected rows or deletes them in one statement. */ export function TaskGrid({ rows, projects, members, canWrite, }: { rows: readonly TaskRow[]; projects: readonly ProjectOption[]; members: readonly MemberOption[]; canWrite: boolean; }) { const t = useExtracted("projects"); const statusLabel = useStatusLabel(); const errorMessage = useErrorMessage(); const [selection, setSelection] = useState<RowSelectionState>({}); const projectName = new Map(projects.map((each) => [each.id, each.name])); const memberName = new Map(members.map((each) => [each.userId, each.name])); const bulk = useAction(runTaskBulkAction, { onSuccess: ({ count }) => { toast.success( t("{count, plural, one {# task} other {# tasks}} changed", { count }), ); setSelection({}); }, onError: (error) => { toast.error(errorMessage(error)); }, }); const columns: ColumnDef<DataGridFeatures, GridRow>[] = [ ...(canWrite ? [SELECT_COLUMN] : []), { accessorKey: "title", header: t("Title"), cell: TitleCell, size: 280 }, { accessorKey: "status", header: t("Status"), cell: StatusCell, size: 120 }, { accessorKey: "projectName", header: t("Project"), size: 180 }, { accessorKey: "assigneeName", header: t("Assignee"), size: 160 }, { accessorKey: "dueOn", header: t("Due"), size: 120 }, ]; const data = rows.map((row) => ({ ...row, projectName: projectName.get(row.projectId) ?? "", assigneeName: row.assigneeId === null ? t("Unassigned") : (memberName.get(row.assigneeId) ?? ""), })); const table = useTable({ features: dataGridFeatures, columns, data, getRowId: (row) => row.id, state: { rowSelection: selection }, enableRowSelection: canWrite, onRowSelectionChange: setSelection, }); const ids = rows.filter((row) => selection[row.id]).map((row) => row.id); return ( <div className="flex flex-col gap-3" data-recipe="record-grid"> {ids.length > 0 ? ( <div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/50 p-2"> <span className="text-sm"> {t("{count, plural, one {# selected} other {# selected}}", { count: ids.length, })} </span> {TASK_STATUSES.map((status) => ( <Button key={status} size="sm" variant="outline" disabled={bulk.pending} onClick={() => { void bulk.run({ kind: "status", ids, status }); }} > {t("Mark {status}", { status: statusLabel(status) })} </Button> ))} <Button size="sm" variant="destructive" className="ml-auto" disabled={bulk.pending} onClick={() => { void bulk.run({ kind: "delete", ids }); }} > <Trash2Icon /> {t("Delete")} </Button> </div> ) : null} <DataGrid table={table} recordCount={rows.length}> <DataGridContainer> <DataGridScrollArea> <DataGridTable /> </DataGridScrollArea> </DataGridContainer> </DataGrid> </div> ); }