# 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.tsx` and `authorities/authority-row.tsx` are byte-for-byte twins except: the mutation
  hooks (`useUpdateTerm`/`useDeleteTerm` vs `useUpdateAuthority`/`useDeleteAuthority`), the record type
  (`TermView` vs `AuthorityView`), 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.tsx` and `vocab/vocabulary-terms.tsx` share 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` +
  `Navigate` guard.

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`).
```ts
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 while `savePending`;
  `<MutationError error={saveError} />` below the buttons.
- The URI input id uses `useId()` (replaces the `term-uri-${id}` / `auth-uri-${id}` scheme).
- `record.labels` is `LabelView[]`; cast to `LabelInput[]` for `LabelEditor` (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.
```ts
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).
```ts
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")`; else `rows.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:
```tsx
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.required` validation (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** `records` length (empty) vs the
  **filtered** `rows` length (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 calls `onSave` and closes on `done`; a
    failed save (passing `saveError`) shows the inline error and the row stays editable; Delete invokes
    `onDelete`.
  - `labelled-record-create-form.test.tsx`: submit with empty labels shows `form.required` and does NOT
    call `onCreate`; a valid submit calls `onCreate` and the `reset` clears 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:colors` green; no new i18n keys; en/sv
  parity unaffected; no codename; no new dependency. `check:size` should not grow (net code reduction).

## Acceptance criteria
1. `LabelledRecordRow`, `LabelledRecordCreateForm`, `FilteredRecordList<T>` exist in `src/components/` with
   the prop shapes above; `TermRow`/`AuthorityRow` and the two pages are adapters over them.
2. `term-row.tsx` + `authority-row.tsx` no longer duplicate the row body; `authorities-page.tsx` +
   `vocabulary-terms.tsx` no longer duplicate the filter/list/create-form body.
3. All existing tests pass unchanged; the three new components have focused tests.
4. No behavior change: inline edit/save/delete, create + validation, filtering, the 4 list states, the
   authorities kind-nav, and breadcrumbs all work as before.
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported (not increased); no new
   i18n keys; no codename; no new dependency.

## Out of scope → follow-ups
- `vocabulary-list.tsx` (key-based vocabularies) and the `objects` table/detail/RHF surface.
- A field-definition "position"/ordering concept; server-side filtering for large vocabularies (#43).
