Files
nxdns/admin/src/ui/useCrudForm.test.tsx
T

180 lines
6.2 KiB
TypeScript

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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const hook = renderHook(
() =>
useCrudForm<Row, string>({
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<Row, string>({
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 (
<>
<button type="button" onClick={() => onDelete({ id: 3, name: "gamma" })}>
Delete gamma
</button>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete row"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</>
);
}
function renderDeleteHarness() {
const removed: number[] = [];
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<DeleteHarness onRemoved={(id) => removed.push(id)} />
</QueryClientProvider>,
);
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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const hook = renderHook(
() =>
useCrudForm<Row, string>({
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();
});