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