180 lines
6.2 KiB
TypeScript
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();
|
|
});
|