diff --git a/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-4-design.md b/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-4-design.md new file mode 100644 index 0000000..41f6d1c --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-4-design.md @@ -0,0 +1,168 @@ +# Frontend SPA — Milestone 4 (Vocabulary & Authority Management) — Design + +**Date:** 2026-06-04 +**Status:** Approved (brainstorming) — ready for implementation planning. + +## Context + +Milestones 1–3 (merged to `main` at `7a8e7ff`) delivered the SPA foundation, object +read/authoring, and the publishing workflow. The app shell's nav has **Vocabularies** +and **Authorities** items rendered as disabled stubs. Milestone 4 enables them: managing +the controlled vocabularies (and their terms) and the authority records that catalogue +fields reference. + +Pure frontend — the admin endpoints already exist (built in the backend admin-CRUD +phase): `GET/POST /api/admin/vocabularies`, `GET/POST /api/admin/vocabularies/{id}/terms`, +`GET/POST /api/admin/authorities?kind=`. + +Milestone roadmap: M1 foundation → M2 authoring → M3 publish → **M4 vocab/authority +(this)** → M5 search. + +## Decisions (settled during brainstorming) + +- **One milestone for both surfaces** (vocabularies+terms and authorities), sharing a + `LabelEditor` and a create-form pattern. +- **Two-pane master–detail layout** (consistent with the Objects inspector): the + Vocabularies screen is vocab-list-left / terms-right; Authorities is kind-tabs + list. +- **Create + list only.** The backend exposes only create and list for vocabularies, + terms, and authorities — no update/delete — so M4 is create + list. Editing/deleting + reference data is a later milestone (needs backend endpoints first). +- **Fixed sv/en `LabelEditor`** (not arbitrary languages), matching the app's sv/en MVP + scope and M2's `localized_text` field; produces `LabelInput[]` of non-empty langs. +- **EN label required, SV optional** (canonical English), consistent with M2. + +## Backend contract (already shipped — verify against `web/src/api/schema.d.ts`) + +- `GET /api/admin/vocabularies` → `VocabularyView[]` (`{ id, key }`). +- `POST /api/admin/vocabularies` body `NewVocabularyRequest { key }` → `201 VocabularyView`. +- `GET /api/admin/vocabularies/{id}/terms` → `TermView[]` (`{ id, external_uri?, labels }`). +- `POST /api/admin/vocabularies/{id}/terms` body `NewTermRequest { external_uri?, labels }` + → `201 CreatedId`. +- `GET /api/admin/authorities?kind=person|organisation|place` → `AuthorityView[]` + (`{ id, kind, external_uri?, labels }`). +- `POST /api/admin/authorities` body `NewAuthorityRequest { kind, external_uri?, labels }` + → `201 CreatedId`. +- `LabelInput` / `LabelView` = `{ lang, label }`. + +(Existing hooks from M2: `useTerms(vocabularyId)`, `useAuthorities(kind)`.) + +## Scope (YAGNI) + +**In:** Vocabularies screen (list + create vocabulary; per-vocab terms list + add term); +Authorities screen (kind-tabbed list + create authority); shared `LabelEditor` (sv/en); +4 new hooks; the two nav stubs enabled; client validation; list invalidation on create. + +**Out:** update/delete of vocab/term/authority (no backend endpoints — later milestone); +audit of vocab/authority creation (backend follow-up #21); searchable pickers (#27); +search UI (M5); per-language beyond sv/en. + +## Architecture + +### Routes & navigation + +Enable the `Vocabularies` and `Authorities` nav items in `app-shell.tsx` (currently +disabled buttons → active `NavLink`s). Routes under the protected `AppShell`, two-pane +via nested `` like Objects: + +``` +/vocabularies → VocabulariesPage (list + create left; right) + index → "select a vocabulary" prompt + :id → VocabularyTerms (the vocab's terms + add-term form) +/authorities → redirect to /authorities/person +/authorities/:kind → AuthoritiesPage (kind tabs + list + create), kind ∈ person|organisation|place +``` + +`/authorities/:kind` validates the kind param (unknown → redirect to `person`). + +### Components / files + +``` +web/src/vocab/ + vocabularies-page.tsx two-pane: VocabularyList (+ create) left, right + vocabulary-list.tsx useVocabularies list + NewVocabularyForm + vocabulary-terms.tsx (:id) useTerms list + AddTermForm +web/src/authorities/ + authorities-page.tsx kind tabs + AuthorityList(kind) + NewAuthorityForm(kind) +web/src/components/ + label-editor.tsx shared sv/en label editor (RHF-controlled), -> LabelInput[] +web/src/api/queries.ts + useVocabularies, useCreateVocabulary, useAddTerm, useCreateAuthority +web/src/app.tsx + the routes above +web/src/shell/app-shell.tsx enable the Vocabularies + Authorities nav links +web/src/i18n/{en,sv}.json + vocab.* / authorities.* keys +``` + +Keep each page focused; the create forms (`NewVocabularyForm`, `AddTermForm`, +`NewAuthorityForm`) are small and may live in their page files or as siblings — the +shared piece that must be its own unit is `LabelEditor`. + +### `LabelEditor` + +A controlled editor rendering an **English** input and a **Swedish** input. Given/produces +`LabelInput[]` (`{ lang, label }`). On change it emits the array with only the non-empty +langs (so an empty SV is omitted). Used by `AddTermForm` and `NewAuthorityForm`. +Validation: the EN label is required (the parent form wires `required` on the EN field); +SV optional. (Mirrors M2's `localized_text` handling and the existing detail/edit label +rendering.) + +### Data layer (new hooks in `queries.ts`) + +- `useVocabularies()` → `GET /api/admin/vocabularies` → `VocabularyView[]`. +- `useCreateVocabulary()` → `POST /api/admin/vocabularies` `{ key }`; invalidate + `["vocabularies"]`. +- `useAddTerm()` → `POST /api/admin/vocabularies/{id}/terms` `{ external_uri?, labels }`; + invalidate `["terms", vocabularyId]`. +- `useCreateAuthority()` → `POST /api/admin/authorities` `{ kind, external_uri?, labels }`; + invalidate `["authorities", kind]`. + +(`useTerms`/`useAuthorities` already use keys `["terms", vocabularyId]` / +`["authorities", kind]`; the mutations invalidate those exact keys.) + +### Data flow + +- **Create vocabulary:** form (`key`) → `useCreateVocabulary` → invalidate list; clear form. +- **Add term:** form (sv/en labels + optional uri) on `/vocabularies/:id` → + `useAddTerm({ id, labels, external_uri })` → invalidate `["terms", id]`; clear form. +- **Create authority:** form on `/authorities/:kind` (labels + optional uri) → + `useCreateAuthority({ kind, labels, external_uri })` → invalidate `["authorities", kind]`; + clear form. + +## Error handling + +Create failures → a form-level error (reuse `form.rejected`). Lists show loading / +empty / error states (reuse the M1 list-state patterns). Required validation (vocab key; +EN label) blocks submit with inline messages. Unknown authority kind in the route → +redirect to `person`. + +## Testing (Vitest + RTL + MSW) + +- `LabelEditor` — entering EN+SV produces `[{lang:"en",...},{lang:"sv",...}]`; empty SV + omitted. +- Vocabularies: list renders; create a vocabulary → POST `{key}` (assert body) → list + invalidated/refetched shows it; selecting a vocab shows its terms; add a term → + POST with the labels body (assert) → terms refetch. +- Authorities: kind tabs switch the list (`?kind=`); create an authority for the active + kind → POST `{kind, labels}` (assert) → list refetch; required EN label blocks submit. +- Nav: the Vocabularies + Authorities nav items are enabled links (not disabled). +- New MSW handlers: `POST /api/admin/vocabularies`, `POST /api/admin/vocabularies/:id/terms`, + `POST /api/admin/authorities` (the GET handlers + the existing `?kind=` filter handler + are already present from M2). + +## Acceptance criteria (Milestone 4 "done") + +1. The Vocabularies and Authorities nav items are enabled and route to their screens. +2. A vocabulary can be created (key) and appears in the list; selecting it shows its + terms; a term can be added with sv/en labels (+ optional URI) and appears. +3. Authorities can be filtered by kind via tabs; an authority can be created for the + active kind with sv/en labels and appears in that kind's list. +4. The shared `LabelEditor` produces `LabelInput[]` with only non-empty langs; EN is + required. +5. Create failures surface a form-level error; lists have loading/empty/error states. +6. Web CI green (typecheck, lint, tests, build, bundle ≤150 KB gz). + +## Out of scope / follow-ups + +- Edit/delete of vocabularies, terms, authorities — needs backend endpoints first + (file a backend follow-up when this milestone lands). +- Audit of vocab/term/authority creation (#21). +- Searchable pickers / large-vocabulary handling (#27). +- Arbitrary (non sv/en) label languages.