milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 5m6s
CI / frontend (push) Successful in 45s
CI / cross (push) Successful in 7m53s
CI / docker (push) Failing after 1h10m57s

This commit is contained in:
2026-08-07 18:20:30 +02:00
parent c50c6d285a
commit 6f67940995
82 changed files with 3167 additions and 3114 deletions
+32
View File
@@ -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";
+118
View File
@@ -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();
});
+54
View File
@@ -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 };
}