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>
8.3 KiB
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
LabelEditorand 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'slocalized_textfield; producesLabelInput[]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/vocabulariesbodyNewVocabularyRequest { key }→201 VocabularyView.GET /api/admin/vocabularies/{id}/terms→TermView[]({ id, external_uri?, labels }).POST /api/admin/vocabularies/{id}/termsbodyNewTermRequest { external_uri?, labels }→201 CreatedId.GET /api/admin/authorities?kind=person|organisation|place→AuthorityView[]({ id, kind, external_uri?, labels }).POST /api/admin/authoritiesbodyNewAuthorityRequest { 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/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")
- The Vocabularies and Authorities nav items are enabled and route to their screens.
- 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.
- 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.
- The shared
LabelEditorproducesLabelInput[]with only non-empty langs; EN is required. - Create failures surface a form-level error; lists have loading/empty/error states.
- 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.