RegistryUI recipes
Bulk action bar
A bar over a selection of rows that runs an action on all of them and reports failures.
It loads its data on the server through the block's client; the bulk block must be installed (npx better-supabase add bulk). Its page on bettersupabase.com is /recipes/bulk-action-bar.
Web, with shadcn components:
npx shadcn add alert alert-dialog button select/**
* The bar over the grid's selected rows. It changes their state or moves them
* to the trash through the bulk block, which runs each row on its own; the rows
* that failed stay selected and are listed with their reason.
*/
export function BulkActionBar({
selected,
onSettled,
}: {
selected: readonly Pick<WorkItemRow, "id" | "identifier">[];
onSettled: (failedIds: readonly string[]) => void;
}) {
const t = useExtracted("work");
const errorMessage = useErrorMessage();
const stateLabel = useStateLabel();
const [to, setTo] = useState<WorkItemState | null>(null);
const [confirming, setConfirming] = useState(false);
const [failures, setFailures] = useState<readonly BulkFailure[]>([]);
const bulk = useAction(runWorkBulkAction);
const identifiers = new Map(selected.map((row) => [row.id, row.identifier]));
const run = async (
input: { action: "state"; to: WorkItemState } | { action: "delete" },
): Promise<void> => {
const ids = selected.map((row) => row.id);
const result = await bulk.run({ ...input, ids });
if (!result.ok) {
toast.error(errorMessage(result.error));
return;
}
const { succeeded, failed } = result.data;
setFailures(
failed.map((row) => ({
id: row.id,
identifier: identifiers.get(row.id) ?? row.id,
message: row.message === "" ? t("No reason given.") : row.message,
})),
);
if (failed.length === 0) {
toast.success(
t("{count, plural, one {# item updated} other {# items updated}}", {
count: succeeded,
}),
);
} else {
toast.warning(
t("{failed, number} of {total, number} items failed", {
failed: failed.length,
total: result.data.total,
}),
);
}
onSettled(failed.map((row) => row.id));
};
const stateItems = WORK_ITEM_STATES.map((state) => ({
value: state,
label: stateLabel(state),
}));
if (selected.length === 0 && failures.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-3" data-recipe="bulk-action-bar">
{selected.length === 0 ? null : (
<div
role="toolbar"
aria-label={t("Selected items")}
className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/50 p-2"
>
<span className="px-2 text-sm font-medium tabular-nums">
{t("{count, plural, one {# selected} other {# selected}}", {
count: selected.length,
})}
</span>
<Select
value={to}
items={stateItems}
disabled={bulk.pending}
onValueChange={(value) => {
const state = WORK_ITEM_STATES.find((each) => each === value);
setTo(state ?? null);
}}
>
<SelectTrigger
size="sm"
className="w-40"
aria-label={t("New state")}
>
<SelectValue placeholder={t("Set state")} />
</SelectTrigger>
<SelectContent>
{stateItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
disabled={to === null || bulk.pending}
onClick={() => {
if (to !== null) {
void run({ action: "state", to });
}
}}
>
{t("Apply")}
</Button>
<AlertDialog
open={confirming}
onOpenChange={(next) => {
if (!bulk.pending) {
setConfirming(next);
}
}}
>
<AlertDialogTrigger
render={
<Button
type="button"
size="sm"
variant="destructive"
disabled={bulk.pending}
/>
}
>
<Trash2Icon />
{t("Delete")}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(
"{count, plural, one {Delete # item?} other {Delete # items?}}",
{ count: selected.length },
)}
</AlertDialogTitle>
<AlertDialogDescription>
{t("They move to the trash, where they can be restored.")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulk.pending}>
{t("Cancel")}
</AlertDialogCancel>
<AlertDialogAction
type="button"
variant="destructive"
disabled={bulk.pending}
onClick={() => {
void run({ action: "delete" }).then(() => {
setConfirming(false);
});
}}
>
{t("Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
size="sm"
variant="ghost"
disabled={bulk.pending}
onClick={() => {
onSettled([]);
}}
>
<XIcon />
{t("Clear")}
</Button>
</div>
)}
{failures.length === 0 ? null : (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertTitle>
{t(
"{count, plural, one {# item wasn't changed} other {# items weren't changed}}",
{ count: failures.length },
)}
</AlertTitle>
<AlertDescription>
<ul className="flex flex-col gap-1">
{failures.map((failure) => (
<li key={failure.id}>
<span className="font-medium tabular-nums">
{failure.identifier}
</span>
{": "}
{failure.message}
</li>
))}
</ul>
</AlertDescription>
<AlertAction>
<Button
size="sm"
variant="outline"
onClick={() => {
setFailures([]);
}}
>
{t("Dismiss")}
</Button>
</AlertAction>
</Alert>
)}
</div>
);
}Last updated on