From 194f18c8ede37c810cd793cdf8b975dec872180e Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 5 Jun 2026 20:12:23 +0200 Subject: [PATCH] feat(web): reusable DeleteConfirmDialog with in-use handling + stories --- .../delete-confirm-dialog.stories.tsx | 36 ++++++++++ web/src/components/delete-confirm-dialog.tsx | 70 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 web/src/components/delete-confirm-dialog.stories.tsx create mode 100644 web/src/components/delete-confirm-dialog.tsx diff --git a/web/src/components/delete-confirm-dialog.stories.tsx b/web/src/components/delete-confirm-dialog.stories.tsx new file mode 100644 index 0000000..6665f80 --- /dev/null +++ b/web/src/components/delete-confirm-dialog.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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) + }, +} diff --git a/web/src/components/delete-confirm-dialog.tsx b/web/src/components/delete-confirm-dialog.tsx new file mode 100644 index 0000000..190d2ff --- /dev/null +++ b/web/src/components/delete-confirm-dialog.tsx @@ -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; + /** 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(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 ( + + + {triggerLabel ?? t("actions.delete")} + + } + /> + + {t("actions.delete")} + {description} + {message && ( +

+ {message} +

+ )} + + {t("form.cancel")} + {t("actions.delete")} + +
+
+ ); +}