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:
@@ -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).
|
||||
Reference in New Issue
Block a user