RegistryUI recipes
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; the work-items, state-machine, rank blocks must be installed (npx better-supabase add work-items state-machine rank). Its page on bettersupabase.com is /recipes/work-item-board.
Web, with shadcn components:
npx shadcn add badge card @reui/kanban/**
* 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>
);
}Last updated on