Files
biggus-dickus/docs/superpowers/specs/2026-06-08-unify-record-crud-design.md
T

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.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).

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.

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): isErrorloadErrorText; !isError && records?.length === 0emptyText; !isError && records && records.length > 0 && rows.length === 0t("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:

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; isErrorloadErrorText; 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).