# 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 + `` 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 ` 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 ( ); } export function FieldInput({ definition, form, }: { definition: FieldDefinitionView; form: UseFormReturn<{ fields: Record }>; }) { 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 (
); case "date": return (
); case "boolean": return (
( field.onChange(c === true)} /> )} />
); case "localized_text": return (
); case "term": return ( ); case "authority": return ( ); case "text": default: return (
); } } function TermField({ definition, form, label, lang, placeholder }: { definition: FieldDefinitionView; form: UseFormReturn<{ fields: Record }>; label: string; lang: string; placeholder: string; }) { const { data: terms } = useTerms(definition.vocabulary_id); return (
( )} />
); } function AuthorityField({ definition, form, label, lang, placeholder }: { definition: FieldDefinitionView; form: UseFormReturn<{ fields: Record }>; label: string; lang: string; placeholder: string; }) { const { data: authorities } = useAuthorities(definition.authority_kind); return (
( )} />
); } ``` NOTES: - Uses a **native ` {errors.core?.[key] &&

{t("form.required")}

} ); return (
{formError &&

{formError}

} {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" && (
)} {definitions && definitions.length > 0 && (
{t("form.flexibleHeading")} {definitions.map((def) => (
{errors.fields?.[def.key] && (

{t("form.required")}

)}
))}
)}
); } // Drop empty optional values so the replace-semantics field map only carries real values. function pruneFields(fields: Record): Record { const out: Record = {}; 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).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 ( } /> detail for {window.location.pathname}} /> edit page} /> ); } 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(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 (
navigate("/objects")} />
); } ``` (`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 } /> ``` (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: {t("objects.new")} ``` (Place it so the existing object-list tests still pass — e.g. a header row above the `
    `.) - [ ] **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 `` 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 (
    ); } ``` 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 (
    {t("objects.selectPrompt")}
    ); } ``` - [ ] **Step 2: Nest the routes** — in `web/src/app.tsx` ```tsx } /> }> } /> } /> } /> ``` (`ObjectDetail` and `ObjectEditForm` now render inside `ObjectsPage`'s ``; 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 ( } /> detail view} /> ); } 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( (location.state as { fieldsError?: boolean } | null)?.fieldsError ? t("form.rejected") : null, ); if (isLoading) return
    ; if (!object) return

    {t("objects.notFound")}

    ; 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 }; 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 ( 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: {t("actions.edit")} ``` - [ ] **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 ( } /> objects list
    } /> ); } 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 ( {t("actions.delete")} {t("actions.confirmDelete")} {t("form.cancel")} {t("actions.delete")} ); } ``` (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 `` 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 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.` 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_fields` 422 to carry field detail (currently bare). - `fields` map cast (`Record`) remains pending issue #24. - The native `