merge: unify vocabulary + authority CRUD into shared components (#64)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,726 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Unify Vocabulary + Authority CRUD — Design
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||
**Issue:** #64 (the duplicated Vocabulary-terms + Authorities CRUD surfaces).
|
||||
|
||||
## Context
|
||||
|
||||
The Vocabulary-terms and Authorities admin surfaces are two copies of one feature ("a labelled record
|
||||
with an optional external URI: filterable list + inline-edit rows + create form"). The duplication spans
|
||||
four files and ~280 lines:
|
||||
|
||||
- `vocab/term-row.tsx` and `authorities/authority-row.tsx` are byte-for-byte twins except: the mutation
|
||||
hooks (`useUpdateTerm`/`useDeleteTerm` vs `useUpdateAuthority`/`useDeleteAuthority`), the record type
|
||||
(`TermView` vs `AuthorityView`), the URI-input id prefix (`term-uri-` / `auth-uri-`), the mutate-arg
|
||||
shape (`{ vocabularyId, termId, … }` vs `{ id, kind, … }`), and the delete-confirm i18n key.
|
||||
- `authorities/authorities-page.tsx` and `vocab/vocabulary-terms.tsx` share the filter input, the
|
||||
4-state list (loading→skeleton / error / empty / no-matches / rows), and the create form
|
||||
(`LabelEditor` + URI input + `labels.some()` validation + `MutationError` + submit). They differ in the
|
||||
i18n keys, the create-form heading, and (authorities only) the kind-tabs `<nav>` + `PageTitle` +
|
||||
`Navigate` guard.
|
||||
|
||||
A fix to inline-edit behaviour must currently be made in two places and silently drifts. (These files
|
||||
already share `LabelEditor`, `ExternalUriLink`, `DeleteConfirmDialog`, and `MutationError`.)
|
||||
|
||||
`vocabulary-list.tsx` (vocabularies are `key`-based, not labelled records) and the `objects` RHF surface
|
||||
are intentionally **not** unified — different shapes.
|
||||
|
||||
### Decisions (from brainstorming)
|
||||
Full unification — three shared components in `src/components/`, with `TermRow`/`AuthorityRow` and the
|
||||
two pages reduced to thin adapters. All variance is pushed into props; no generics on the row (the adapter
|
||||
owns the mutate-arg shape). Behavior-preserving — existing tests are the guard.
|
||||
|
||||
## Components
|
||||
|
||||
### `components/labelled-record-row.tsx` (new) — `LabelledRecordRow`
|
||||
Owns `editing` / `labels` / `uri` state. Renders the display row (`labelText` + `ExternalUriLink` + Edit
|
||||
button + `DeleteConfirmDialog`) or the edit view (`LabelEditor` + URI `<Input>` + Save/Cancel +
|
||||
`MutationError`).
|
||||
```ts
|
||||
type RecordLike = { id: string; labels: LabelView[]; external_uri: string | null };
|
||||
|
||||
function LabelledRecordRow(props: {
|
||||
record: RecordLike;
|
||||
lang: string;
|
||||
deleteConfirmKey: string; // i18n key for the confirm prompt
|
||||
savePending: boolean; // update.isPending
|
||||
saveError: unknown; // update.error
|
||||
onEditOpen: () => void; // adapter calls update.reset()
|
||||
onSave: (labels: LabelInput[], uri: string | null, done: () => void) => void;
|
||||
onDelete: () => Promise<void>;
|
||||
}): JSX.Element;
|
||||
```
|
||||
- Edit button onClick: `onEditOpen(); setLabels(record.labels as LabelInput[]); setUri(record.external_uri ?? ""); setEditing(true);` (preserves the #63 reset-on-edit-open behaviour).
|
||||
- Save: `onSave(labels, uri.trim() || null, () => setEditing(false))`; Save disabled while `savePending`;
|
||||
`<MutationError error={saveError} />` below the buttons.
|
||||
- The URI input id uses `useId()` (replaces the `term-uri-${id}` / `auth-uri-${id}` scheme).
|
||||
- `record.labels` is `LabelView[]`; cast to `LabelInput[]` for `LabelEditor` (same cast the current rows do).
|
||||
|
||||
### `components/labelled-record-create-form.tsx` (new) — `LabelledRecordCreateForm`
|
||||
Owns its own `labels` / `uri` / `requiredError` state. Renders `heading` + `LabelEditor` + URI `<Input>`
|
||||
(id via `useId()`) + the `form.required` validation alert + `<MutationError error={error} />` + submit.
|
||||
```ts
|
||||
function LabelledRecordCreateForm(props: {
|
||||
heading: ReactNode;
|
||||
submitLabel: string;
|
||||
pending: boolean; // create.isPending
|
||||
error: unknown; // create.error
|
||||
onCreate: (labels: LabelInput[], uri: string | null, reset: () => void) => void;
|
||||
}): JSX.Element;
|
||||
```
|
||||
- Submit: `if (!labels.some((l) => l.label)) { setRequiredError(true); return; } setRequiredError(false);
|
||||
onCreate(labels, uri.trim() || null, () => { setLabels([]); setUri(""); });`
|
||||
- Submit button disabled while `pending`.
|
||||
|
||||
### `components/filtered-record-list.tsx` (new) — `FilteredRecordList<T>`
|
||||
Owns the `filter` state. Renders the filter `<Input>` **always**, then (loading → `<ListSkeleton>` else the
|
||||
`<ul>`), so the filter stays visible during load (matches current behaviour).
|
||||
```ts
|
||||
function FilteredRecordList<T extends { id: string; labels: LabelView[] }>(props: {
|
||||
records: T[] | undefined;
|
||||
lang: string;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
loadErrorText: string;
|
||||
emptyText: string;
|
||||
renderRow: (record: T) => ReactNode;
|
||||
}): JSX.Element;
|
||||
```
|
||||
- `const q = filter.trim().toLowerCase(); const rows = [...(records ?? [])].filter((r) => !q ||
|
||||
labelText(r.labels, lang).toLowerCase().includes(q)).sort(byLabel(lang));`
|
||||
- List states (preserving the current logic exactly): `isError` → `loadErrorText`; `!isError &&
|
||||
records?.length === 0` → `emptyText`; `!isError && records && records.length > 0 && rows.length === 0`
|
||||
→ `t("common.noMatches")`; else `rows.map(renderRow)`.
|
||||
- Filter input uses `aria-label={t("common.filter")}` + `placeholder={t("common.filter")}` (unchanged).
|
||||
|
||||
### Adapters
|
||||
|
||||
**`vocab/term-row.tsx`** keeps its public API `<TermRow vocabularyId term lang />` (its #63 test stays
|
||||
valid). Body:
|
||||
```tsx
|
||||
const update = useUpdateTerm();
|
||||
const del = useDeleteTerm();
|
||||
return (
|
||||
<LabelledRecordRow
|
||||
record={term}
|
||||
lang={lang}
|
||||
deleteConfirmKey="actions.confirmDeleteTerm"
|
||||
savePending={update.isPending}
|
||||
saveError={update.error}
|
||||
onEditOpen={() => update.reset()}
|
||||
onSave={(labels, uri, done) =>
|
||||
update.mutate({ vocabularyId, termId: term.id, external_uri: uri, labels }, { onSuccess: done })}
|
||||
onDelete={() => del.mutateAsync({ vocabularyId, termId: term.id })}
|
||||
/>
|
||||
);
|
||||
```
|
||||
**`authorities/authority-row.tsx`** keeps `<AuthorityRow authority kind lang />`; analogous with
|
||||
`useUpdateAuthority`/`useDeleteAuthority`, `deleteConfirmKey="actions.confirmDeleteAuthority"`, save arg
|
||||
`{ id: authority.id, kind, external_uri: uri, labels }`, delete `{ id: authority.id, kind }`.
|
||||
|
||||
**`authorities/authorities-page.tsx`** keeps the `PageTitle`, kind `<nav>`, `Navigate` guard, breadcrumb,
|
||||
and `useDocumentTitle`. Replaces the inline filter/list with `<FilteredRecordList records={authorities}
|
||||
lang={lang} isLoading={isLoading} isError={isError} loadErrorText={t("authorities.loadError")}
|
||||
emptyText={t("authorities.empty")} renderRow={(a) => <AuthorityRow authority={a} kind={currentKind}
|
||||
lang={lang} />} />`, and the inline form with `<LabelledRecordCreateForm heading={`${t("authorities.new")} ·
|
||||
${t(\`authorities.${currentKind}\`)}`} submitLabel={t("authorities.create")} pending={create.isPending}
|
||||
error={create.error} onCreate={(labels, uri, reset) => create.mutate({ kind, external_uri: uri, labels },
|
||||
{ onSuccess: reset })} />`. (Drops the now-unused local `labels`/`uri`/`error`/`filter` state and the
|
||||
`onCreate` handler.)
|
||||
|
||||
**`vocab/vocabulary-terms.tsx`** keeps its `vocab.terms` caption + breadcrumb. Uses `<FilteredRecordList
|
||||
records={terms} … loadErrorText={t("vocab.loadError")} emptyText={t("vocab.noTerms")} renderRow={(term) =>
|
||||
<TermRow vocabularyId={id} term={term} lang={lang} />} />` and `<LabelledRecordCreateForm
|
||||
heading={t("vocab.addTerm")} submitLabel={t("vocab.addTerm")} pending={addTerm.isPending}
|
||||
error={addTerm.error} onCreate={(labels, uri, reset) => addTerm.mutate({ vocabularyId: id, external_uri:
|
||||
uri, labels }, { onSuccess: reset })} />`.
|
||||
|
||||
## Data flow
|
||||
No change to the query/mutation layer. Each page owns its data hooks + the page-specific chrome (nav,
|
||||
headings, breadcrumb) and hands records + render callbacks to the shared list, mutate callbacks to the
|
||||
shared create form, and per-row mutate callbacks (via the row adapters) to the shared row.
|
||||
|
||||
## Error handling / edges
|
||||
- Inline save errors and create errors still render via `<MutationError>` (status-aware, from #63),
|
||||
unchanged.
|
||||
- The `form.required` validation (empty labels) stays in the create form.
|
||||
- Reset-on-edit-open (`update.reset()`) preserved so a stale save error doesn't linger.
|
||||
- The empty-vs-no-matches distinction is computed from the **raw** `records` length (empty) vs the
|
||||
**filtered** `rows` length (no matches), exactly as today.
|
||||
- `useId()` keeps URI-input ids unique across simultaneously-mounted rows/forms.
|
||||
|
||||
## Testing
|
||||
- **Behavior guard:** existing tests stay green unchanged — `vocab/term-row.test.tsx`,
|
||||
`authorities/authorities.test.tsx` (page filter/create/list), `vocab/vocabularies.test.tsx`
|
||||
(out-of-scope list, must not break). Same public component APIs + DOM.
|
||||
- **New focused tests** for the three components:
|
||||
- `labelled-record-row.test.tsx`: display → click Edit → Save calls `onSave` and closes on `done`; a
|
||||
failed save (passing `saveError`) shows the inline error and the row stays editable; Delete invokes
|
||||
`onDelete`.
|
||||
- `labelled-record-create-form.test.tsx`: submit with empty labels shows `form.required` and does NOT
|
||||
call `onCreate`; a valid submit calls `onCreate` and the `reset` clears the inputs.
|
||||
- `filtered-record-list.test.tsx`: typing in the filter narrows the rendered rows; `records=[]` →
|
||||
`emptyText`; non-empty records + non-matching filter → `common.noMatches`; `isError` → `loadErrorText`;
|
||||
`isLoading` → skeleton.
|
||||
- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no new i18n keys; en/sv
|
||||
parity unaffected; no codename; no new dependency. `check:size` should not grow (net code reduction).
|
||||
|
||||
## Acceptance criteria
|
||||
1. `LabelledRecordRow`, `LabelledRecordCreateForm`, `FilteredRecordList<T>` exist in `src/components/` with
|
||||
the prop shapes above; `TermRow`/`AuthorityRow` and the two pages are adapters over them.
|
||||
2. `term-row.tsx` + `authority-row.tsx` no longer duplicate the row body; `authorities-page.tsx` +
|
||||
`vocabulary-terms.tsx` no longer duplicate the filter/list/create-form body.
|
||||
3. All existing tests pass unchanged; the three new components have focused tests.
|
||||
4. No behavior change: inline edit/save/delete, create + validation, filtering, the 4 list states, the
|
||||
authorities kind-nav, and breadcrumbs all work as before.
|
||||
5. `typecheck`/`lint`/`test`/`build`/`check:colors` green; `check:size` reported (not increased); no new
|
||||
i18n keys; no codename; no new dependency.
|
||||
|
||||
## Out of scope → follow-ups
|
||||
- `vocabulary-list.tsx` (key-based vocabularies) and the `objects` table/detail/RHF surface.
|
||||
- A field-definition "position"/ordering concept; server-side filtering for large vocabularies (#43).
|
||||
@@ -1,26 +1,16 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { NavLink, Navigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { components } from "../api/schema";
|
||||
import { useAuthorities, useCreateAuthority } from "../api/queries";
|
||||
import { LabelEditor } from "../components/label-editor";
|
||||
import { MutationError } from "../components/mutation-error";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FilteredRecordList } from "../components/filtered-record-list";
|
||||
import { LabelledRecordCreateForm } from "../components/labelled-record-create-form";
|
||||
import { PageTitle } from "@/components/ui/page-title";
|
||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
import { AuthorityRow } from "./authority-row";
|
||||
import { byLabel } from "../lib/sort";
|
||||
import { labelText } from "../lib/labels";
|
||||
import { focusRing } from "../lib/focus-ring";
|
||||
import { useDocumentTitle } from "../lib/use-document-title";
|
||||
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
const KINDS = ["person", "organisation", "place"] as const;
|
||||
|
||||
export function AuthoritiesPage() {
|
||||
@@ -34,36 +24,11 @@ export function AuthoritiesPage() {
|
||||
const { data: authorities, isLoading, isError } = useAuthorities(currentKind);
|
||||
const create = useCreateAuthority();
|
||||
|
||||
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||
const [error, setError] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [uri, setUri] = useState("");
|
||||
|
||||
useDocumentTitle(t("nav.authorities"));
|
||||
useBreadcrumb([{ label: t("nav.authorities") }]);
|
||||
|
||||
if (!isValidKind) return <Navigate to="/authorities/person" replace />;
|
||||
|
||||
const onCreate = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!labels.some((l) => l.label)) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(false);
|
||||
create.mutate(
|
||||
{ kind: kind as string, external_uri: uri.trim() || null, labels },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLabels([]);
|
||||
setUri("");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto p-4">
|
||||
<PageTitle className="mb-3">{t("nav.authorities")}</PageTitle>
|
||||
@@ -81,73 +46,24 @@ export function AuthoritiesPage() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
aria-label={t("common.filter")}
|
||||
placeholder={t("common.filter")}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<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} />}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<ListSkeleton className="mb-4" rows={5} />
|
||||
) : (
|
||||
(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
const rows = [...(authorities ?? [])]
|
||||
.filter((a) => !q || labelText(a.labels, lang).toLowerCase().includes(q))
|
||||
.sort(byLabel(lang));
|
||||
|
||||
return (
|
||||
<ul className="mb-4">
|
||||
{isError && (
|
||||
<li className="text-sm text-destructive">{t("authorities.loadError")}</li>
|
||||
)}
|
||||
{!isError && authorities?.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">{t("authorities.empty")}</li>
|
||||
)}
|
||||
{!isError && authorities && authorities.length > 0 && rows.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">{t("common.noMatches")}</li>
|
||||
)}
|
||||
{rows.map((a) => (
|
||||
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
|
||||
<div className="text-sm font-medium">
|
||||
{t("authorities.new")} · {t(`authorities.${currentKind}`)}
|
||||
</div>
|
||||
|
||||
<LabelEditor value={labels} onChange={setLabels} />
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="auth-create-uri">{t("labels.externalUri")}</Label>
|
||||
<Input
|
||||
id="auth-create-uri"
|
||||
type="url"
|
||||
placeholder={t("labels.uriPlaceholder")}
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.required")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MutationError error={create.error} />
|
||||
|
||||
<Button type="submit" size="sm" disabled={create.isPending}>
|
||||
{t("authorities.create")}
|
||||
</Button>
|
||||
</form>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,90 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { components } from "../api/schema";
|
||||
import { useUpdateAuthority, useDeleteAuthority } from "../api/queries";
|
||||
import { LabelEditor } from "../components/label-editor";
|
||||
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
|
||||
import { MutationError } from "../components/mutation-error";
|
||||
import { ExternalUriLink } from "../components/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";
|
||||
import { LabelledRecordRow } from "../components/labelled-record-row";
|
||||
|
||||
type AuthorityView = components["schemas"]["AuthorityView"];
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityView; kind: string; lang: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const updateAuthority = useUpdateAuthority();
|
||||
const deleteAuthority = useDeleteAuthority();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [labels, setLabels] = useState<LabelInput[]>(authority.labels as LabelInput[]);
|
||||
const [uri, setUri] = useState(authority.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={`auth-uri-${authority.id}`}>{t("labels.externalUri")}</Label>
|
||||
<Input
|
||||
id={`auth-uri-${authority.id}`}
|
||||
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={updateAuthority.isPending}
|
||||
onClick={() =>
|
||||
updateAuthority.mutate(
|
||||
{ id: authority.id, kind, external_uri: uri.trim() || null, labels },
|
||||
{ onSuccess: () => setEditing(false) },
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
<MutationError error={updateAuthority.error} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const update = useUpdateAuthority();
|
||||
const del = useDeleteAuthority();
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 border-b py-1 text-sm">
|
||||
<div className="flex-1">
|
||||
<div>{labelText(authority.labels, lang)}</div>
|
||||
{authority.external_uri && <ExternalUriLink uri={authority.external_uri} />}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateAuthority.reset();
|
||||
setLabels(authority.labels as LabelInput[]);
|
||||
setUri(authority.external_uri ?? "");
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{t("actions.edit")}
|
||||
</Button>
|
||||
<DeleteConfirmDialog
|
||||
description={t("actions.confirmDeleteAuthority")}
|
||||
onConfirm={() => deleteAuthority.mutateAsync({ id: authority.id, kind })}
|
||||
/>
|
||||
</li>
|
||||
<LabelledRecordRow
|
||||
record={{ ...authority, external_uri: authority.external_uri ?? null }}
|
||||
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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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();
|
||||
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("");
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||
const dialog = within(document.body);
|
||||
const confirmButtons = await dialog.findAllByRole("button", { name: /delete/i });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+14
-74
@@ -1,84 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { components } from "../api/schema";
|
||||
import { useUpdateTerm, useDeleteTerm } from "../api/queries";
|
||||
import { LabelEditor } from "../components/label-editor";
|
||||
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
|
||||
import { MutationError } from "../components/mutation-error";
|
||||
import { ExternalUriLink } from "../components/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";
|
||||
import { LabelledRecordRow } from "../components/labelled-record-row";
|
||||
|
||||
type TermView = components["schemas"]["TermView"];
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; term: TermView; lang: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const updateTerm = useUpdateTerm();
|
||||
const deleteTerm = useDeleteTerm();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [labels, setLabels] = useState<LabelInput[]>(term.labels as LabelInput[]);
|
||||
const [uri, setUri] = useState(term.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={`term-uri-${term.id}`}>{t("labels.externalUri")}</Label>
|
||||
<Input id={`term-uri-${term.id}`} 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={updateTerm.isPending}
|
||||
onClick={() =>
|
||||
updateTerm.mutate(
|
||||
{ vocabularyId, termId: term.id, external_uri: uri.trim() || null, labels },
|
||||
{ onSuccess: () => setEditing(false) },
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
<MutationError error={updateTerm.error} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const update = useUpdateTerm();
|
||||
const del = useDeleteTerm();
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 border-b py-1 text-sm">
|
||||
<div className="flex-1">
|
||||
<div>{labelText(term.labels, lang)}</div>
|
||||
{term.external_uri && <ExternalUriLink uri={term.external_uri} />}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateTerm.reset();
|
||||
setLabels(term.labels as LabelInput[]);
|
||||
setUri(term.external_uri ?? "");
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{t("actions.edit")}
|
||||
</Button>
|
||||
<DeleteConfirmDialog
|
||||
description={t("actions.confirmDeleteTerm")}
|
||||
onConfirm={() => deleteTerm.mutateAsync({ vocabularyId, termId: term.id })}
|
||||
/>
|
||||
</li>
|
||||
<LabelledRecordRow
|
||||
record={{ ...term, external_uri: term.external_uri ?? null }}
|
||||
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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,20 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { components } from "../api/schema";
|
||||
import { useTerms, useAddTerm, useVocabularies } from "../api/queries";
|
||||
import { byLabel } from "../lib/sort";
|
||||
import { labelText } from "../lib/labels";
|
||||
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
||||
import { LabelEditor } from "../components/label-editor";
|
||||
import { MutationError } from "../components/mutation-error";
|
||||
import { FilteredRecordList } from "../components/filtered-record-list";
|
||||
import { LabelledRecordCreateForm } from "../components/labelled-record-create-form";
|
||||
import { TermRow } from "./term-row";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ListSkeleton } from "@/components/ui/skeletons";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
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 [labels, setLabels] = useState<LabelInput[]>([]);
|
||||
|
||||
const [uri, setUri] = useState("");
|
||||
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const { data: vocabularies } = useVocabularies();
|
||||
const vocabKey = vocabularies?.find((v) => v.id === id)?.key;
|
||||
|
||||
@@ -47,81 +26,28 @@ export function VocabularyTerms() {
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
const onAdd = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!labels.some((l) => l.label)) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(false);
|
||||
|
||||
addTerm.mutate(
|
||||
{ vocabularyId: id, external_uri: uri.trim() || null, labels },
|
||||
{ onSuccess: () => { setLabels([]); setUri(""); } },
|
||||
);
|
||||
};
|
||||
|
||||
const q = filter.trim().toLowerCase();
|
||||
const rows = [...(terms ?? [])]
|
||||
.filter((term) => !q || labelText(term.labels, lang).toLowerCase().includes(q))
|
||||
.sort(byLabel(lang));
|
||||
|
||||
return (
|
||||
<div className="overflow-auto p-4">
|
||||
<div className="mb-2 label-caption">
|
||||
{t("vocab.terms")}
|
||||
</div>
|
||||
<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">{t("vocab.loadError")}</li>
|
||||
)}
|
||||
{!isError && terms?.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">{t("vocab.noTerms")}</li>
|
||||
)}
|
||||
{!isError && terms && terms.length > 0 && rows.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground">{t("common.noMatches")}</li>
|
||||
)}
|
||||
{rows.map((term) => (
|
||||
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={onAdd} className="space-y-2 border-t pt-3">
|
||||
<div className="text-sm font-medium">{t("vocab.addTerm")}</div>
|
||||
<LabelEditor value={labels} onChange={setLabels} />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="term-uri">{t("labels.externalUri")}</Label>
|
||||
<Input
|
||||
id="term-uri"
|
||||
type="url"
|
||||
placeholder={t("labels.uriPlaceholder")}
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("form.required")}
|
||||
</p>
|
||||
)}
|
||||
<MutationError error={addTerm.error} />
|
||||
<Button type="submit" size="sm" disabled={addTerm.isPending}>
|
||||
{t("vocab.addTerm")}
|
||||
</Button>
|
||||
</form>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user