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>
11 KiB
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/objectsbodyObjectCreateRequest(object_number, object_name, number_of_objects, optional brief_description/current_location/current_owner/recorder, recording_dateYYYY-MM-DD, visibility) →201 CreatedObject {id}. Rejectsvisibility=publicand bad dates with422.PUT /api/admin/objects/{id}bodyObjectUpdateRequest(same minimum fields, no visibility) →204;404if missing.DELETE /api/admin/objects/{id}→204;404if missing.PUT /api/admin/objects/{id}/fieldsbody = JSON map of field key → value, replace semantics (the body is the complete desired set) →204;404object missing;422unknown 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_fieldsunknown/type/unresolved — bare): surface a form-level alert ("The server rejected the changes — check the highlighted and referenced fields"). The bareset_fields422 carries no per-field detail, so client validation is the primary guard. - Partial create (create
POSTsucceeds, fieldsPUTfails): the core record now exists as Draft. Rather than lose it, navigate to/objects/:id/editwith 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;
newoffers only Draft/Internal (never Public).
Testing (Vitest + RTL + MSW)
FieldInput: renders the correct control for eachdata_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 /objectsthenPUT /objects/:id/fieldswere 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
newform's visibility select offers only Draft and Internal. - Partial create: create OK but fields PUT 422 → lands on
/objects/:id/editwith an error banner. - New MSW handlers: terms, authorities,
POST/PUT/DELETE /objects,PUT .../fields.
Acceptance criteria (Milestone 2 "done")
- 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.
- 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.
- All seven field types render and round-trip (incl. term/authority selects and sv/en localized text).
- Required fields are enforced client-side; the
newform cannot set Public. - "Delete" confirms, deletes, and returns to the list; the list no longer shows it.
- Partial-create failure lands on the edit page with the core record preserved.
- 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_fields422 to carry field detail (currently bare — see the admin 422 bodies note in issue #16's follow-up). fieldsmap typing still relies on theRecord<string, unknown>cast pending issue #24 (open-map OpenAPI typing).