rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user