docs(specs): unify vocabulary + authority CRUD (#64)
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user