# Work item grid

> A data grid of work items with filters, sorting and saved views.

- Blocks: [work-items](https://bettersupabase.com/blocks/work-items.md), [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/work-item-grid

## Install the blocks

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

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add badge button checkbox dialog field input select table
```

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

```tsx
/**
 * Work items as a table. The search, state filter and sort live in the URL
 * and can be saved as named views. State and assignee edit in place, and
 * the checkboxes feed the bulk action bar.
 */
export function WorkItemGrid({
  rows,
  stages,
  members,
  views,
  query,
  canWrite,
  canBulk,
}: {
  rows: readonly WorkItemRow[];
  stages: readonly StageColumn[];
  members: readonly MemberOption[];
  views: readonly SavedViewOption[];
  query: WorkGridQuery;
  canWrite: boolean;
  canBulk: boolean;
}) {
  const t = useExtracted("work");
  const router = useRouter();
  const pathname = usePathname();
  const errorMessage = useErrorMessage();
  const workErrorHint = useWorkErrorHint();
  const stateLabel = useStateLabel();
  const priorityLabel = usePriorityLabel();
  const sortLabel = useSortLabel();
  const [navigating, startTransition] = useTransition();
  const [search, setSearch] = useState(query.q);
  const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());

  const onEditError = (error: DbError) => {
    toast.error(workErrorHint(error.hint) ?? errorMessage(error));
  };

  const stage = useAction(setWorkItemStage, { onError: onEditError });
  const assign = useAction(setWorkItemAssignee, { onError: onEditError });

  const removeView = useAction(deleteWorkView, {
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  const go = (next: WorkGridQuery) => {
    startTransition(() => {
      router.replace(`${pathname}${workGridSearch(next)}`);
    });
  };

  const visibleIds = new Set(rows.map((row) => row.id));
  const selection = [...selected].filter((id) => visibleIds.has(id));
  const selectedRows = rows.filter((row) => selected.has(row.id));
  const allSelected = rows.length > 0 && selection.length === rows.length;

  const stateItems = [
    { value: ANY, label: t("Any state") },
    ...WORK_ITEM_STATES.map((state) => ({
      value: state,
      label: stateLabel(state),
    })),
  ];

  const sortItems = WORK_ITEM_SORTS.map((sort) => ({
    value: sort,
    label: sortLabel(sort),
  }));

  const stageItems = stages.map((each) => ({
    value: each.id,
    label: each.name,
  }));

  const memberItems = [
    { value: UNASSIGNED, label: t("Unassigned") },
    ...members.map((member) => ({
      value: member.userId,
      label: member.name,
    })),
  ];

  const viewItems = views.map((view) => ({
    value: view.id,
    label: view.name,
  }));

  const activeView = views.find(
    (view) => workGridSearch(view.query) === workGridSearch(query),
  );

  return (
    <div className="flex flex-col gap-4" data-recipe="work-item-grid">
      <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 titles")}
            aria-label={t("Search titles")}
            onChange={(event) => {
              setSearch(event.target.value);
            }}
          />
          <Button
            type="submit"
            size="icon-sm"
            variant="outline"
            aria-label={t("Search")}
            disabled={navigating}
          >
            <SearchIcon />
          </Button>
        </form>
        <Select
          value={query.state ?? ANY}
          items={stateItems}
          onValueChange={(value) => {
            const state = WORK_ITEM_STATES.find((each) => each === value);

            go({ ...query, state: state ?? null });
          }}
        >
          <SelectTrigger size="sm" className="w-36" aria-label={t("State")}>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {stateItems.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <Select
          value={query.sort}
          items={sortItems}
          onValueChange={(value) => {
            const sort = WORK_ITEM_SORTS.find((each) => each === value);

            go({ ...query, sort: sort ?? "newest" });
          }}
        >
          <SelectTrigger size="sm" className="w-36" aria-label={t("Sort")}>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {sortItems.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <div className="ml-auto flex items-center gap-2">
          {views.length === 0 ? null : (
            <Select
              value={activeView?.id ?? null}
              items={viewItems}
              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>
                {viewItems.map((item) => (
                  <SelectItem key={item.value} value={item.value}>
                    {item.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          )}
          {activeView ? (
            <Button
              size="icon-sm"
              variant="ghost"
              aria-label={t("Delete view {name}", { name: activeView.name })}
              disabled={removeView.pending}
              onClick={() => {
                void removeView.run({ id: activeView.id });
              }}
            >
              <Trash2Icon />
            </Button>
          ) : (
            <SaveViewDialog query={query} />
          )}
        </div>
      </div>
      {canBulk ? (
        <BulkActionBar
          selected={selectedRows}
          onSettled={(failedIds) => {
            setSelected(new Set(failedIds));
          }}
        />
      ) : null}
      <Table>
        <TableHeader>
          <TableRow>
            {canBulk ? (
              <TableHead className="w-8">
                <Checkbox
                  checked={allSelected}
                  indeterminate={selection.length > 0 && !allSelected}
                  aria-label={t("Select all")}
                  onCheckedChange={(checked) => {
                    setSelected(
                      checked ? new Set(rows.map((row) => row.id)) : new Set(),
                    );
                  }}
                />
              </TableHead>
            ) : null}
            <TableHead>{t("ID")}</TableHead>
            <TableHead>{t("Title")}</TableHead>
            <TableHead>{t("State")}</TableHead>
            <TableHead>{t("Priority")}</TableHead>
            <TableHead>{t("Assignee")}</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((row) => {
            const pendingStage = stage.pendingInputs.find(
              (input) => input.id === row.id,
            );

            const pendingAssignee = assign.pendingInputs.find(
              (input) => input.id === row.id,
            );

            const assignee = pendingAssignee
              ? (pendingAssignee.userId ?? UNASSIGNED)
              : (row.assigneeIds[0] ?? UNASSIGNED);

            return (
              <TableRow
                key={row.id}
                data-state={selected.has(row.id) ? "selected" : undefined}
              >
                {canBulk ? (
                  <TableCell>
                    <Checkbox
                      checked={selected.has(row.id)}
                      aria-label={t("Select {identifier}", {
                        identifier: row.identifier,
                      })}
                      onCheckedChange={(checked) => {
                        setSelected((current) => {
                          const next = new Set(current);

                          if (checked) {
                            next.add(row.id);
                          } else {
                            next.delete(row.id);
                          }

                          return next;
                        });
                      }}
                    />
                  </TableCell>
                ) : null}
                <TableCell className="text-muted-foreground tabular-nums">
                  {row.identifier}
                </TableCell>
                <TableCell className="font-medium">{row.title}</TableCell>
                <TableCell>
                  {canWrite && stages.length > 0 ? (
                    <Select
                      value={pendingStage?.stageId ?? row.stageId}
                      items={stageItems}
                      disabled={pendingStage !== undefined}
                      onValueChange={(value) => {
                        if (value !== null && value !== row.stageId) {
                          void stage.run({ id: row.id, stageId: value });
                        }
                      }}
                    >
                      <SelectTrigger
                        size="sm"
                        className="w-40"
                        aria-label={t("State of {identifier}", {
                          identifier: row.identifier,
                        })}
                      >
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {stageItems.map((item) => (
                          <SelectItem key={item.value} value={item.value}>
                            {item.label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  ) : (
                    <Badge variant={stateBadgeVariant(row.state)}>
                      {stateLabel(row.state)}
                    </Badge>
                  )}
                </TableCell>
                <TableCell className="text-muted-foreground">
                  {priorityLabel(row.priority)}
                </TableCell>
                <TableCell>
                  {canWrite ? (
                    <Select
                      value={assignee}
                      items={memberItems}
                      disabled={pendingAssignee !== undefined}
                      onValueChange={(value) => {
                        void assign.run({
                          id: row.id,
                          userId:
                            value === null || value === UNASSIGNED
                              ? null
                              : value,
                        });
                      }}
                    >
                      <SelectTrigger
                        size="sm"
                        className="w-44"
                        aria-label={t("Assignee of {identifier}", {
                          identifier: row.identifier,
                        })}
                      >
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {memberItems.map((item) => (
                          <SelectItem key={item.value} value={item.value}>
                            {item.label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  ) : (
                    <span className="text-sm text-muted-foreground">
                      {members.find((member) => member.userId === assignee)
                        ?.name ?? t("Unassigned")}
                    </span>
                  )}
                </TableCell>
              </TableRow>
            );
          })}
        </TableBody>
      </Table>
    </div>
  );
}
```

## Prompt

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

### Web

```text
Build "Work item grid" in a React web app that uses shadcn/ui components. A data grid of work items with filters, sorting and saved views.

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

Steps:

1. If the app doesn't have the blocks yet, add the SQL with `npx better-supabase add work-items saved-views` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add badge button checkbox dialog field input select table`.
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/work/components/work-item-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/work-item-grid.md
- Block docs: https://bettersupabase.com/docs/blocks/work-items
```
