# Frontend SPA — Milestone 2 (Object Authoring) — Design **Date:** 2026-06-04 **Status:** Approved (brainstorming) — ready for implementation planning. ## Context Milestone 1 (merged to `main` at `0a2398f`) delivered the SPA foundation: typed client, TanStack Query hooks, app shell, sv/en i18n, login/session guard, and a read-only two-pane Objects screen (paginated list + detail). Milestone 2 adds **authoring** — create, edit, and delete catalogue objects, including the **dynamic flexible-field form** driven by the field-definition registry. This is **pure frontend**: every endpoint already exists on the admin surface (`POST/PUT/DELETE /api/admin/objects`, `PUT /api/admin/objects/{id}/fields`, `GET /api/admin/field-definitions`, `GET /api/admin/vocabularies/{id}/terms`, `GET /api/admin/authorities?kind=`). Milestone roadmap (from M1): M2 authoring (this) → M3 publish workflow → M4 vocabulary/authority management → M5 search. ## Decisions (settled during brainstorming) - **Create/edit flow shapes:** **edit in-place** in the right pane (`/objects/:id/edit`, keeps the inspector + list context); **new** as a **full-width route** (`/objects/new`, room for the full field set). - **Reference fields (term/authority):** plain `` | string | | `integer` | `` | number | | `date` | `` | `YYYY-MM-DD` string | | `boolean` | `` | boolean | | `localized_text` | sv + en ``s | `{ sv?, en? }` (omit empty langs) | | `term` | `` of `useAuthorities(definition.authority_kind)` | authority id (string) | Option labels render in the active locale (fall back to English, then the raw key — same rule as M1's detail view). Fields render **grouped by `definition.group`** (a group heading per non-empty group; ungrouped fields under no heading), preserving the field-definitions API order within each group. `definition.required` drives a client-side required rule. ### Data flow - **Create** (`ObjectNewPage`): `useCreateObject` → `POST /objects` (core + visibility) → `{id}`; if any flexible values are set, `useSetFields(id, values)` → `PUT /objects/:id/fields`; invalidate `["objects"]`; navigate `/objects/:id`. - **Edit** (`ObjectEditForm`): `useUpdateObject(id)` → `PUT /objects/:id` (core); `useSetFields(id, values)` → `PUT /objects/:id/fields` (replace); invalidate `["object", id]` + `["objects"]`; navigate `/objects/:id`. - **Delete** (`DeleteObjectDialog`): `useDeleteObject(id)` → `DELETE /objects/:id`; invalidate `["objects"]`; navigate `/objects`. Flexible-field submission uses **replace semantics**: the form sends the complete desired field map. Cleared optional fields are omitted (removed); set fields are included with their current value. ### Error handling - **Client validation** (RHF): required fields present; integer is numeric; date is a valid `YYYY-MM-DD`. Blocks submit with inline messages. Prevents most server 422s. - **Server `422`** (bad date on create; `set_fields` unknown/type/unresolved — bare): surface a **form-level alert** ("The server rejected the changes — check the highlighted and referenced fields"). The bare `set_fields` 422 carries no per-field detail, so client validation is the primary guard. - **Partial create** (create `POST` succeeds, fields `PUT` fails): the core record now exists as Draft. Rather than lose it, navigate to `/objects/:id/edit` with an error banner so the user can retry the field values. (Documented behavior, tested.) - **Edit a since-deleted object** (`404`): show the not-found state. - Visibility is never sent on edit; `new` offers only Draft/Internal (never Public). ### Testing (Vitest + RTL + MSW) - `FieldInput`: renders the correct control for each `data_type`; term/authority selects populate options from MSW handlers (labels in active locale; value = id). - **New flow**: fill core + one flexible field → submit → asserts `POST /objects` then `PUT /objects/:id/fields` were called (MSW) → navigates to the detail route. - **Edit flow**: form pre-fills from the loaded object → change a field → save → `PUT /objects/:id` + `PUT .../fields` → returns to detail. - **Delete**: confirm dialog → `DELETE` → navigates to the list. - **Validation**: a required field left empty blocks submit and shows an error. - **Visibility**: the `new` form's visibility select offers only Draft and Internal. - **Partial create**: create OK but fields PUT 422 → lands on `/objects/:id/edit` with an error banner. - New MSW handlers: terms, authorities, `POST/PUT/DELETE /objects`, `PUT .../fields`. ## Acceptance criteria (Milestone 2 "done") 1. From the list, "New object" opens the full-width form; filling core + flexible fields and submitting creates the object (Draft/Internal) and lands on its detail view. 2. From a record's detail, "Edit" opens the in-pane form pre-filled with current core + flexible values; saving persists both and returns to the detail view. 3. All seven field types render and round-trip (incl. term/authority selects and sv/en localized text). 4. Required fields are enforced client-side; the `new` form cannot set Public. 5. "Delete" confirms, deletes, and returns to the list; the list no longer shows it. 6. Partial-create failure lands on the edit page with the core record preserved. 7. Web CI green (typecheck, lint, tests, build, bundle ≤150 KB gz). ## Out of scope / follow-ups - Searchable combobox + server-side term search for large vocabularies (later). - Publish/visibility transitions (M3). - Surfacing per-field server errors would require the backend `set_fields` 422 to carry field detail (currently bare — see the admin 422 bodies note in issue #16's follow-up). - `fields` map typing still relies on the `Record` cast pending issue #24 (open-map OpenAPI typing).