milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The shared Tailwind class vocabulary (milestone 18, ruling 10).
|
||||
*
|
||||
* Every interactive element must carry `focusRing`; that is the milestone-9
|
||||
* accessibility floor. Compose these constants for one-off variants
|
||||
* (`` `${buttonClass} md:hidden` ``) instead of re-spelling the literal.
|
||||
*/
|
||||
|
||||
export const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
|
||||
|
||||
/** The ring drawn inside the element, for controls flush against a panel edge. */
|
||||
export const insetFocusRing = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600";
|
||||
|
||||
export const inputClass = `mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
|
||||
export const smallInputClass = `rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
|
||||
|
||||
export const buttonClass = `rounded border border-zinc-300 px-3 py-1.5 text-sm ${focusRing} dark:border-zinc-700`;
|
||||
export const smallButtonClass = `rounded border border-zinc-300 px-2 py-1 text-sm ${focusRing} dark:border-zinc-700`;
|
||||
export const largeButtonClass = `rounded border border-zinc-300 px-3 py-2 font-medium ${focusRing} dark:border-zinc-700`;
|
||||
|
||||
export const primaryButtonClass = `rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 ${focusRing}`;
|
||||
export const largePrimaryButtonClass = `rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 ${focusRing}`;
|
||||
|
||||
export const rowButtonClass = `rounded px-2 py-1 text-sm text-blue-600 ${focusRing} dark:text-blue-400`;
|
||||
export const linkButtonClass = `text-sm font-medium text-blue-600 ${focusRing} dark:text-blue-400`;
|
||||
export const dangerLinkButtonClass = `text-sm font-medium text-red-600 disabled:opacity-50 ${focusRing} dark:text-red-400`;
|
||||
export const retryButtonClass = `mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 ${focusRing} dark:border-red-800 dark:text-red-200`;
|
||||
|
||||
export const thClass = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||
export const tdClass = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||
export const tableWrapClass = "mt-4 overflow-x-auto";
|
||||
export const formCardClass = "mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800";
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
|
||||
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 };
|
||||
}
|
||||
|
||||
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("delete asks for confirmation and only removes when confirmed", async () => {
|
||||
const { hook, removed } = setup();
|
||||
const confirm = vi.fn(() => false);
|
||||
vi.stubGlobal("confirm", confirm);
|
||||
|
||||
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
||||
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
|
||||
expect(removed).toEqual([]);
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
||||
await waitFor(() => expect(removed).toEqual([3]));
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions } from "@tanstack/react-query";
|
||||
|
||||
/** Which form is open: none, the create form, or the edit form for one row. */
|
||||
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
|
||||
|
||||
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
|
||||
|
||||
export interface CrudFormSpec<Entity extends { id: number }, Input> {
|
||||
create: MutationFactory<Input>;
|
||||
update: MutationFactory<{ id: number; input: Input }>;
|
||||
remove: MutationFactory<number>;
|
||||
/** The `window.confirm` text shown before a delete. */
|
||||
confirmDelete: (entity: Entity) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
|
||||
* three mutations, the open-form state, and the three handlers. The field JSX
|
||||
* and the table stay in the caller.
|
||||
*/
|
||||
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
|
||||
const queryClient = useQueryClient();
|
||||
const create = useMutation(spec.create(queryClient));
|
||||
const update = useMutation(spec.update(queryClient));
|
||||
const remove = useMutation(spec.remove(queryClient));
|
||||
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
|
||||
|
||||
function openForm(next: CrudFormState<Entity>) {
|
||||
create.reset();
|
||||
update.reset();
|
||||
setForm(next);
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setForm(null);
|
||||
}
|
||||
|
||||
function onSubmit(input: Input) {
|
||||
if (form === null) return;
|
||||
if (form.mode === "create") {
|
||||
create.mutate(input, { onSuccess: () => setForm(null) });
|
||||
} else {
|
||||
update.mutate({ id: form.entity.id, input }, { onSuccess: () => setForm(null) });
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(entity: Entity) {
|
||||
if (!window.confirm(spec.confirmDelete(entity))) return;
|
||||
remove.mutate(entity.id);
|
||||
}
|
||||
|
||||
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete };
|
||||
}
|
||||
Reference in New Issue
Block a user