Files
biggus-dickus/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-4-design.md
T
logaritmisk 684b5449ca docs(spec): frontend SPA milestone 4 (vocabulary & authority management) design
Two-pane vocab (list/create + terms/add) + kind-tabbed authorities
(list/create); shared sv/en LabelEditor; create+list only (no backend
edit/delete yet); 4 new hooks; enables the nav stubs; Vitest+RTL+MSW.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:00:02 +02:00

8.3 KiB
Raw Blame History

Frontend SPA — Milestone 4 (Vocabulary & Authority Management) — Design

Date: 2026-06-04 Status: Approved (brainstorming) — ready for implementation planning.

Context

Milestones 13 (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 masterdetail 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/vocabulariesVocabularyView[] ({ id, key }).
  • POST /api/admin/vocabularies body NewVocabularyRequest { key }201 VocabularyView.
  • GET /api/admin/vocabularies/{id}/termsTermView[] ({ id, external_uri?, labels }).
  • POST /api/admin/vocabularies/{id}/terms body NewTermRequest { external_uri?, labels }201 CreatedId.
  • GET /api/admin/authorities?kind=person|organisation|placeAuthorityView[] ({ 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 NavLinks). Routes under the protected AppShell, two-pane via nested <Outlet/> like Objects:

/vocabularies            → VocabulariesPage (list + create left; <Outlet/> 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, <Outlet/> 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/vocabulariesVocabularyView[].
  • 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/:iduseAddTerm({ 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.