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

30 KiB

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:
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:
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

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

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

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):
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 />):
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:
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:
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 nullid 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):
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:
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

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.