Files
biggus-dickus/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-2-design.md
T
logaritmisk 9f43793c4a 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>
2026-06-04 00:09:15 +02:00

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/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-definitionsFieldDefinitionView[] (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}/termsTermView[] (id, external_uri?, labels).
  • GET /api/admin/authorities?kind=person|organisation|placeAuthorityView[] (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): useCreateObjectPOST /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).