import type { ReactNode } from "react"; import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query"; import ConfirmDialog from "./ConfirmDialog"; import { useCrudForm } from "./useCrudForm"; interface Row { id: number; name: string; } function setup() { const created: string[] = []; const updated: { id: number; input: string }[] = []; const removed: number[] = []; const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); const hook = renderHook( () => useCrudForm({ create: (_qc: QC) => ({ mutationFn: async (input: string) => { created.push(input); }, }), update: (_qc: QC) => ({ mutationFn: async (vars: { id: number; input: string }) => { updated.push(vars); }, }), remove: (_qc: QC) => ({ mutationFn: async (id: number) => { removed.push(id); }, }), confirmDelete: (row) => `Delete "${row.name}"?`, }), { wrapper }, ); return { hook, created, updated, removed }; } /** * The delete path is only finished when a real dialog drives it, so this * harness wires the hook's pending-delete state to the component operators * actually see (ruling 5). `window.confirm` no longer exists to stub. */ function DeleteHarness({ onRemoved }: { onRemoved: (id: number) => void }) { const { onDelete, pendingDelete, confirmPendingDelete, cancelPendingDelete } = useCrudForm({ create: (_qc: QC) => ({ mutationFn: async () => undefined }), update: (_qc: QC) => ({ mutationFn: async () => undefined }), remove: (_qc: QC) => ({ mutationFn: async (id: number) => { onRemoved(id); }, }), confirmDelete: (row) => `Delete "${row.name}"?`, }); return ( <> ); } function renderDeleteHarness() { const removed: number[] = []; const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); render( removed.push(id)} /> , ); return removed; } afterEach(() => vi.unstubAllGlobals()); test("the create form submits through the create mutation and closes", async () => { const { hook, created, updated } = setup(); act(() => hook.result.current.openForm({ mode: "create" })); expect(hook.result.current.form).toEqual({ mode: "create" }); act(() => hook.result.current.onSubmit("alpha")); await waitFor(() => expect(hook.result.current.form).toBeNull()); expect(created).toEqual(["alpha"]); expect(updated).toEqual([]); }); test("the edit form submits the row id through the update mutation", async () => { const { hook, created, updated } = setup(); act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 7, name: "beta" } })); act(() => hook.result.current.onSubmit("beta2")); await waitFor(() => expect(hook.result.current.form).toBeNull()); expect(updated).toEqual([{ id: 7, input: "beta2" }]); expect(created).toEqual([]); }); test("submitting with no form open does nothing", () => { const { hook, created, updated } = setup(); act(() => hook.result.current.onSubmit("ignored")); expect(created).toEqual([]); expect(updated).toEqual([]); }); test("openForm clears a stale create error so the reopened form starts clean", async () => { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); const hook = renderHook( () => useCrudForm({ create: (_qc: QC) => ({ mutationFn: () => Promise.reject(new Error("boom")) }), update: (_qc: QC) => ({ mutationFn: async () => undefined }), remove: (_qc: QC) => ({ mutationFn: async () => undefined }), confirmDelete: () => "?", }), { wrapper }, ); act(() => hook.result.current.openForm({ mode: "create" })); act(() => hook.result.current.onSubmit("alpha")); await waitFor(() => expect(hook.result.current.create.error).not.toBeNull()); expect(hook.result.current.form).toEqual({ mode: "create" }); act(() => hook.result.current.openForm({ mode: "create" })); await waitFor(() => expect(hook.result.current.create.error).toBeNull()); }); test("onDelete opens the confirmation instead of removing the row", () => { const { hook, removed } = setup(); act(() => hook.result.current.onDelete({ id: 3, name: "gamma" })); expect(hook.result.current.pendingDelete).toEqual({ entity: { id: 3, name: "gamma" }, message: 'Delete "gamma"?' }); expect(removed).toEqual([]); }); test("cancelling the confirmation dialog leaves the row alone", async () => { const removed = renderDeleteHarness(); fireEvent.click(screen.getByRole("button", { name: "Delete gamma" })); const dialog = await screen.findByRole("alertdialog"); expect(dialog.textContent).toContain('Delete "gamma"?'); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); expect(removed).toEqual([]); }); test("confirming the dialog removes the row by id", async () => { const removed = renderDeleteHarness(); fireEvent.click(screen.getByRole("button", { name: "Delete gamma" })); await screen.findByRole("alertdialog"); fireEvent.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => expect(removed).toEqual([3])); expect(screen.queryByRole("alertdialog")).toBeNull(); }); test("closeForm closes whichever form is open", () => { const { hook } = setup(); act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 1, name: "delta" } })); act(() => hook.result.current.closeForm()); expect(hook.result.current.form).toBeNull(); });