11 KiB
Unify Vocabulary + Authority CRUD — Design
Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation planning. Issue: #64 (the duplicated Vocabulary-terms + Authorities CRUD surfaces).
Context
The Vocabulary-terms and Authorities admin surfaces are two copies of one feature ("a labelled record with an optional external URI: filterable list + inline-edit rows + create form"). The duplication spans four files and ~280 lines:
vocab/term-row.tsxandauthorities/authority-row.tsxare byte-for-byte twins except: the mutation hooks (useUpdateTerm/useDeleteTermvsuseUpdateAuthority/useDeleteAuthority), the record type (TermViewvsAuthorityView), the URI-input id prefix (term-uri-/auth-uri-), the mutate-arg shape ({ vocabularyId, termId, … }vs{ id, kind, … }), and the delete-confirm i18n key.authorities/authorities-page.tsxandvocab/vocabulary-terms.tsxshare the filter input, the 4-state list (loading→skeleton / error / empty / no-matches / rows), and the create form (LabelEditor+ URI input +labels.some()validation +MutationError+ submit). They differ in the i18n keys, the create-form heading, and (authorities only) the kind-tabs<nav>+PageTitle+Navigateguard.
A fix to inline-edit behaviour must currently be made in two places and silently drifts. (These files
already share LabelEditor, ExternalUriLink, DeleteConfirmDialog, and MutationError.)
vocabulary-list.tsx (vocabularies are key-based, not labelled records) and the objects RHF surface
are intentionally not unified — different shapes.
Decisions (from brainstorming)
Full unification — three shared components in src/components/, with TermRow/AuthorityRow and the
two pages reduced to thin adapters. All variance is pushed into props; no generics on the row (the adapter
owns the mutate-arg shape). Behavior-preserving — existing tests are the guard.
Components
components/labelled-record-row.tsx (new) — LabelledRecordRow
Owns editing / labels / uri state. Renders the display row (labelText + ExternalUriLink + Edit
button + DeleteConfirmDialog) or the edit view (LabelEditor + URI <Input> + Save/Cancel +
MutationError).
type RecordLike = { id: string; labels: LabelView[]; external_uri: string | null };
function LabelledRecordRow(props: {
record: RecordLike;
lang: string;
deleteConfirmKey: string; // i18n key for the confirm prompt
savePending: boolean; // update.isPending
saveError: unknown; // update.error
onEditOpen: () => void; // adapter calls update.reset()
onSave: (labels: LabelInput[], uri: string | null, done: () => void) => void;
onDelete: () => Promise<void>;
}): JSX.Element;
- Edit button onClick:
onEditOpen(); setLabels(record.labels as LabelInput[]); setUri(record.external_uri ?? ""); setEditing(true);(preserves the #63 reset-on-edit-open behaviour). - Save:
onSave(labels, uri.trim() || null, () => setEditing(false)); Save disabled whilesavePending;<MutationError error={saveError} />below the buttons. - The URI input id uses
useId()(replaces theterm-uri-${id}/auth-uri-${id}scheme). record.labelsisLabelView[]; cast toLabelInput[]forLabelEditor(same cast the current rows do).
components/labelled-record-create-form.tsx (new) — LabelledRecordCreateForm
Owns its own labels / uri / requiredError state. Renders heading + LabelEditor + URI <Input>
(id via useId()) + the form.required validation alert + <MutationError error={error} /> + submit.
function LabelledRecordCreateForm(props: {
heading: ReactNode;
submitLabel: string;
pending: boolean; // create.isPending
error: unknown; // create.error
onCreate: (labels: LabelInput[], uri: string | null, reset: () => void) => void;
}): JSX.Element;
- Submit:
if (!labels.some((l) => l.label)) { setRequiredError(true); return; } setRequiredError(false); onCreate(labels, uri.trim() || null, () => { setLabels([]); setUri(""); }); - Submit button disabled while
pending.
components/filtered-record-list.tsx (new) — FilteredRecordList<T>
Owns the filter state. Renders the filter <Input> always, then (loading → <ListSkeleton> else the
<ul>), so the filter stays visible during load (matches current behaviour).
function FilteredRecordList<T extends { id: string; labels: LabelView[] }>(props: {
records: T[] | undefined;
lang: string;
isLoading: boolean;
isError: boolean;
loadErrorText: string;
emptyText: string;
renderRow: (record: T) => ReactNode;
}): JSX.Element;
const q = filter.trim().toLowerCase(); const rows = [...(records ?? [])].filter((r) => !q || labelText(r.labels, lang).toLowerCase().includes(q)).sort(byLabel(lang));- List states (preserving the current logic exactly):
isError→loadErrorText;!isError && records?.length === 0→emptyText;!isError && records && records.length > 0 && rows.length === 0→t("common.noMatches"); elserows.map(renderRow). - Filter input uses
aria-label={t("common.filter")}+placeholder={t("common.filter")}(unchanged).
Adapters
vocab/term-row.tsx keeps its public API <TermRow vocabularyId term lang /> (its #63 test stays
valid). Body:
const update = useUpdateTerm();
const del = useDeleteTerm();
return (
<LabelledRecordRow
record={term}
lang={lang}
deleteConfirmKey="actions.confirmDeleteTerm"
savePending={update.isPending}
saveError={update.error}
onEditOpen={() => update.reset()}
onSave={(labels, uri, done) =>
update.mutate({ vocabularyId, termId: term.id, external_uri: uri, labels }, { onSuccess: done })}
onDelete={() => del.mutateAsync({ vocabularyId, termId: term.id })}
/>
);
authorities/authority-row.tsx keeps <AuthorityRow authority kind lang />; analogous with
useUpdateAuthority/useDeleteAuthority, deleteConfirmKey="actions.confirmDeleteAuthority", save arg
{ id: authority.id, kind, external_uri: uri, labels }, delete { id: authority.id, kind }.
authorities/authorities-page.tsx keeps the PageTitle, kind <nav>, Navigate guard, breadcrumb,
and useDocumentTitle. Replaces the inline filter/list with <FilteredRecordList records={authorities} lang={lang} isLoading={isLoading} isError={isError} loadErrorText={t("authorities.loadError")} emptyText={t("authorities.empty")} renderRow={(a) => <AuthorityRow authority={a} kind={currentKind} lang={lang} />} />, and the inline form with <LabelledRecordCreateForm heading={${t("authorities.new")} ·
${t(`authorities.${currentKind}`)}} submitLabel={t("authorities.create")} pending={create.isPending} error={create.error} onCreate={(labels, uri, reset) => create.mutate({ kind, external_uri: uri, labels }, { onSuccess: reset })} />. (Drops the now-unused local labels/uri/error/filter state and the
onCreate handler.)
vocab/vocabulary-terms.tsx keeps its vocab.terms caption + breadcrumb. Uses <FilteredRecordList records={terms} … loadErrorText={t("vocab.loadError")} emptyText={t("vocab.noTerms")} renderRow={(term) => <TermRow vocabularyId={id} term={term} lang={lang} />} /> and <LabelledRecordCreateForm heading={t("vocab.addTerm")} submitLabel={t("vocab.addTerm")} pending={addTerm.isPending} error={addTerm.error} onCreate={(labels, uri, reset) => addTerm.mutate({ vocabularyId: id, external_uri: uri, labels }, { onSuccess: reset })} />.
Data flow
No change to the query/mutation layer. Each page owns its data hooks + the page-specific chrome (nav, headings, breadcrumb) and hands records + render callbacks to the shared list, mutate callbacks to the shared create form, and per-row mutate callbacks (via the row adapters) to the shared row.
Error handling / edges
- Inline save errors and create errors still render via
<MutationError>(status-aware, from #63), unchanged. - The
form.requiredvalidation (empty labels) stays in the create form. - Reset-on-edit-open (
update.reset()) preserved so a stale save error doesn't linger. - The empty-vs-no-matches distinction is computed from the raw
recordslength (empty) vs the filteredrowslength (no matches), exactly as today. useId()keeps URI-input ids unique across simultaneously-mounted rows/forms.
Testing
- Behavior guard: existing tests stay green unchanged —
vocab/term-row.test.tsx,authorities/authorities.test.tsx(page filter/create/list),vocab/vocabularies.test.tsx(out-of-scope list, must not break). Same public component APIs + DOM. - New focused tests for the three components:
labelled-record-row.test.tsx: display → click Edit → Save callsonSaveand closes ondone; a failed save (passingsaveError) shows the inline error and the row stays editable; Delete invokesonDelete.labelled-record-create-form.test.tsx: submit with empty labels showsform.requiredand does NOT callonCreate; a valid submit callsonCreateand theresetclears the inputs.filtered-record-list.test.tsx: typing in the filter narrows the rendered rows;records=[]→emptyText; non-empty records + non-matching filter →common.noMatches;isError→loadErrorText;isLoading→ skeleton.
- Gate:
typecheck/lint/test/build/check:size/check:colorsgreen; no new i18n keys; en/sv parity unaffected; no codename; no new dependency.check:sizeshould not grow (net code reduction).
Acceptance criteria
LabelledRecordRow,LabelledRecordCreateForm,FilteredRecordList<T>exist insrc/components/with the prop shapes above;TermRow/AuthorityRowand the two pages are adapters over them.term-row.tsx+authority-row.tsxno longer duplicate the row body;authorities-page.tsx+vocabulary-terms.tsxno longer duplicate the filter/list/create-form body.- All existing tests pass unchanged; the three new components have focused tests.
- No behavior change: inline edit/save/delete, create + validation, filtering, the 4 list states, the authorities kind-nav, and breadcrumbs all work as before.
typecheck/lint/test/build/check:colorsgreen;check:sizereported (not increased); no new i18n keys; no codename; no new dependency.
Out of scope → follow-ups
vocabulary-list.tsx(key-based vocabularies) and theobjectstable/detail/RHF surface.- A field-definition "position"/ordering concept; server-side filtering for large vocabularies (#43).