Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
53 KiB
Frontend SPA — Milestone 2 (Object Authoring) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Create / edit / delete catalogue objects from the SPA, including the dynamic flexible-field form (all field types) — consuming the existing admin endpoints.
Architecture: New full-width route /objects/new; in-pane edit at /objects/:id/edit via nested routes under ObjectsPage (list + <Outlet/> right pane). A shared ObjectForm (react-hook-form) renders inventory-minimum core fields plus dynamic flexible fields; FieldInput switches on data_type to the right control (term/authority are id-valued <Select>s populated from the vocab/authority endpoints). Create = POST /objects then PUT /objects/:id/fields; edit = PUT /objects/:id + PUT .../fields (replace semantics); delete via an AlertDialog.
Tech Stack: React 19, react-router-dom 7, @tanstack/react-query 5, react-hook-form, shadcn/ui (Base UI: select, checkbox, alert-dialog), openapi-fetch typed client, react-i18next, Vitest + RTL + MSW.
Reference spec: docs/superpowers/specs/2026-06-04-frontend-spa-milestone-2-design.md
Baseline (M1, merged): web/ has the typed client (web/src/api/client.ts api; schema web/src/api/schema.d.ts), hooks in web/src/api/queries.ts (useMe, useObjectsPage, useObject, useFieldDefinitions, useLogin, useLogout), renderApp helper (web/src/test/render.tsx), MSW harness (web/src/test/{server,handlers,fixtures}.ts, onUnhandledRequest:"error"), i18n (web/src/i18n/{en,sv}.json), shadcn Button/Input/Label/Card/Badge/Skeleton, and the Objects screen (web/src/objects/{objects-page,object-list,object-detail,visibility-badge}.tsx). 17 tests green. Run all web commands from web/.
Conventions:
- shadcn CLI (
pnpm dlx shadcn@latest add <x>) emitsimport {cn} from "src/lib/utils"— REWRITE to@/lib/utils; relocate any file written under a literalweb/@/dir intoweb/src/components/ui/. - i18n: every user-facing string via
t(); keepen.json/sv.jsonkey sets identical. Tests that assert English copy run under the defaultenlocale (the i18n test resets the singleton). - Codename "biggus"/"dickus" must appear NOWHERE.
- Each task ends green:
pnpm test,pnpm typecheck,pnpm lint,pnpm buildclean.
Backend contract (verify against web/src/api/schema.d.ts):
POST /api/admin/objectsbodyObjectCreateRequest→201 {id}; rejects public/bad-date with 422.PUT /api/admin/objects/{id}bodyObjectUpdateRequest(no visibility) → 204; 404 missing.DELETE /api/admin/objects/{id}→ 204; 404 missing.PUT /api/admin/objects/{id}/fieldsbody = JSON map (replace semantics) → 204; 404; 422 (bare).GET /api/admin/field-definitions→FieldDefinitionView[](key,data_type,vocabulary_id?,authority_kind?,required,group?,labels).GET /api/admin/vocabularies/{id}/terms→TermView[].GET /api/admin/authorities?kind=→AuthorityView[].data_type∈ {text, localized_text, integer, date, boolean, term, authority}.
Task 1: Authoring hooks (queries + mutations) + MSW handlers + shadcn primitives
Files:
-
Modify:
web/src/api/queries.ts,web/src/test/handlers.ts,web/src/test/fixtures.ts,web/package.json -
Create:
web/src/components/ui/{select,checkbox,alert-dialog}.tsx(via shadcn),web/src/api/queries.authoring.test.ts -
Step 1: Install react-hook-form + add shadcn primitives
cd web
pnpm add react-hook-form
pnpm dlx shadcn@latest add select checkbox alert-dialog
Fix any src/lib/utils imports in the three new components/ui/*.tsx files to @/lib/utils; relocate from web/@/ into web/src/components/ui/ if the CLI misplaced them. Confirm pnpm typecheck after.
- Step 2: Extend fixtures — append to
web/src/test/fixtures.ts
import type { components } from "../api/schema";
export type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
export type TermView = components["schemas"]["TermView"];
export type AuthorityView = components["schemas"]["AuthorityView"];
export const fieldDefinitions: FieldDefinitionView[] = [
{ key: "inscription", data_type: "text", vocabulary_id: null, authority_kind: null,
required: true, group: "Description", labels: [{ lang: "en", label: "Inscription" }, { lang: "sv", label: "Inskription" }] },
{ key: "count_seen", data_type: "integer", vocabulary_id: null, authority_kind: null,
required: false, group: null, labels: [{ lang: "en", label: "Count seen" }] },
{ key: "made_on", data_type: "date", vocabulary_id: null, authority_kind: null,
required: false, group: null, labels: [{ lang: "en", label: "Made on" }] },
{ key: "is_fragment", data_type: "boolean", vocabulary_id: null, authority_kind: null,
required: false, group: null, labels: [{ lang: "en", label: "Is fragment" }] },
{ key: "title_ml", data_type: "localized_text", vocabulary_id: null, authority_kind: null,
required: false, group: null, labels: [{ lang: "en", label: "Title" }] },
{ key: "material", data_type: "term", vocabulary_id: "v-material", authority_kind: null,
required: false, group: null, labels: [{ lang: "en", label: "Material" }] },
{ key: "maker", data_type: "authority", vocabulary_id: null, authority_kind: "person",
required: false, group: null, labels: [{ lang: "en", label: "Maker" }] },
];
export const materialTerms: TermView[] = [
{ id: "t-bronze", external_uri: null, labels: [{ lang: "en", label: "Bronze" }, { lang: "sv", label: "Brons" }] },
{ id: "t-wood", external_uri: null, labels: [{ lang: "en", label: "Wood" }] },
];
export const personAuthorities: AuthorityView[] = [
{ id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
];
(Adapt literals to the generated types — e.g. if optional fields are ?: rather than | null, drop the nulls. pnpm typecheck is the arbiter.)
- Step 3: Extend default handlers — append handlers in
web/src/test/handlers.ts
import { fieldDefinitions, materialTerms, personAuthorities } from "./fixtures";
// add to the `handlers` array:
http.get("/api/admin/field-definitions", () => HttpResponse.json(fieldDefinitions)),
http.get("/api/admin/vocabularies/:id/terms", () => HttpResponse.json(materialTerms)),
http.get("/api/admin/authorities", ({ request }) => {
const kind = new URL(request.url).searchParams.get("kind");
return HttpResponse.json(kind === "person" ? personAuthorities : []);
}),
http.post("/api/admin/objects", () =>
HttpResponse.json({ id: "11111111-1111-1111-1111-111111111111" }, { status: 201 })),
http.put("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
http.put("/api/admin/objects/:id/fields", () => new HttpResponse(null, { status: 204 })),
http.delete("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
(There is already a GET /api/admin/field-definitions handler from M1 returning a single "material" def — REPLACE it with the fieldDefinitions one above so the richer set is the default. Keep the existing GET objects / objects/:id handlers.)
- Step 4: Write the failing hook tests —
web/src/api/queries.authoring.test.ts
import { describe, expect, test } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import {
useTerms, useAuthorities, useCreateObject, useUpdateObject, useSetFields, useDeleteObject,
} from "./queries";
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("authoring hooks", () => {
test("useTerms loads a vocabulary's terms", async () => {
const { result } = renderHook(() => useTerms("v-material"), { wrapper });
await waitFor(() => expect(result.current.data).toBeDefined());
expect(result.current.data?.[0].id).toBe("t-bronze");
});
test("useAuthorities loads by kind", async () => {
const { result } = renderHook(() => useAuthorities("person"), { wrapper });
await waitFor(() => expect(result.current.data?.length).toBe(1));
expect(result.current.data?.[0].id).toBe("a-ada");
});
test("useCreateObject returns the new id", async () => {
const { result } = renderHook(() => useCreateObject(), { wrapper });
const created = await result.current.mutateAsync({
object_number: "A-1", object_name: "x", number_of_objects: 1, visibility: "draft",
});
expect(created.id).toBe("11111111-1111-1111-1111-111111111111");
});
test("useSetFields / useUpdateObject / useDeleteObject resolve", async () => {
const setFields = renderHook(() => useSetFields(), { wrapper });
await setFields.result.current.mutateAsync({ id: "o1", fields: { inscription: "hi" } });
const update = renderHook(() => useUpdateObject(), { wrapper });
await update.result.current.mutateAsync({ id: "o1", body: { object_number: "A-1", object_name: "x", number_of_objects: 1 } });
const del = renderHook(() => useDeleteObject(), { wrapper });
await del.result.current.mutateAsync("o1");
expect(true).toBe(true); // mutateAsync rejects on failure; reaching here = success
});
});
- Step 5: Run to verify it fails
Run: pnpm test src/api/queries.authoring.test.ts
Expected: FAIL — the new hooks aren't exported yet.
- Step 6: Implement the hooks — append to
web/src/api/queries.ts
type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"];
type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"];
export function useTerms(vocabularyId: string | null | undefined) {
return useQuery({
queryKey: ["terms", vocabularyId],
enabled: !!vocabularyId,
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", {
params: { path: { id: vocabularyId! } },
});
if (error || !data) throw new Error("failed to load terms");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useAuthorities(kind: string | null | undefined) {
return useQuery({
queryKey: ["authorities", kind],
enabled: !!kind,
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/authorities", {
params: { query: { kind: kind! } },
});
if (error || !data) throw new Error("failed to load authorities");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useCreateObject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: ObjectCreateRequest) => {
const { data, error } = await api.POST("/api/admin/objects", { body });
if (error || !data) throw new Error("create failed");
return data; // { id }
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
});
}
export function useUpdateObject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => {
const { response } = await api.PUT("/api/admin/objects/{id}", {
params: { path: { id } }, body,
});
if (response.status !== 204) throw new Error("update failed");
},
onSuccess: (_d, { id }) => {
void qc.invalidateQueries({ queryKey: ["objects"] });
void qc.invalidateQueries({ queryKey: ["object", id] });
},
});
}
export function useSetFields() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, fields }: { id: string; fields: Record<string, unknown> }) => {
const { response } = await api.PUT("/api/admin/objects/{id}/fields", {
params: { path: { id } },
body: fields as Record<string, never>, // schema types fields as Record<string,never> (issue #24)
});
if (response.status !== 204) throw new Error("set fields failed");
},
onSuccess: (_d, { id }) => {
void qc.invalidateQueries({ queryKey: ["object", id] });
},
});
}
export function useDeleteObject() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { response } = await api.DELETE("/api/admin/objects/{id}", {
params: { path: { id } },
});
if (response.status !== 204) throw new Error("delete failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
});
}
(Confirm api.PUT/api.DELETE exist on the openapi-fetch client and the body/params shapes match the installed version. The fields as Record<string,never> cast mirrors the read-side cast and is tracked by issue #24. If ObjectCreateRequest/ObjectUpdateRequest aren't the exact generated names, use the actual ones from schema.d.ts.)
- Step 7: Run / typecheck / lint / build
Run: pnpm test → all pass (M1's 17 + the new hook tests). pnpm typecheck, pnpm lint, pnpm build → clean.
NOTE: replacing the default field-definitions handler may affect the M1 object-detail.test.tsx (it overrode the handler itself, so it's unaffected) — confirm the full suite stays green; if an M1 test depended on the old single-def handler, update it minimally.
- Step 8: Commit
cd ..
git add web
git commit -m "feat(web): authoring query/mutation hooks + MSW handlers + shadcn select/checkbox/alert-dialog"
Task 2: FieldInput — dynamic control per data_type
Files:
-
Create:
web/src/objects/field-input.tsx,web/src/objects/field-input.test.tsx -
Step 1: Write the failing test
web/src/objects/field-input.test.tsx
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { useForm } from "react-hook-form";
import { renderApp } from "../test/render";
import { FieldInput } from "./field-input";
import { fieldDefinitions } from "../test/fixtures";
function Harness({ defKey }: { defKey: string }) {
const def = fieldDefinitions.find((d) => d.key === defKey)!;
const form = useForm({ defaultValues: { fields: {} as Record<string, unknown> } });
return <FieldInput definition={def} form={form} />;
}
test("text field renders a text input labelled in the active locale", async () => {
renderApp(<Harness defKey="inscription" />);
expect(await screen.findByLabelText("Inscription")).toBeInTheDocument();
});
test("boolean field renders a checkbox", async () => {
renderApp(<Harness defKey="is_fragment" />);
expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument();
});
test("localized_text renders sv and en inputs", async () => {
renderApp(<Harness defKey="title_ml" />);
expect(await screen.findByLabelText(/title.*\(en\)/i)).toBeInTheDocument();
expect(screen.getByLabelText(/title.*\(sv\)/i)).toBeInTheDocument();
});
test("term field renders a select populated from the vocabulary", async () => {
renderApp(<Harness defKey="material" />);
// option label from the term, in the active (en) locale
expect(await screen.findByText("Bronze")).toBeInTheDocument();
});
test("authority field renders a select populated by kind", async () => {
renderApp(<Harness defKey="maker" />);
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
});
-
Step 2: Run to verify it fails —
pnpm test src/objects/field-input.test.tsx→ FAIL (noFieldInput). -
Step 3: Implement —
web/src/objects/field-input.tsx
import { Controller, type UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useTerms, useAuthorities } from "../api/queries";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
type LabelView = components["schemas"]["LabelView"];
function labelIn(labels: LabelView[], lang: string): string {
return (
labels.find((l) => l.lang === lang)?.label ??
labels.find((l) => l.lang === "en")?.label ??
labels[0]?.label ??
""
);
}
// A native <select> keeps the bundle lean and is fully accessible; the shadcn Select
// can replace it later without changing the value contract (option value = id).
function OptionsSelect({
id, value, onChange, options, lang, placeholder,
}: {
id: string; value: string; onChange: (v: string) => void;
options: { id: string; labels: LabelView[] }[]; lang: string; placeholder: string;
}) {
return (
<select id={id} className="w-full rounded border px-2 py-1 text-sm"
value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.id} value={o.id}>{labelIn(o.labels, lang)}</option>
))}
</select>
);
}
export function FieldInput({
definition, form,
}: {
definition: FieldDefinitionView;
form: UseFormReturn<{ fields: Record<string, unknown> }>;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const label = labelIn(definition.labels, lang);
const name = `fields.${definition.key}` as const;
const placeholder = t("form.selectPlaceholder");
switch (definition.data_type) {
case "integer":
return (
<div className="space-y-1">
<Label htmlFor={definition.key}>{label}</Label>
<Input id={definition.key} type="number"
{...form.register(name, { valueAsNumber: true, required: definition.required })} />
</div>
);
case "date":
return (
<div className="space-y-1">
<Label htmlFor={definition.key}>{label}</Label>
<Input id={definition.key} type="date"
{...form.register(name, { required: definition.required })} />
</div>
);
case "boolean":
return (
<div className="flex items-center gap-2">
<Controller control={form.control} name={name}
render={({ field }) => (
<Checkbox id={definition.key} checked={!!field.value}
onCheckedChange={(c) => field.onChange(c === true)} />
)} />
<Label htmlFor={definition.key}>{label}</Label>
</div>
);
case "localized_text":
return (
<div className="space-y-1">
<Label>{label}</Label>
<Label htmlFor={`${definition.key}-en`} className="text-xs text-neutral-500">{label} (EN)</Label>
<Input id={`${definition.key}-en`} {...form.register(`fields.${definition.key}.en` as const)} />
<Label htmlFor={`${definition.key}-sv`} className="text-xs text-neutral-500">{label} (SV)</Label>
<Input id={`${definition.key}-sv`} {...form.register(`fields.${definition.key}.sv` as const)} />
</div>
);
case "term":
return (
<TermField definition={definition} form={form} label={label} lang={lang} placeholder={placeholder} />
);
case "authority":
return (
<AuthorityField definition={definition} form={form} label={label} lang={lang} placeholder={placeholder} />
);
case "text":
default:
return (
<div className="space-y-1">
<Label htmlFor={definition.key}>{label}</Label>
<Input id={definition.key}
{...form.register(name, { required: definition.required })} />
</div>
);
}
}
function TermField({ definition, form, label, lang, placeholder }: {
definition: FieldDefinitionView; form: UseFormReturn<{ fields: Record<string, unknown> }>;
label: string; lang: string; placeholder: string;
}) {
const { data: terms } = useTerms(definition.vocabulary_id);
return (
<div className="space-y-1">
<Label htmlFor={definition.key}>{label}</Label>
<Controller control={form.control} name={`fields.${definition.key}` as const}
rules={{ required: definition.required }}
render={({ field }) => (
<OptionsSelect id={definition.key} value={(field.value as string) ?? ""}
onChange={field.onChange} options={terms ?? []} lang={lang} placeholder={placeholder} />
)} />
</div>
);
}
function AuthorityField({ definition, form, label, lang, placeholder }: {
definition: FieldDefinitionView; form: UseFormReturn<{ fields: Record<string, unknown> }>;
label: string; lang: string; placeholder: string;
}) {
const { data: authorities } = useAuthorities(definition.authority_kind);
return (
<div className="space-y-1">
<Label htmlFor={definition.key}>{label}</Label>
<Controller control={form.control} name={`fields.${definition.key}` as const}
rules={{ required: definition.required }}
render={({ field }) => (
<OptionsSelect id={definition.key} value={(field.value as string) ?? ""}
onChange={field.onChange} options={authorities ?? []} lang={lang} placeholder={placeholder} />
)} />
</div>
);
}
NOTES:
-
Uses a native
<select>for term/authority (lean, accessible) rather than the shadcn Select; the value contract (option value = id) is unchanged if you later swap in shadcn Select. (If you prefer the shadcn Select that Task 1 added, use it — but keep the id-as-value contract and ensuregetByText(label)still finds options.) -
The form's field state lives under a
fieldsobject (fields.<key>), so RHF dotted paths work;localized_textnestsfields.<key>.en/.sv. -
Add i18n key
form.selectPlaceholder(= "— select —" / "— välj —") in Task 3's i18n step (or here). For this task's test, add it now to bothen.jsonandsv.jsonsot("form.selectPlaceholder")resolves. -
Step 4: Add the i18n key — in
web/src/i18n/en.jsonadd"form": { "selectPlaceholder": "— select —" }; insv.jsonadd"form": { "selectPlaceholder": "— välj —" }. (Moreform.*keys are added in Task 3; keep theformnamespace.) -
Step 5: Run —
pnpm test src/objects/field-input.test.tsx→ PASS (5 tests). Thenpnpm test(all),pnpm typecheck,pnpm lint,pnpm build→ clean. -
Step 6: Commit
cd ..
git add web
git commit -m "feat(web): dynamic FieldInput (text/integer/date/boolean/localized_text/term/authority)"
Task 3: ObjectForm — shared core + flexible form
Files:
-
Create:
web/src/objects/object-form.tsx,web/src/objects/object-form.test.tsx -
Modify:
web/src/i18n/{en,sv}.json -
Step 1: Add i18n form keys — merge into the
formnamespace ofweb/src/i18n/en.json
"form": {
"selectPlaceholder": "— select —",
"create": "Create object", "save": "Save", "cancel": "Cancel",
"visibility": "Visibility", "draft": "Draft", "internal": "Internal",
"required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields",
"flexibleHeading": "Catalogue fields"
}
and sv.json:
"form": {
"selectPlaceholder": "— välj —",
"create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt",
"visibility": "Synlighet", "draft": "Utkast", "internal": "Intern",
"required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält",
"flexibleHeading": "Katalogfält"
}
(Reuse the existing fieldsLabels.* keys from M1 for the core field labels.)
- Step 2: Write the failing test
web/src/objects/object-form.test.tsx
import { expect, test, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { ObjectForm } from "./object-form";
test("create mode: shows visibility (draft/internal only) and submits assembled values", async () => {
const onSubmit = vi.fn();
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
const visibility = screen.getByLabelText(/visibility/i) as HTMLSelectElement;
expect([...visibility.options].map((o) => o.value)).toEqual(
expect.arrayContaining(["draft", "internal"]),
);
expect([...visibility.options].map((o) => o.value)).not.toContain("public");
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
const values = onSubmit.mock.calls[0][0];
expect(values.core.object_number).toBe("A-9");
expect(values.visibility).toBe("draft");
expect(values.fields.inscription).toBe("To the gods");
});
test("required core + required flexible field block submit", async () => {
const onSubmit = vi.fn();
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
await userEvent.click(await screen.findByRole("button", { name: /create object/i }));
// object_number, object_name, and required flexible "inscription" missing
await waitFor(() => expect(screen.getAllByText(/required/i).length).toBeGreaterThan(0));
expect(onSubmit).not.toHaveBeenCalled();
});
test("edit mode: no visibility control, save button, prefilled values", async () => {
const onSubmit = vi.fn();
renderApp(
<ObjectForm mode="edit" onSubmit={onSubmit} onCancel={() => {}}
defaults={{
core: { object_number: "A-1", object_name: "Amphora", number_of_objects: 1,
brief_description: null, current_location: "Vault 3", current_owner: null,
recorder: null, recording_date: null },
fields: { inscription: "hi" },
}} />,
);
expect(await screen.findByDisplayValue("Amphora")).toBeInTheDocument();
expect(screen.queryByLabelText(/visibility/i)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
});
- Step 3: Implement —
web/src/objects/object-form.tsx
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useFieldDefinitions } from "../api/queries";
import { FieldInput } from "./field-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type ObjectCore = {
object_number: string;
object_name: string;
number_of_objects: number;
brief_description: string | null;
current_location: string | null;
current_owner: string | null;
recorder: string | null;
recording_date: string | null;
};
export type ObjectFormValues = {
core: ObjectCore;
visibility?: "draft" | "internal";
fields: Record<string, unknown>;
};
type FormShape = { core: ObjectCore; visibility: "draft" | "internal"; fields: Record<string, unknown> };
const EMPTY_CORE: ObjectCore = {
object_number: "", object_name: "", number_of_objects: 1,
brief_description: null, current_location: null, current_owner: null,
recorder: null, recording_date: null,
};
export function ObjectForm({
mode, defaults, onSubmit, onCancel, formError,
}: {
mode: "create" | "edit";
defaults?: { core: ObjectCore; fields: Record<string, unknown> };
onSubmit: (values: ObjectFormValues) => void;
onCancel: () => void;
formError?: string | null;
}) {
const { t } = useTranslation();
const { data: definitions } = useFieldDefinitions();
const form = useForm<FormShape>({
defaultValues: {
core: defaults?.core ?? EMPTY_CORE,
visibility: "draft",
fields: defaults?.fields ?? {},
},
});
const { register, handleSubmit, formState: { errors } } = form;
const submit = handleSubmit((data) => {
const fields = pruneFields(data.fields);
onSubmit(
mode === "create"
? { core: data.core, visibility: data.visibility, fields }
: { core: data.core, fields },
);
});
const coreField = (key: keyof ObjectCore, labelKey: string, opts?: { type?: string; required?: boolean }) => (
<div className="space-y-1">
<Label htmlFor={key}>{t(`fieldsLabels.${labelKey}`)}</Label>
<Input id={key} type={opts?.type ?? "text"}
{...register(`core.${key}` as const,
opts?.type === "number"
? { valueAsNumber: true, required: opts?.required }
: { required: opts?.required })} />
{errors.core?.[key] && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
</div>
);
return (
<form onSubmit={submit} className="space-y-4 overflow-auto p-4">
{formError && <p role="alert" className="text-sm text-red-600">{formError}</p>}
{coreField("object_number", "objectNumber", { required: true })}
{coreField("object_name", "objectName", { required: true })}
{coreField("number_of_objects", "count", { type: "number", required: true })}
{coreField("brief_description", "briefDescription")}
{coreField("current_location", "currentLocation")}
{coreField("current_owner", "currentOwner")}
{coreField("recorder", "recorder")}
{coreField("recording_date", "recordingDate", { type: "date" })}
{mode === "create" && (
<div className="space-y-1">
<Label htmlFor="visibility">{t("form.visibility")}</Label>
<select id="visibility" className="w-full rounded border px-2 py-1 text-sm"
{...register("visibility")}>
<option value="draft">{t("form.draft")}</option>
<option value="internal">{t("form.internal")}</option>
</select>
</div>
)}
{definitions && definitions.length > 0 && (
<fieldset className="space-y-3 border-t pt-3">
<legend className="text-xs font-medium uppercase text-neutral-500">{t("form.flexibleHeading")}</legend>
{definitions.map((def) => (
<div key={def.key}>
<FieldInput definition={def} form={form} />
{errors.fields?.[def.key] && (
<p role="alert" className="text-xs text-red-600">{t("form.required")}</p>
)}
</div>
))}
</fieldset>
)}
<div className="flex gap-2 pt-2">
<Button type="submit">{mode === "create" ? t("form.create") : t("form.save")}</Button>
<Button type="button" variant="ghost" onClick={onCancel}>{t("form.cancel")}</Button>
</div>
</form>
);
}
// Drop empty optional values so the replace-semantics field map only carries real values.
function pruneFields(fields: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fields)) {
if (value === undefined || value === null || value === "") continue;
if (typeof value === "object" && !Array.isArray(value)) {
const inner = Object.fromEntries(
Object.entries(value as Record<string, unknown>).filter(([, v]) => v !== undefined && v !== null && v !== ""),
);
if (Object.keys(inner).length > 0) out[key] = inner;
continue;
}
out[key] = value;
}
return out;
}
NOTES:
-
The
core.object_namelabel usesfieldsLabels.objectName("Name"); the test matches/^name/iagainst that label — confirm the M1fieldsLabels.objectNamevalue is "Name"/"Namn" (it is). The/object number/imatchesfieldsLabels.objectNumber. -
pruneFieldsenforces replace-semantics: empty optional fields are omitted;localized_textobjects drop empty langs and are omitted entirely if both empty. -
Number coercion via
valueAsNumber; date stays aYYYY-MM-DDstring from the native date input. -
Required flexible fields use the
definition.requiredrule wired inFieldInput(Task 2); the error message renders here. -
Step 4: Run —
pnpm test src/objects/object-form.test.tsx→ PASS (3 tests). Then fullpnpm test/ typecheck / lint / build clean. -
Step 5: Commit
cd ..
git add web
git commit -m "feat(web): ObjectForm (core + dynamic flexible fields, RHF, validation)"
Task 4: New-object page + create flow + /objects/new route
Files:
-
Create:
web/src/objects/object-new-page.tsx,web/src/objects/object-new-page.test.tsx -
Modify:
web/src/app.tsx,web/src/objects/object-list.tsx,web/src/i18n/{en,sv}.json -
Step 1: i18n — add to the
objectsnamespace:en.json"new": "New object";sv.json"new": "Nytt föremål". -
Step 2: Write the failing test
web/src/objects/object-new-page.test.tsx
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Routes, Route } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { ObjectNewPage } from "./object-new-page";
function tree() {
return (
<Routes>
<Route path="/objects/new" element={<ObjectNewPage />} />
<Route path="/objects/:id" element={<div>detail for {window.location.pathname}</div>} />
<Route path="/objects/:id/edit" element={<div>edit page</div>} />
</Routes>
);
}
test("create: POST then PUT fields, then navigate to the new object's detail", async () => {
let postBody: unknown; let fieldsBody: unknown;
server.use(
http.post("/api/admin/objects", async ({ request }) => {
postBody = await request.json();
return HttpResponse.json({ id: "new-id-1" }, { status: 201 });
}),
http.put("/api/admin/objects/:id/fields", async ({ request }) => {
fieldsBody = await request.json();
return new HttpResponse(null, { status: 204 });
}),
);
renderApp(tree(), { route: "/objects/new" });
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
await waitFor(() => expect(screen.getByText(/detail for/i)).toBeInTheDocument());
expect((postBody as { object_number: string }).object_number).toBe("A-9");
expect((fieldsBody as { inscription: string }).inscription).toBe("To the gods");
});
test("partial create: fields PUT fails -> navigate to edit with an error banner", async () => {
server.use(
http.post("/api/admin/objects", () => HttpResponse.json({ id: "new-id-2" }, { status: 201 })),
http.put("/api/admin/objects/:id/fields", () => new HttpResponse(null, { status: 422 })),
);
renderApp(tree(), { route: "/objects/new" });
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
await userEvent.type(screen.getByLabelText(/inscription/i), "x");
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
await waitFor(() => expect(screen.getByText(/edit page/i)).toBeInTheDocument());
});
- Step 3: Implement —
web/src/objects/object-new-page.tsx
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ObjectForm, type ObjectFormValues } from "./object-form";
import { useCreateObject, useSetFields } from "../api/queries";
export function ObjectNewPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const create = useCreateObject();
const setFields = useSetFields();
const [error, setError] = useState<string | null>(null);
const onSubmit = async (values: ObjectFormValues) => {
setError(null);
let id: string;
try {
const created = await create.mutateAsync({
...values.core,
visibility: values.visibility ?? "draft",
});
id = created.id;
} catch {
setError(t("form.rejected"));
return;
}
if (Object.keys(values.fields).length > 0) {
try {
await setFields.mutateAsync({ id, fields: values.fields });
} catch {
// core record is saved; recover on the edit page so fields can be retried
navigate(`/objects/${id}/edit`, { state: { fieldsError: true } });
return;
}
}
navigate(`/objects/${id}`);
};
return (
<div className="mx-auto max-w-2xl">
<ObjectForm mode="create" formError={error}
onSubmit={onSubmit} onCancel={() => navigate("/objects")} />
</div>
);
}
(create.mutateAsync body spreads values.core + visibility to match ObjectCreateRequest. Confirm ObjectCreateRequest accepts exactly these keys; if it requires non-null strings vs nulls for optionals, adapt the core nulls to undefined/omit. The edit page reads location.state.fieldsError in Task 5 to show the banner.)
- Step 4: Wire the route + list button
In web/src/app.tsx, add (as a sibling of /objects, inside the protected AppShell group, BEFORE/independent of the /objects route — React Router ranks static new above dynamic :id):
<Route path="/objects/new" element={<ObjectNewPage />} />
(Import ObjectNewPage. Keep the existing /objects + /objects/:id routes for now; Task 5 converts them to nested children.)
In web/src/objects/object-list.tsx, add a "New object" link near the list header/footer:
import { Link } from "react-router-dom";
// in the footer or a small header row:
<Link to="/objects/new" className="text-sm font-medium text-indigo-600">{t("objects.new")}</Link>
(Place it so the existing object-list tests still pass — e.g. a header row above the <ul>.)
-
Step 5: Run —
pnpm test src/objects/object-new-page.test.tsx→ PASS (2 tests). Fullpnpm test/ typecheck / lint / build clean. (Confirmobject-list.test.tsxstill green after adding the link.) -
Step 6: Commit
cd ..
git add web
git commit -m "feat(web): new-object full-width page + create flow + /objects/new"
Task 5: Nested routes + in-pane edit form + edit flow
Files:
-
Create:
web/src/objects/object-edit-form.tsx,web/src/objects/object-edit-form.test.tsx -
Modify:
web/src/objects/objects-page.tsx,web/src/app.tsx,web/src/objects/object-detail.tsx -
Step 1: Convert
ObjectsPageto render an<Outlet/>right pane — replaceweb/src/objects/objects-page.tsx
import { Outlet } from "react-router-dom";
import { ObjectList } from "./object-list";
export function ObjectsPage() {
return (
<div className="grid h-full grid-cols-[20rem_1fr]">
<div className="overflow-hidden border-r">
<ObjectList />
</div>
<div className="overflow-hidden">
<Outlet />
</div>
</div>
);
}
Create the index placeholder inline or as a tiny component web/src/objects/select-prompt.tsx:
import { useTranslation } from "react-i18next";
export function SelectPrompt() {
const { t } = useTranslation();
return (
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
{t("objects.selectPrompt")}
</div>
);
}
- Step 2: Nest the routes — in
web/src/app.tsx
<Route path="/objects/new" element={<ObjectNewPage />} />
<Route path="/objects" element={<ObjectsPage />}>
<Route index element={<SelectPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
<Route path=":id/edit" element={<ObjectEditForm />} />
</Route>
(ObjectDetail and ObjectEditForm now render inside ObjectsPage's <Outlet/>; both read :id via useParams. Import SelectPrompt, ObjectEditForm.)
- Step 3: Write the failing edit test
web/src/objects/object-edit-form.test.tsx
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Routes, Route } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { ObjectEditForm } from "./object-edit-form";
import { amphora } from "../test/fixtures";
function tree() {
return (
<Routes>
<Route path="/objects/:id/edit" element={<ObjectEditForm />} />
<Route path="/objects/:id" element={<div>detail view</div>} />
</Routes>
);
}
test("edit: prefilled, save -> PUT core + PUT fields -> back to detail", async () => {
let putCore: unknown; let putFields: unknown;
server.use(
http.get("/api/admin/objects/:id", () =>
HttpResponse.json({ ...amphora, fields: { inscription: "old" } })),
http.put("/api/admin/objects/:id", async ({ request }) => {
putCore = await request.json();
return new HttpResponse(null, { status: 204 });
}),
http.put("/api/admin/objects/:id/fields", async ({ request }) => {
putFields = await request.json();
return new HttpResponse(null, { status: 204 });
}),
);
renderApp(tree(), { route: `/objects/${amphora.id}/edit` });
const name = await screen.findByDisplayValue("Amphora");
await userEvent.clear(name);
await userEvent.type(name, "Big amphora");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => expect(screen.getByText("detail view")).toBeInTheDocument());
expect((putCore as { object_name: string }).object_name).toBe("Big amphora");
expect((putFields as { inscription: string }).inscription).toBe("old");
});
(The default amphora fixture has fields: {} due to the schema typing; the handler override returns fields: { inscription: "old" } as a plain object — same approach as M1's detail test.)
- Step 4: Implement —
web/src/objects/object-edit-form.tsx
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ObjectForm, type ObjectFormValues, type ObjectCore } from "./object-form";
import { useObject, useUpdateObject, useSetFields } from "../api/queries";
export function ObjectEditForm() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { data: object, isLoading } = useObject(id!);
const update = useUpdateObject();
const setFields = useSetFields();
const [error, setError] = useState<string | null>(
(location.state as { fieldsError?: boolean } | null)?.fieldsError ? t("form.rejected") : null,
);
if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;
if (!object) return <p className="p-4 text-sm text-neutral-500">{t("objects.notFound")}</p>;
const core: ObjectCore = {
object_number: object.object_number,
object_name: object.object_name,
number_of_objects: object.number_of_objects,
brief_description: object.brief_description ?? null,
current_location: object.current_location ?? null,
current_owner: object.current_owner ?? null,
recorder: object.recorder ?? null,
recording_date: object.recording_date ?? null,
};
const defaults = { core, fields: object.fields as Record<string, unknown> };
const onSubmit = async (values: ObjectFormValues) => {
setError(null);
try {
await update.mutateAsync({ id: id!, body: values.core });
await setFields.mutateAsync({ id: id!, fields: values.fields });
} catch {
setError(t("form.rejected"));
return;
}
navigate(`/objects/${id}`);
};
return (
<ObjectForm mode="edit" defaults={defaults} formError={error}
onSubmit={onSubmit} onCancel={() => navigate(`/objects/${id}`)} />
);
}
- Step 5: Add the Edit action to detail — first add the i18n key, then the link.
In web/src/i18n/en.json add an actions namespace "actions": { "edit": "Edit" }; in sv.json "actions": { "edit": "Redigera" }. (Task 6 extends this actions namespace with delete/confirmDelete.)
In web/src/objects/object-detail.tsx, add (in the header row next to the badge) a link to edit:
import { Link } from "react-router-dom";
// in the header row next to the badge:
<Link to={`/objects/${object.id}/edit`} className="text-sm font-medium text-indigo-600">{t("actions.edit")}</Link>
-
Step 6: Run —
pnpm test src/objects/object-edit-form.test.tsx→ PASS. Fullpnpm test(M1 detail + objects-page tests now run under nested routing — confirm they still pass; theobjects-page.test.tsxfrom M1 asserted clicking a row shows detail, which still holds via nested routes — update that test's route table to the nested shape if needed), typecheck, lint, build clean. NOTE: M1'sobjects-page.test.tsxrenderedObjectsPageunder flat/objects+/objects/:idroutes. With nesting, update that test to render the nested route tree (parentObjectsPagewith:idchild) so the click-through still asserts detail in the right pane. -
Step 7: Commit
cd ..
git add web
git commit -m "feat(web): nested object routes + in-pane edit form + edit flow"
Task 6: Delete (confirm dialog + flow)
Files:
-
Create:
web/src/objects/delete-object-dialog.tsx,web/src/objects/delete-object-dialog.test.tsx -
Modify:
web/src/objects/object-detail.tsx,web/src/i18n/{en,sv}.json -
Step 1: i18n — EXTEND the existing
actionsnamespace (created in Task 5 withedit) by addingdelete+confirmDelete. Finalactionsinen.json:{ "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }; insv.json:{ "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }. -
Step 2: Write the failing test
web/src/objects/delete-object-dialog.test.tsx
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Routes, Route } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { DeleteObjectDialog } from "./delete-object-dialog";
function tree() {
return (
<Routes>
<Route path="/objects/:id" element={<DeleteObjectDialog id="o-1" />} />
<Route path="/objects" element={<div>objects list</div>} />
</Routes>
);
}
test("confirm delete: DELETE then navigate to the list", async () => {
let deleted = false;
server.use(http.delete("/api/admin/objects/:id", () => { deleted = true; return new HttpResponse(null, { status: 204 }); }));
renderApp(tree(), { route: "/objects/o-1" });
await userEvent.click(await screen.findByRole("button", { name: /delete/i }));
// confirm in the dialog
await userEvent.click(await screen.findByRole("button", { name: /^delete$/i }));
await waitFor(() => expect(screen.getByText("objects list")).toBeInTheDocument());
expect(deleted).toBe(true);
});
(If the trigger and confirm buttons collide on the /delete/i name, give the trigger an explicit accessible name like the icon-button aria-label and match the confirm with an exact ^Delete$ — adapt the queries so the test unambiguously clicks trigger then confirm.)
- Step 3: Implement —
web/src/objects/delete-object-dialog.tsx
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useDeleteObject } from "../api/queries";
import {
AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
export function DeleteObjectDialog({ id }: { id: string }) {
const { t } = useTranslation();
const navigate = useNavigate();
const del = useDeleteObject();
const onConfirm = async () => {
await del.mutateAsync(id);
navigate("/objects");
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-red-600">{t("actions.delete")}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
<AlertDialogDescription>{t("actions.confirmDelete")}</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{t("actions.delete")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
(Adapt the import names to the shadcn alert-dialog the CLI generated — Base UI registry may name parts slightly differently. The contract: a trigger button labelled Delete, a confirm action that calls useDeleteObject().mutateAsync(id) then navigates to /objects. If trigger/confirm both match /delete/i, set the trigger's text via actions.delete and the dialog action via the same key — disambiguate in the test by dialog role/scoping, or give the trigger an aria-label.)
-
Step 4: Add Delete to detail — in
web/src/objects/object-detail.tsx, render<DeleteObjectDialog id={object.id} />in the header actions next to Edit. -
Step 5: Run —
pnpm test src/objects/delete-object-dialog.test.tsx→ PASS. Full suite / typecheck / lint / build clean. -
Step 6: Commit
cd ..
git add web
git commit -m "feat(web): delete object with confirm dialog"
Task 7: Final wiring + full verification
Files:
-
Modify: any remaining wiring;
web/src/objects/object-detail.tsx(confirm Edit + Delete actions present) -
Step 1: Confirm the detail header has both actions —
object-detail.tsxshows the object, anEditlink to/objects/:id/edit, and theDeleteObjectDialog. Adjust layout so they sit together; keep the visibility badge. -
Step 2: i18n parity check — confirm
en.jsonandsv.jsonhave identical key sets (the newform.*andactions.*namespaces in both). Run a quick check:
cd web
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))"
Expected: PARITY OK. Fix any mismatch.
- Step 3: Full verification
cd web
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
Expected: typecheck/lint clean; all tests pass; build clean; bundle ≤150 KB gz (report the number — adding RHF + shadcn select/checkbox/alert-dialog should stay under; if it exceeds, lazy-load the new-object page and edit form via React.lazy in app.tsx).
-
Step 4: Manual smoke (optional, recommended) — with the backend running + a Spectrum-seeded field set (or a manually created field definition) + an admin user:
pnpm dev, create an object with a flexible field, edit it, delete it; switch sv/en. -
Step 5: Commit
cd ..
git add web
git commit -m "feat(web): wire object authoring actions into detail; i18n parity"
Self-Review (completed)
Spec coverage:
- New (full-width
/objects/new) + create flow → Task 4. ✓ - Edit (in-pane
/objects/:id/edit) + nested routes + edit flow → Task 5. ✓ - Delete + confirm dialog → Task 6. ✓
- Dynamic flexible-field form, all 7 types (incl. term/authority Selects, sv/en localized_text) → Tasks 2–3. ✓
- Client validation (required, integer, date) + form-level 422 + partial-create recovery → Tasks 3, 4, 5. ✓
- Mutations + option hooks + invalidation → Task 1. ✓
- Visibility only Draft/Internal on create, never on edit → Task 3 (test asserts). ✓
- i18n sv/en parity → Task 7 (+ keys added per task). ✓
- Testing Vitest+RTL+MSW → Tasks 1–6. ✓
- Bundle budget → Task 7 (check:size). ✓
Placeholder scan: none — every code step shows complete code; the "adapt to the generated/shadcn API" notes are verification instructions with concrete contracts, not deferred work.
Type consistency: ObjectFormValues/ObjectCore defined in Task 3 and consumed in Tasks 4–5; hooks useTerms/useAuthorities/useCreateObject/useUpdateObject/useSetFields/useDeleteObject defined in Task 1 and used in 2/4/5/6; FieldInput (Task 2) consumed by ObjectForm (Task 3); the fields.<key> RHF path shape is consistent across FieldInput and ObjectForm; route shapes (/objects/new, /objects/:id, /objects/:id/edit) consistent across Tasks 4–6.
Notes for follow-on
- Searchable combobox + server-side term search for large vocabularies (deferred).
- Per-field server error mapping requires the backend
set_fields422 to carry field detail (currently bare). fieldsmap cast (Record<string, unknown>) remains pending issue #24.- The native
<select>for term/authority can be swapped to shadcn Select without changing the id-as-value contract.