# Record grid

> An app table as a data grid with search, filters, saved views and bulk actions on the selected rows.

- Block: [saved-views](https://bettersupabase.com/blocks/saved-views.md)
- Kind: screen
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/record-grid

## Install the block

```bash
npx better-supabase add saved-views
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add badge button dialog input select @reui/data-grid @reui/filters
```

From `apps/examples/nextjs/src/features/projects/components/task-grid.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/projects/components/task-grid.tsx):

```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>
  );
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Record grid" in a React web app that uses shadcn/ui components. An app table as a data grid with search, filters, saved views and bulk actions on the selected rows.

It uses the better-supabase `saved-views` block (Saved views). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add saved-views` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add badge button dialog input select @reui/data-grid @reui/filters`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the screen. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/projects/components/task-grid.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/record-grid.md
- Block docs: https://bettersupabase.com/docs/blocks/saved-views
```
