feat(web): reusable DeleteConfirmDialog with in-use handling + stories
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user