feat(web): reusable DeleteConfirmDialog with in-use handling + stories

This commit is contained in:
2026-06-05 20:12:23 +02:00
parent 282e6430d4
commit 194f18c8ed
2 changed files with 106 additions and 0 deletions
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent, fn, within } from 'storybook/test'
import { DeleteConfirmDialog } from './delete-confirm-dialog'
import { InUseError } from '../api/queries'
const meta = {
component: DeleteConfirmDialog,
tags: ['ai-generated'],
} satisfies Meta<typeof DeleteConfirmDialog>
export default meta
type Story = StoryObj<typeof meta>
export const Confirms: Story = {
args: { description: 'Delete this term? This cannot be undone.', onConfirm: fn() },
play: async ({ canvas, args }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Delete' }))
const confirm = await within(document.body).findByRole('button', { name: 'Delete' })
await userEvent.click(confirm)
await expect(args.onConfirm).toHaveBeenCalled()
},
}
export const ShowsInUse: Story = {
args: {
description: 'Delete this term? This cannot be undone.',
onConfirm: async () => { throw new InUseError(7) },
},
play: async ({ canvas }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Delete' }))
const confirm = await within(document.body).findByRole('button', { name: 'Delete' })
await userEvent.click(confirm)
await expect(await within(document.body).findByRole('alert')).toHaveTextContent(/used by 7/i)
},
}
@@ -0,0 +1,70 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { InUseError } from "../api/queries";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
export function DeleteConfirmDialog({
description,
onConfirm,
triggerLabel,
}: {
/** Confirmation prompt, e.g. t("actions.confirmDeleteTerm"). */
description: string;
/** Performs the delete; may throw InUseError to surface the in-use count. */
onConfirm: () => Promise<void>;
/** Optional override for the trigger button text (defaults to actions.delete). */
triggerLabel?: string;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const confirm = async () => {
setMessage(null);
try {
await onConfirm();
} catch (err) {
// Keep the dialog open; show the blocking reason. Never let the rejected
// mutation escape as an unhandled rejection.
setMessage(err instanceof InUseError ? t("actions.inUse", { count: err.count }) : t("form.rejected"));
return;
}
setOpen(false);
};
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button variant="ghost" size="sm" className="text-red-600">
{triggerLabel ?? t("actions.delete")}
</Button>
}
/>
<AlertDialogContent>
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
{message && (
<p role="alert" className="text-sm text-red-600">
{message}
</p>
)}
<AlertDialogFooter>
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={confirm}>{t("actions.delete")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}