docs(spec): frontend SPA milestone 2 (object authoring) design

Create (full-width /objects/new) + edit (in-pane /objects/:id/edit) +
delete; dynamic flexible-field form (all 7 field types incl. term/authority
Selects + sv/en localized text) via react-hook-form; replace-semantics
field save; client validation + partial-create recovery; Vitest+RTL+MSW.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 00:09:15 +02:00
parent 0a2398f507
commit 9f43793c4a
@@ -0,0 +1,197 @@
# 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 `<Select>` populated from the relevant
endpoint (all options client-side). Acceptable while vocabularies are small; a
searchable combobox (+ a future server-side term-search) is a later refinement.
- **All field types in scope:** text, integer, date, boolean, localized_text (sv+en),
term, authority.
- **Form library:** **react-hook-form**, used directly with the existing shadcn
Input/Label/Select (Controller for Select/Checkbox) — no shadcn Form wrapper (leaner).
- Validation client-side; query invalidation after writes; bundle stays within the
150 KB gz budget (current ≈120 KB; RHF ≈9 KB gz).
## Scope (YAGNI)
**In:** New (full-width), Edit (in-pane), Delete (confirm dialog); the dynamic
flexible-field form covering all field types; client-side validation incl. required
fields; create/edit/delete + set-fields mutations with cache invalidation.
**Out:** visibility/publish transitions (M3 — `new` offers only Draft/Internal, edit
never changes visibility); vocabulary/authority management UI (M4); search (M5); media;
searchable combobox; bulk/import.
## Backend contract (already shipped — verify against `web/src/api/schema.d.ts`)
- `POST /api/admin/objects` body `ObjectCreateRequest` (object_number, object_name,
number_of_objects, optional brief_description/current_location/current_owner/recorder,
recording_date `YYYY-MM-DD`, visibility) → `201 CreatedObject {id}`. Rejects
`visibility=public` and bad dates with `422`.
- `PUT /api/admin/objects/{id}` body `ObjectUpdateRequest` (same minimum fields, **no
visibility**) → `204`; `404` if missing.
- `DELETE /api/admin/objects/{id}``204`; `404` if missing.
- `PUT /api/admin/objects/{id}/fields` body = JSON map of field key → value, **replace
semantics** (the body is the complete desired set) → `204`; `404` object missing;
`422` unknown field / type mismatch / unresolved reference (bare — no field detail).
- `GET /api/admin/field-definitions``FieldDefinitionView[]` (`key`, `data_type`,
`vocabulary_id?`, `authority_kind?`, `required`, `group?`, `labels:[{lang,label}]`).
`data_type` ∈ {text, localized_text, integer, date, boolean, term, authority}.
- `GET /api/admin/vocabularies/{id}/terms``TermView[]` (`id`, `external_uri?`,
`labels`).
- `GET /api/admin/authorities?kind=person|organisation|place``AuthorityView[]`
(`id`, `kind`, `external_uri?`, `labels`).
## Architecture
### Routes
```
AppShell (protected)
/objects/new → ObjectNewPage (full-width; sibling, static beats :id)
/objects → ObjectsPage (two-pane: list left + <Outlet/> right)
index → select-prompt placeholder
:id → ObjectDetail (read view; gains Edit/Delete actions)
:id/edit → ObjectEditForm (in-pane edit form)
```
`/objects/new` is a sibling of `/objects` (full-width, not the two-pane). React Router
ranks the static `new` segment above the dynamic `:id`, so `/objects/new` never matches
the detail route. Edit and detail are **children** of `ObjectsPage`, which renders the
list plus an `<Outlet/>` for the right pane.
### Components / files
```
web/src/objects/
object-form.tsx shared form body: core (inventory-minimum) + dynamic flexible
fields; RHF; used by both new and edit. Props: defaults,
mode ("create" | "edit"), onSubmit(values), onCancel.
field-input.tsx <FieldInput definition control/>: switch on data_type →
the right control. Term/authority variants fetch options.
object-new-page.tsx full-width /objects/new: empty ObjectForm (mode=create,
visibility select Draft/Internal) → create flow.
object-edit-form.tsx in-pane /objects/:id/edit: loads the object, pre-fills
ObjectForm (mode=edit, no visibility) → edit flow.
delete-object-dialog.tsx AlertDialog confirm → delete flow.
object-detail.tsx (modify) add Edit + Delete actions.
objects-page.tsx (modify) render list + <Outlet/> for the right pane.
object-list.tsx (modify) add a "New object" action → /objects/new.
web/src/api/queries.ts (modify) + useTerms, useAuthorities, useCreateObject,
useUpdateObject, useSetFields, useDeleteObject.
web/src/app.tsx (modify) nested routes above.
web/src/i18n/{en,sv}.json (modify) form labels, field-type controls, actions, errors.
web/src/components/ui/ shadcn adds: select, checkbox, alert-dialog.
```
`object-form.tsx` is the one unit that could grow large; keep `FieldInput` and the
term/authority option hooks in `field-input.tsx` so the form file stays focused on
layout + submission, and the per-type rendering lives separately.
### Dynamic field rendering (`FieldInput`)
Switch on `definition.data_type`:
| data_type | control | value shape |
|---|---|---|
| `text` | `<Input type="text">` | string |
| `integer` | `<Input type="number">` | number |
| `date` | `<Input type="date">` | `YYYY-MM-DD` string |
| `boolean` | `<Checkbox>` | boolean |
| `localized_text` | sv + en `<Input>`s | `{ sv?, en? }` (omit empty langs) |
| `term` | `<Select>` of `useTerms(definition.vocabulary_id)` | term id (string) |
| `authority` | `<Select>` 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<string, unknown>` cast pending
issue #24 (open-map OpenAPI typing).