Files
biggus-dickus/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-2.md
2026-06-04 00:16:22 +02:00

1208 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>`) emits `import {cn} from "src/lib/utils"` — REWRITE to `@/lib/utils`; relocate any file written under a literal `web/@/` dir into `web/src/components/ui/`.
- i18n: every user-facing string via `t()`; keep `en.json`/`sv.json` key sets identical. Tests that assert English copy run under the default `en` locale (the i18n test resets the singleton).
- Codename "biggus"/"dickus" must appear NOWHERE.
- Each task ends green: `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` clean.
**Backend contract (verify against `web/src/api/schema.d.ts`):**
- `POST /api/admin/objects` body `ObjectCreateRequest``201 {id}`; rejects public/bad-date with 422.
- `PUT /api/admin/objects/{id}` body `ObjectUpdateRequest` (no visibility) → 204; 404 missing.
- `DELETE /api/admin/objects/{id}` → 204; 404 missing.
- `PUT /api/admin/objects/{id}/fields` body = 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**
```bash
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`
```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 `null`s. `pnpm typecheck` is the arbiter.)
- [ ] **Step 3: Extend default handlers** — append handlers in `web/src/test/handlers.ts`
```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`
```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`
```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**
```bash
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`
```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 (no `FieldInput`).
- [ ] **Step 3: Implement**`web/src/objects/field-input.tsx`
```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 ensure `getByText(label)` still finds options.)
- The form's field state lives under a `fields` object (`fields.<key>`), so RHF dotted paths work; `localized_text` nests `fields.<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 both `en.json` and `sv.json` so `t("form.selectPlaceholder")` resolves.
- [ ] **Step 4: Add the i18n key** — in `web/src/i18n/en.json` add `"form": { "selectPlaceholder": "— select —" }`; in `sv.json` add `"form": { "selectPlaceholder": "— välj —" }`. (More `form.*` keys are added in Task 3; keep the `form` namespace.)
- [ ] **Step 5: Run**`pnpm test src/objects/field-input.test.tsx` → PASS (5 tests). Then `pnpm test` (all), `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
- [ ] **Step 6: Commit**
```bash
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 `form` namespace of `web/src/i18n/en.json`
```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`:
```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`
```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`
```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_name` label uses `fieldsLabels.objectName` ("Name"); the test matches `/^name/i` against that label — confirm the M1 `fieldsLabels.objectName` value is "Name"/"Namn" (it is). The `/object number/i` matches `fieldsLabels.objectNumber`.
- `pruneFields` enforces replace-semantics: empty optional fields are omitted; `localized_text` objects drop empty langs and are omitted entirely if both empty.
- Number coercion via `valueAsNumber`; date stays a `YYYY-MM-DD` string from the native date input.
- Required flexible fields use the `definition.required` rule wired in `FieldInput` (Task 2); the error message renders here.
- [ ] **Step 4: Run**`pnpm test src/objects/object-form.test.tsx` → PASS (3 tests). Then full `pnpm test` / typecheck / lint / build clean.
- [ ] **Step 5: Commit**
```bash
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 `objects` namespace: `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`
```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`
```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`):
```tsx
<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:
```tsx
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). Full `pnpm test` / typecheck / lint / build clean. (Confirm `object-list.test.tsx` still green after adding the link.)
- [ ] **Step 6: Commit**
```bash
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 `ObjectsPage` to render an `<Outlet/>` right pane** — replace `web/src/objects/objects-page.tsx`
```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`:
```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`
```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`
```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`
```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:
```tsx
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. Full `pnpm test` (M1 detail + objects-page tests now run under nested routing — confirm they still pass; the `objects-page.test.tsx` from 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's `objects-page.test.tsx` rendered `ObjectsPage` under flat `/objects` + `/objects/:id` routes. With nesting, update that test to render the nested route tree (parent `ObjectsPage` with `:id` child) so the click-through still asserts detail in the right pane.
- [ ] **Step 7: Commit**
```bash
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 `actions` namespace (created in Task 5 with `edit`) by adding `delete` + `confirmDelete`. Final `actions` in `en.json`: `{ "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }`; in `sv.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`
```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`
```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**
```bash
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.tsx` shows the object, an `Edit` link to `/objects/:id/edit`, and the `DeleteObjectDialog`. Adjust layout so they sit together; keep the visibility badge.
- [ ] **Step 2: i18n parity check** — confirm `en.json` and `sv.json` have identical key sets (the new `form.*` and `actions.*` namespaces in both). Run a quick check:
```bash
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**
```bash
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**
```bash
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 23. ✓
- 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 16. ✓
- 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 45; 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 46.
## Notes for follow-on
- Searchable combobox + server-side term search for large vocabularies (deferred).
- Per-field server error mapping requires the backend `set_fields` 422 to carry field detail (currently bare).
- `fields` map 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.