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

727 lines
30 KiB
Markdown

# Unify Vocabulary + Authority CRUD — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Collapse the duplicated Vocabulary-terms + Authorities CRUD (~280 lines across 4 files) into three shared components, with the rows and pages reduced to thin adapters — behavior-preserving.
**Architecture:** Build `LabelledRecordRow`, `LabelledRecordCreateForm`, and `FilteredRecordList<T>` in `src/components/` (Tasks 1-3, additive — existing app untouched, all existing tests stay green). Then rewire `term-row`/`authority-row` and `authorities-page`/`vocabulary-terms` onto them (Task 4) and run the full gate. Variance (mutation hooks, arg shapes, i18n keys, page chrome) lives entirely in the adapters.
**Tech Stack:** React 19 + TS + pnpm, TanStack Query v5, react-i18next, Base UI, Vitest 4 (jsdom) + RTL.
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; double-quote+semicolon; token classes only; `components/ui/*` untouched. Run a single test pass per task.
**Spec:** `docs/superpowers/specs/2026-06-08-unify-record-crud-design.md`
**Key facts:**
- Types: `type LabelView = components["schemas"]["LabelView"]`, `type LabelInput = components["schemas"]["LabelInput"]`. `labelText(labels: LabelView[], lang)` (`lib/labels`), `byLabel(lang)` returns a comparator over `{ labels: LabelView[] }` (`lib/sort`).
- Shared building blocks (in `src/components/`): `label-editor` (`LabelEditor`, uses `useId` since #62, needs `useConfig` which defaults to `DEFAULTS` — works under `renderApp`), `delete-confirm-dialog` (`DeleteConfirmDialog` — props `description`, `onConfirm: () => Promise<void>`), `mutation-error` (`MutationError` — prop `error: unknown`), `external-uri-link` (`ExternalUriLink` — prop `uri`).
- UI kit: `Button`, `Input`, `Label` from `@/components/ui/*`; `ListSkeleton` from `@/components/ui/skeletons` (props `className`, `rows`).
- Test harness: `renderApp(ui, { route })` from `../test/render` (wraps QueryClient + memory router + i18n; NO ConfigProvider, but `useConfig` falls back to defaults). `HttpError` is exported from `../api/queries`.
- Existing tests that MUST stay green unchanged: `vocab/term-row.test.tsx`, `authorities/authorities.test.tsx`, `vocab/vocabularies.test.tsx`.
- Current `term-row.tsx`/`authority-row.tsx` are twins; `authorities-page.tsx` has a kind `<nav>` + `PageTitle` + `Navigate` guard + breadcrumb; `vocabulary-terms.tsx` has a `vocab.terms` caption + breadcrumb. Both pages render the filter input ALWAYS, then `isLoading ? <ListSkeleton/> : <ul>…</ul>`.
---
# Task 1: `LabelledRecordRow`
**Files:** Create `web/src/components/labelled-record-row.tsx`, `web/src/components/labelled-record-row.test.tsx`.
- [ ] **Step 1: Create `web/src/components/labelled-record-row.tsx`:**
```tsx
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { LabelEditor } from "./label-editor";
import { DeleteConfirmDialog } from "./delete-confirm-dialog";
import { MutationError } from "./mutation-error";
import { ExternalUriLink } from "./external-uri-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { labelText } from "../lib/labels";
type LabelView = components["schemas"]["LabelView"];
type LabelInput = components["schemas"]["LabelInput"];
export type RecordLike = { id: string; labels: LabelView[]; external_uri: string | null };
/** One labelled record (term/authority): a display row with edit + delete, or an
* inline editor. All variance (mutation hooks, arg shapes, delete-confirm key) is
* supplied by the caller via callbacks/state — see term-row.tsx / authority-row.tsx. */
export function LabelledRecordRow({
record,
lang,
deleteConfirmKey,
savePending,
saveError,
onEditOpen,
onSave,
onDelete,
}: {
record: RecordLike;
lang: string;
deleteConfirmKey: string;
savePending: boolean;
saveError: unknown;
onEditOpen: () => void;
onSave: (labels: LabelInput[], uri: string | null, done: () => void) => void;
onDelete: () => Promise<void>;
}) {
const { t } = useTranslation();
const uriId = useId();
const [editing, setEditing] = useState(false);
const [labels, setLabels] = useState<LabelInput[]>(record.labels as LabelInput[]);
const [uri, setUri] = useState(record.external_uri ?? "");
if (editing) {
return (
<li className="space-y-2 border-b py-2">
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor={uriId}>{t("labels.externalUri")}</Label>
<Input
id={uriId}
type="url"
placeholder={t("labels.uriPlaceholder")}
value={uri}
onChange={(e) => setUri(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={savePending}
onClick={() => onSave(labels, uri.trim() || null, () => setEditing(false))}
>
{t("actions.save")}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
{t("form.cancel")}
</Button>
</div>
<MutationError error={saveError} />
</li>
);
}
return (
<li className="flex items-center gap-2 border-b py-1 text-sm">
<div className="flex-1">
<div>{labelText(record.labels, lang)}</div>
{record.external_uri && <ExternalUriLink uri={record.external_uri} />}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
onEditOpen();
setLabels(record.labels as LabelInput[]);
setUri(record.external_uri ?? "");
setEditing(true);
}}
>
{t("actions.edit")}
</Button>
<DeleteConfirmDialog description={t(deleteConfirmKey)} onConfirm={onDelete} />
</li>
);
}
```
- [ ] **Step 2: Create `web/src/components/labelled-record-row.test.tsx`** (write + run). Type the test record as `RecordLike` (import it) — no `any`/`never`:
```tsx
import { expect, test, vi } from "vitest";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { LabelledRecordRow, type RecordLike } from "./labelled-record-row";
import { HttpError } from "../api/queries";
const record: RecordLike = { id: "r1", external_uri: null, labels: [{ lang: "en", label: "Bronze" }] };
test("edit → save calls onSave and closes via done()", async () => {
const onSave = vi.fn((_labels: unknown, _uri: unknown, done: () => void) => done());
renderApp(
<ul>
<LabelledRecordRow
record={record}
lang="en"
deleteConfirmKey="actions.confirmDeleteTerm"
savePending={false}
saveError={null}
onEditOpen={() => {}}
onSave={onSave}
onDelete={async () => {}}
/>
</ul>,
);
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /save/i })).toBeNull();
});
test("a save error renders inline and the row stays editable", async () => {
renderApp(
<ul>
<LabelledRecordRow
record={record}
lang="en"
deleteConfirmKey="actions.confirmDeleteTerm"
savePending={false}
saveError={new HttpError(403)}
onEditOpen={() => {}}
onSave={() => {}}
onDelete={async () => {}}
/>
</ul>,
);
await userEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(screen.getByRole("alert")).toHaveTextContent(/permission/i);
expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
});
test("confirming delete invokes onDelete", async () => {
const onDelete = vi.fn(async () => {});
renderApp(
<ul>
<LabelledRecordRow
record={record}
lang="en"
deleteConfirmKey="actions.confirmDeleteTerm"
savePending={false}
saveError={null}
onEditOpen={() => {}}
onSave={() => {}}
onDelete={onDelete}
/>
</ul>,
);
// open the confirm dialog (trigger button is labelled "Delete")
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
// the dialog renders in a portal on document.body with a confirm "Delete" action
const dialog = within(document.body);
const confirmButtons = await dialog.findAllByRole("button", { name: /delete/i });
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
expect(onDelete).toHaveBeenCalled();
});
```
Run: `cd web && pnpm vitest run src/components/labelled-record-row.test.tsx`. (If the delete-dialog DOM differs, mirror the portal/confirm pattern used in `web/src/shell/user-menu.test.tsx` / the delete-confirm-dialog story — the key assertion is that confirming calls `onDelete`. Don't weaken the save/error assertions.)
- [ ] **Step 3: Verify + lint:** `cd web && pnpm vitest run src/components/labelled-record-row.test.tsx && pnpm typecheck && pnpm lint` — all green.
- [ ] **Step 4: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/components/labelled-record-row.tsx web/src/components/labelled-record-row.test.tsx
git commit -m "feat(web): shared LabelledRecordRow component (#64)"
```
---
# Task 2: `LabelledRecordCreateForm`
**Files:** Create `web/src/components/labelled-record-create-form.tsx`, `web/src/components/labelled-record-create-form.test.tsx`.
- [ ] **Step 1: Create `web/src/components/labelled-record-create-form.tsx`:**
```tsx
import { useId, useState, type FormEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { LabelEditor } from "./label-editor";
import { MutationError } from "./mutation-error";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type LabelInput = components["schemas"]["LabelInput"];
/** Create form for a labelled record (term/authority): single-language label +
* optional external URI, with required-label validation and a status-aware error.
* `onCreate` performs the mutation and is handed a `reset` to clear the inputs on success. */
export function LabelledRecordCreateForm({
heading,
submitLabel,
pending,
error,
onCreate,
}: {
heading: ReactNode;
submitLabel: string;
pending: boolean;
error: unknown;
onCreate: (labels: LabelInput[], uri: string | null, reset: () => void) => void;
}) {
const { t } = useTranslation();
const uriId = useId();
const [labels, setLabels] = useState<LabelInput[]>([]);
const [uri, setUri] = useState("");
const [requiredError, setRequiredError] = useState(false);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
if (!labels.some((l) => l.label)) {
setRequiredError(true);
return;
}
setRequiredError(false);
onCreate(labels, uri.trim() || null, () => {
setLabels([]);
setUri("");
});
};
return (
<form onSubmit={onSubmit} className="space-y-2 border-t pt-3">
<div className="text-sm font-medium">{heading}</div>
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor={uriId}>{t("labels.externalUri")}</Label>
<Input
id={uriId}
type="url"
placeholder={t("labels.uriPlaceholder")}
value={uri}
onChange={(e) => setUri(e.target.value)}
/>
</div>
{requiredError && (
<p role="alert" className="text-xs text-destructive">
{t("form.required")}
</p>
)}
<MutationError error={error} />
<Button type="submit" size="sm" disabled={pending}>
{submitLabel}
</Button>
</form>
);
}
```
- [ ] **Step 2: Create `web/src/components/labelled-record-create-form.test.tsx`** (write + run):
```tsx
import { expect, test, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { LabelledRecordCreateForm } from "./labelled-record-create-form";
test("submitting with empty labels shows the required error and does not call onCreate", async () => {
const onCreate = vi.fn();
renderApp(
<LabelledRecordCreateForm heading="New" submitLabel="Create" pending={false} error={null} onCreate={onCreate} />,
);
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(screen.getByRole("alert")).toBeInTheDocument(); // form.required (MutationError is null → no alert)
expect(onCreate).not.toHaveBeenCalled();
});
test("a valid submit calls onCreate and the reset clears the inputs", async () => {
const onCreate = vi.fn((_labels: unknown, _uri: unknown, reset: () => void) => reset());
renderApp(
<LabelledRecordCreateForm heading="New" submitLabel="Create" pending={false} error={null} onCreate={onCreate} />,
);
const labelInput = screen.getByLabelText(/^label$/i) as HTMLInputElement;
await userEvent.type(labelInput, "Bronze");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).toHaveBeenCalled();
expect((screen.getByLabelText(/^label$/i) as HTMLInputElement).value).toBe("");
});
```
Run: `cd web && pnpm vitest run src/components/labelled-record-create-form.test.tsx`. (The required-error `<p role="alert">` is the only alert when `error={null}`; `LabelEditor`'s label is "Label".)
- [ ] **Step 3: Verify + lint:** `cd web && pnpm vitest run src/components/labelled-record-create-form.test.tsx && pnpm typecheck && pnpm lint`.
- [ ] **Step 4: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/components/labelled-record-create-form.tsx web/src/components/labelled-record-create-form.test.tsx
git commit -m "feat(web): shared LabelledRecordCreateForm component (#64)"
```
---
# Task 3: `FilteredRecordList<T>`
**Files:** Create `web/src/components/filtered-record-list.tsx`, `web/src/components/filtered-record-list.test.tsx`.
- [ ] **Step 1: Create `web/src/components/filtered-record-list.tsx`:**
```tsx
import { Fragment, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { byLabel } from "../lib/sort";
import { labelText } from "../lib/labels";
import { Input } from "@/components/ui/input";
import { ListSkeleton } from "@/components/ui/skeletons";
type LabelView = components["schemas"]["LabelView"];
/** Filterable, alphabetically-sorted list of labelled records with the standard
* loading / error / empty / no-matches states. The filter input stays visible
* during load (matching the prior page behaviour). */
export function FilteredRecordList<T extends { id: string; labels: LabelView[] }>({
records,
lang,
isLoading,
isError,
loadErrorText,
emptyText,
renderRow,
}: {
records: T[] | undefined;
lang: string;
isLoading: boolean;
isError: boolean;
loadErrorText: string;
emptyText: string;
renderRow: (record: T) => ReactNode;
}) {
const { t } = useTranslation();
const [filter, setFilter] = useState("");
const q = filter.trim().toLowerCase();
const rows = [...(records ?? [])]
.filter((r) => !q || labelText(r.labels, lang).toLowerCase().includes(q))
.sort(byLabel(lang));
return (
<>
<div className="mb-3">
<Input
aria-label={t("common.filter")}
placeholder={t("common.filter")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{isLoading ? (
<ListSkeleton className="mb-4" rows={5} />
) : (
<ul className="mb-4">
{isError && <li className="text-sm text-destructive">{loadErrorText}</li>}
{!isError && records?.length === 0 && (
<li className="text-sm text-muted-foreground">{emptyText}</li>
)}
{!isError && records && records.length > 0 && rows.length === 0 && (
<li className="text-sm text-muted-foreground">{t("common.noMatches")}</li>
)}
{rows.map((r) => (
<Fragment key={r.id}>{renderRow(r)}</Fragment>
))}
</ul>
)}
</>
);
}
```
- [ ] **Step 2: Create `web/src/components/filtered-record-list.test.tsx`** (write + run):
```tsx
import { expect, test } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { FilteredRecordList } from "./filtered-record-list";
import { labelText } from "../lib/labels";
type Rec = { id: string; labels: { lang: string; label: string }[] };
const recs: Rec[] = [
{ id: "a", labels: [{ lang: "en", label: "Alpha" }] },
{ id: "b", labels: [{ lang: "en", label: "Beta" }] },
];
const row = (r: Rec) => <li>{labelText(r.labels, "en")}</li>;
test("filtering narrows the rendered rows", async () => {
renderApp(
<FilteredRecordList records={recs} lang="en" isLoading={false} isError={false}
loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
);
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/filter/i), "alph");
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.queryByText("Beta")).toBeNull();
});
test("empty records show the empty text", () => {
renderApp(
<FilteredRecordList records={[]} lang="en" isLoading={false} isError={false}
loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
);
expect(screen.getByText("EmptyMsg")).toBeInTheDocument();
});
test("non-empty records with a non-matching filter show no-matches", async () => {
renderApp(
<FilteredRecordList records={recs} lang="en" isLoading={false} isError={false}
loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
);
await userEvent.type(screen.getByLabelText(/filter/i), "zzz");
expect(screen.getByText(/no matches/i)).toBeInTheDocument();
});
test("an error shows the load-error text", () => {
renderApp(
<FilteredRecordList records={undefined} lang="en" isLoading={false} isError={true}
loadErrorText="LoadErr" emptyText="EmptyMsg" renderRow={row} />,
);
expect(screen.getByText("LoadErr")).toBeInTheDocument();
});
```
Run: `cd web && pnpm vitest run src/components/filtered-record-list.test.tsx`.
- [ ] **Step 3: Verify + lint:** `cd web && pnpm vitest run src/components/filtered-record-list.test.tsx && pnpm typecheck && pnpm lint`.
- [ ] **Step 4: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/components/filtered-record-list.tsx web/src/components/filtered-record-list.test.tsx
git commit -m "feat(web): shared FilteredRecordList component (#64)"
```
---
# Task 4: Wire the adapters + pages, then full gate
**Files:** Modify `web/src/vocab/term-row.tsx`, `web/src/authorities/authority-row.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/vocab/vocabulary-terms.tsx`.
- [ ] **Step 1: Rewrite `web/src/vocab/term-row.tsx`** (keep the `<TermRow vocabularyId term lang />` API):
```tsx
import type { components } from "../api/schema";
import { useUpdateTerm, useDeleteTerm } from "../api/queries";
import { LabelledRecordRow } from "../components/labelled-record-row";
type TermView = components["schemas"]["TermView"];
export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; term: TermView; lang: string }) {
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 })}
/>
);
}
```
- [ ] **Step 2: Rewrite `web/src/authorities/authority-row.tsx`** (keep `<AuthorityRow authority kind lang />`):
```tsx
import type { components } from "../api/schema";
import { useUpdateAuthority, useDeleteAuthority } from "../api/queries";
import { LabelledRecordRow } from "../components/labelled-record-row";
type AuthorityView = components["schemas"]["AuthorityView"];
export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityView; kind: string; lang: string }) {
const update = useUpdateAuthority();
const del = useDeleteAuthority();
return (
<LabelledRecordRow
record={authority}
lang={lang}
deleteConfirmKey="actions.confirmDeleteAuthority"
savePending={update.isPending}
saveError={update.error}
onEditOpen={() => update.reset()}
onSave={(labels, uri, done) =>
update.mutate({ id: authority.id, kind, external_uri: uri, labels }, { onSuccess: done })}
onDelete={() => del.mutateAsync({ id: authority.id, kind })}
/>
);
}
```
- [ ] **Step 3: Rewrite `web/src/authorities/authorities-page.tsx`** to use the shared list + form. Keep the `PageTitle`, kind `<nav>`, `Navigate` guard, `useDocumentTitle`, `useBreadcrumb`. Remove the local `labels`/`uri`/`error`/`filter` state, the `onCreate` handler, and now-unused imports (`useState`, `FormEvent`, `LabelEditor`, `MutationError`, `Input`, `Label`, `ListSkeleton`, `byLabel`, `labelText`). New file:
```tsx
import { NavLink, Navigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthorities, useCreateAuthority } from "../api/queries";
import { FilteredRecordList } from "../components/filtered-record-list";
import { LabelledRecordCreateForm } from "../components/labelled-record-create-form";
import { PageTitle } from "@/components/ui/page-title";
import { AuthorityRow } from "./authority-row";
import { focusRing } from "../lib/focus-ring";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { cn } from "@/lib/utils";
const KINDS = ["person", "organisation", "place"] as const;
export function AuthoritiesPage() {
const { t, i18n } = useTranslation();
const { kind } = useParams();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const isValidKind = (KINDS as readonly string[]).includes(kind ?? "");
const currentKind = isValidKind ? (kind as string) : "person";
const { data: authorities, isLoading, isError } = useAuthorities(currentKind);
const create = useCreateAuthority();
useDocumentTitle(t("nav.authorities"));
useBreadcrumb([{ label: t("nav.authorities") }]);
if (!isValidKind) return <Navigate to="/authorities/person" replace />;
return (
<div className="overflow-auto p-4">
<PageTitle className="mb-3">{t("nav.authorities")}</PageTitle>
<nav aria-label={t("nav.authorities")} className="mb-3 flex gap-2">
{KINDS.map((k) => (
<NavLink
key={k}
to={`/authorities/${k}`}
className={({ isActive }) =>
cn("rounded-md px-3 py-1 text-sm", focusRing, isActive ? "bg-primary text-primary-foreground" : "border")
}
>
{t(`authorities.${k}`)}
</NavLink>
))}
</nav>
<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} />}
/>
<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: currentKind, external_uri: uri, labels }, { onSuccess: reset })}
/>
</div>
);
}
```
(Note: the original passed `kind: kind as string` to `create.mutate`; `currentKind` is equivalent here since the `isValidKind` guard already returned otherwise — use `currentKind`.)
- [ ] **Step 4: Rewrite `web/src/vocab/vocabulary-terms.tsx`** to use the shared list + form. Keep the `vocab.terms` caption + breadcrumb + the `useVocabularies` lookup. Remove the local form/list state + now-unused imports:
```tsx
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useTerms, useAddTerm, useVocabularies } from "../api/queries";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { FilteredRecordList } from "../components/filtered-record-list";
import { LabelledRecordCreateForm } from "../components/labelled-record-create-form";
import { TermRow } from "./term-row";
export function VocabularyTerms() {
const { t, i18n } = useTranslation();
const { id } = useParams();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const { data: terms, isLoading, isError } = useTerms(id);
const addTerm = useAddTerm();
const { data: vocabularies } = useVocabularies();
const vocabKey = vocabularies?.find((v) => v.id === id)?.key;
useBreadcrumb(
vocabKey
? [{ label: t("nav.vocabularies"), to: "/vocabularies" }, { label: vocabKey }]
: [{ label: t("nav.vocabularies"), to: "/vocabularies" }],
);
if (!id) return null;
return (
<div className="overflow-auto p-4">
<div className="mb-2 label-caption">{t("vocab.terms")}</div>
<FilteredRecordList
records={terms}
lang={lang}
isLoading={isLoading}
isError={isError}
loadErrorText={t("vocab.loadError")}
emptyText={t("vocab.noTerms")}
renderRow={(term) => <TermRow vocabularyId={id} term={term} lang={lang} />}
/>
<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 })}
/>
</div>
);
}
```
(Note: `useTerms(id)` and `if (!id) return null``id` is `string | undefined`; the hooks accept it, and the `!id` guard runs after the hooks, matching the original order. `renderRow`/`onCreate` use the narrowed `id` inside JSX where `!id` already returned — but to satisfy TS, `id` is `string` after the guard since the guard is before the `return` JSX. Confirm typecheck is clean; if TS still widens, the original already used `id` directly in the same spot, so it resolves.)
- [ ] **Step 5: FULL FRONTEND GATE (run tests EXACTLY ONCE):**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
```
All green. The existing `term-row.test.tsx`, `authorities.test.tsx`, `vocabularies.test.tsx` MUST pass unchanged (behavior-preserving). Report test totals, largest chunk (gz), and the `check:colors` line. If an existing test fails, the refactor changed behavior — fix the adapter/page to match, do NOT edit the test.
- [ ] **Step 6: Codename + status:**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?"
git status --short
```
Expected: no matches (`codename-exit=1`).
- [ ] **Step 7: Manual smoke (recommended).** `pnpm dev`: on Authorities and Vocabulary-terms — filter narrows the list; create with an empty label shows the required error; create/edit/delete work; a failed save shows the inline message and keeps the row editable; the authorities kind-tabs + breadcrumbs are unchanged.
- [ ] **Step 8: Commit**
```bash
cd /Users/olsson/Laboratory/biggus-dickus
git add web/src/vocab/term-row.tsx web/src/authorities/authority-row.tsx web/src/authorities/authorities-page.tsx web/src/vocab/vocabulary-terms.tsx
git commit -m "refactor(web): term/authority rows + pages adopt shared CRUD components (#64)"
```
---
## Self-Review (completed)
**Spec coverage:** AC1 three components with the prop shapes (T1-T3); AC2 rows + pages de-duplicated (T4 S1-S4); AC3 existing tests green + 3 new component tests (T1-T3 tests, T4 S5); AC4 behavior preserved — edit/save/delete/create/validation/filter/4-states/kind-nav/breadcrumb (T4 + the existing-test guard); AC5 gate/check:size/no-new-keys/codename (T4 S5-S6). ✓
**Placeholder scan:** every component + adapter shown in full; tests have concrete assertions; the two soft spots (delete-dialog portal DOM in T1; `id` narrowing in T4) name the exact mitigation/precedent. No TBD. ✓
**Type/consistency:** `RecordLike = { id; labels: LabelView[]; external_uri }` (T1) is the row's `record`; `TermView`/`AuthorityView` structurally satisfy it (both have those fields). `onSave(labels: LabelInput[], uri: string | null, done)` (T1) matches the adapters' `update.mutate({…, external_uri: uri, labels}, { onSuccess: done })` (T4). `LabelledRecordCreateForm.onCreate(labels, uri, reset)` (T2) matches `create.mutate({…}, { onSuccess: reset })` (T4). `FilteredRecordList<T extends { id; labels: LabelView[] }>` (T3) consumed with `authorities`/`terms` (T4). ✓
## Notes
- No new dependency, no new i18n keys, `components/ui/*` untouched. Net code reduction → `check:size` should not grow.
- `TermRow`/`AuthorityRow` keep their public props so `term-row.test.tsx` stays valid unchanged.
- `vocabulary-list.tsx` (key-based vocabularies) is deliberately NOT touched.