# 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).
