Work item board
Work items in state columns, ordered by rank and moved through allowed transitions. 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 card @reui/kanbanInstall the glue
Install the blocks with
npx better-supabase add work-items state-machine rank. 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 examplework-item-board.tsx /** * Work items in the columns of the workflow's stages, each column labelled * with the state its stage stands for. Dragging to another column changes * the stage, which the pipelines trigger checks against the allowed * transitions; dragging within a column saves the order through the rank * block. A refused move puts the card back. The parent keys it by the * items' positions, so a server refresh starts it from the saved board. */ export function WorkItemBoard({ stages, items, canMove, }: { stages: readonly StageColumn[]; items: readonly WorkItemRow[]; canMove: boolean; }) { const t = useExtracted("work"); const errorMessage = useErrorMessage(); const stateLabel = useStateLabel(); const workErrorHint = useWorkErrorHint(); const [columns, setColumns] = useState(() => columnsOf(stages, items)); const move = useAction(moveWorkItem); const byId = new Map(items.map((item) => [item.id, item])); return ( <div data-recipe="work-item-board"> <Kanban value={columns} onValueChange={setColumns} getItemValue={(item) => item.id} onValueCommit={(value, meta) => { if (meta.kind !== "item") { return; } const column = value[meta.overContainer] ?? []; const previous = column[meta.overIndex - 1]; const next = column[meta.overIndex + 1]; void move .run({ id: String(meta.event.active.id), stageId: meta.overContainer, stageChanged: meta.activeContainer !== meta.overContainer, previousId: previous?.id ?? null, nextId: next?.id ?? null, }) .then((result) => { if (!result.ok) { setColumns(meta.previousValue); toast.error( workErrorHint(result.error.hint) ?? errorMessage(result.error), ); return; } if (!result.data.ordered) { toast.warning( t("Moved, but its place in the column wasn't saved."), ); } }); }} > <KanbanBoard className="flex overflow-x-auto"> {stages.map((stage) => { const state = stateOf(stage.category); const count = columns[stage.id]?.length ?? 0; return ( <KanbanColumn key={stage.id} value={stage.id} className="w-72 shrink-0 gap-3 rounded-lg bg-muted/50 p-3" > <div className="flex items-start justify-between gap-2"> <div className="flex min-w-0 flex-col gap-1"> <span className="truncate text-sm font-medium"> {stage.name} </span> {state === null ? null : ( <Badge variant={stateBadgeVariant(state)}> {stateLabel(state)} </Badge> )} </div> <Badge variant={ stage.wipLimit !== null && count > stage.wipLimit ? "destructive-light" : "outline" } > {stage.wipLimit === null ? count : t("{count, number} of {limit, number}", { count, limit: stage.wipLimit, })} </Badge> </div> <KanbanColumnContent value={stage.id} className="min-h-16"> {(columns[stage.id] ?? []).map((item) => ( <KanbanItem key={item.id} value={item.id} disabled={!canMove || move.pending} > <KanbanItemHandle cursor={canMove}> <WorkItemCard item={item} /> </KanbanItemHandle> </KanbanItem> ))} {count === 0 ? ( <p className="text-xs text-muted-foreground"> {t("Drop an item here.")} </p> ) : null} </KanbanColumnContent> </KanbanColumn> ); })} </KanbanBoard> <KanbanOverlay> {({ value, variant }) => { const item = variant === "item" ? byId.get(String(value)) : undefined; return item ? <WorkItemCard item={item} /> : null; }} </KanbanOverlay> </Kanban> </div> ); }