727 lines
30 KiB
Markdown
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.
|