Files
biggus-dickus/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-4.md
2026-06-04 09:05:10 +02:00

728 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Frontend SPA — Milestone 4 (Vocabulary & Authority Management) 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:** Enable the Vocabularies and Authorities admin screens — create/list controlled vocabularies (+ their terms) and authority records (by kind) — with a shared sv/en label editor.
**Architecture:** Two new screens under the app shell (the previously-disabled nav stubs become active). Vocabularies is a two-pane masterdetail (vocab list + create on the left; the selected vocab's terms + add-term on the right) via nested routes like Objects. Authorities is a kind-tabbed list + create at `/authorities/:kind`. A shared controlled `LabelEditor` (sv/en) produces `LabelInput[]`. Four new TanStack Query hooks (one list query + three create mutations) consume the existing admin endpoints; create mutations invalidate the matching list query keys. Create-only (the backend exposes no update/delete for these). Lean forms use local `useState` + inline validation (EN label / vocab key required).
**Tech Stack:** React 19, react-router-dom 7, @tanstack/react-query 5, openapi-fetch typed client, react-i18next, Vitest + RTL + MSW. (No new deps.)
**Reference spec:** `docs/superpowers/specs/2026-06-04-frontend-spa-milestone-4-design.md`
**Baseline (M1M3 merged @ `684b544`):** `web/src/api/queries.ts` has `useTerms(vocabularyId)` (key `["terms",vocabularyId]`) + `useAuthorities(kind)` (key `["authorities",kind]`) plus the object/visibility hooks and the `api` client; nested-route two-pane pattern in `web/src/objects/{objects-page,object-detail}.tsx` + `web/src/objects/select-prompt.tsx`; `web/src/shell/app-shell.tsx` renders Objects as a `NavLink` and `["vocabularies","authorities","fields","search"]` as **disabled** buttons; `renderApp` helper (MemoryRouter + QueryClient); MSW harness (`web/src/test/{server,handlers,fixtures}.ts`, `onUnhandledRequest:"error"`); i18n `web/src/i18n/{en,sv}.json` with `nav.*`, `form.cancel`, `form.rejected`, `visibility.*`. shadcn Button/Input/Label. 45 tests green, ~141 KB gz. Run web commands from `web/`.
**Conventions:** i18n every user-facing string via `t()`, en/sv key parity; NO `any`/`eslint-disable`/`@ts-ignore`; codename "biggus"/"dickus" NOWHERE; each task ends green (`pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`).
**Backend contract (verify against `web/src/api/schema.d.ts`):**
- `GET /api/admin/vocabularies``VocabularyView[]` (`{id,key}`); `POST` body `NewVocabularyRequest {key}``201 VocabularyView`.
- `GET /api/admin/vocabularies/{id}/terms``TermView[]`; `POST` body `NewTermRequest {external_uri?,labels}``201 CreatedId`.
- `GET /api/admin/authorities?kind=``AuthorityView[]`; `POST` body `NewAuthorityRequest {kind,external_uri?,labels}``201 CreatedId`.
- `LabelInput`/`LabelView` = `{lang,label}`.
---
## Task 1: Data layer — list + 3 create hooks + MSW handlers + fixture
**Files:**
- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts`, `web/src/test/fixtures.ts`
- Test: `web/src/api/queries.vocab.test.tsx`
- [ ] **Step 1: Add a vocabularies fixture** — append to `web/src/test/fixtures.ts`:
```ts
import type { components } from "../api/schema";
export type VocabularyView = components["schemas"]["VocabularyView"];
export const vocabularies: VocabularyView[] = [
{ id: "v-material", key: "material" },
{ id: "v-technique", key: "technique" },
];
```
(`materialTerms` and `personAuthorities` already exist from M2.)
- [ ] **Step 2: Add the MSW handlers** — in `web/src/test/handlers.ts`, add a GET for the vocabularies list and POST handlers (the GET terms/authorities handlers already exist from M2; do NOT duplicate them). Add:
```ts
import { vocabularies } from "./fixtures";
// in the handlers array:
http.get("/api/admin/vocabularies", () => HttpResponse.json(vocabularies)),
http.post("/api/admin/vocabularies", () =>
HttpResponse.json({ id: "v-new", key: "new" }, { status: 201 })),
http.post("/api/admin/vocabularies/:id/terms", () =>
HttpResponse.json({ id: "t-new" }, { status: 201 })),
http.post("/api/admin/authorities", () =>
HttpResponse.json({ id: "a-new" }, { status: 201 })),
```
- [ ] **Step 3: Write the failing hook test** `web/src/api/queries.vocab.test.tsx`
```tsx
import { describe, expect, test } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "../test/server";
import { useVocabularies, useCreateVocabulary, useAddTerm, useCreateAuthority } from "./queries";
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe("vocab/authority hooks", () => {
test("useVocabularies lists vocabularies", async () => {
const { result } = renderHook(() => useVocabularies(), { wrapper });
await waitFor(() => expect(result.current.data?.length).toBe(2));
expect(result.current.data?.[0].key).toBe("material");
});
test("useCreateVocabulary POSTs the key", async () => {
let body: unknown;
server.use(http.post("/api/admin/vocabularies", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "v-x", key: "colour" }, { status: 201 });
}));
const { result } = renderHook(() => useCreateVocabulary(), { wrapper });
await result.current.mutateAsync({ key: "colour" });
expect((body as { key: string }).key).toBe("colour");
});
test("useAddTerm POSTs labels to the vocabulary", async () => {
let body: unknown;
server.use(http.post("/api/admin/vocabularies/:id/terms", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "t-x" }, { status: 201 });
}));
const { result } = renderHook(() => useAddTerm(), { wrapper });
await result.current.mutateAsync({
vocabularyId: "v-material", external_uri: null,
labels: [{ lang: "en", label: "Red" }],
});
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Red");
});
test("useCreateAuthority POSTs kind + labels", async () => {
let body: unknown;
server.use(http.post("/api/admin/authorities", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "a-x" }, { status: 201 });
}));
const { result } = renderHook(() => useCreateAuthority(), { wrapper });
await result.current.mutateAsync({
kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada" }],
});
expect((body as { kind: string }).kind).toBe("person");
});
});
```
- [ ] **Step 4: Run to verify it fails**`pnpm test src/api/queries.vocab.test.tsx` → FAIL (hooks missing).
- [ ] **Step 5: Implement the hooks** — append to `web/src/api/queries.ts`:
```ts
type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"];
type LabelInput = components["schemas"]["LabelInput"];
export function useVocabularies() {
return useQuery({
queryKey: ["vocabularies"],
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/vocabularies");
if (error || !data) throw new Error("failed to load vocabularies");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useCreateVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: NewVocabularyRequest) => {
const { data, error } = await api.POST("/api/admin/vocabularies", { body });
if (error || !data) throw new Error("create vocabulary failed");
return data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
});
}
export function useAddTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ vocabularyId, external_uri, labels }: {
vocabularyId: string; external_uri: string | null; labels: LabelInput[];
}) => {
const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", {
params: { path: { id: vocabularyId } },
body: { external_uri, labels },
});
if (response.status !== 201) throw new Error("add term failed");
},
onSuccess: (_r, { vocabularyId }) =>
qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
});
}
export function useCreateAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ kind, external_uri, labels }: {
kind: string; external_uri: string | null; labels: LabelInput[];
}) => {
const { response } = await api.POST("/api/admin/authorities", {
body: { kind, external_uri, labels },
});
if (response.status !== 201) throw new Error("create authority failed");
},
onSuccess: (_r, { kind }) =>
qc.invalidateQueries({ queryKey: ["authorities", kind] }),
});
}
```
(Verify path keys + body types against `schema.d.ts`. `useQuery`/`useMutation`/`useQueryClient`/`api`/`components` are already imported. The `["terms",vocabularyId]`/`["authorities",kind]` keys MUST match the existing `useTerms`/`useAuthorities` keys so invalidation refetches — confirm by reading those two hooks. If `NewTermRequest`/`NewAuthorityRequest` require non-null `external_uri`, pass `null` is fine since they're `string | null`.)
- [ ] **Step 6: Run**`pnpm test src/api/queries.vocab.test.tsx` → PASS (4). Full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
- [ ] **Step 7: Commit**
```bash
cd ..
git add web
git commit -m "feat(web): vocabulary/term/authority list+create hooks + MSW handlers"
```
---
## Task 2: Shared `LabelEditor` (sv/en)
**Files:**
- Create: `web/src/components/label-editor.tsx`, `web/src/components/label-editor.test.tsx`
- Modify: `web/src/i18n/{en,sv}.json`
- [ ] **Step 1: i18n** — merge a `labels` namespace into `en.json`: `"labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" }`; `sv.json`: `"labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" }`. Keep parity.
- [ ] **Step 2: Write the failing test** `web/src/components/label-editor.test.tsx`
```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 { LabelEditor } from "./label-editor";
import type { components } from "../api/schema";
type LabelInput = components["schemas"]["LabelInput"];
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
return <LabelEditor value={[]} onChange={onChange} />;
}
test("typing EN and SV emits both labels; empty langs are omitted", async () => {
const seen: LabelInput[][] = [];
renderApp(<Harness onChange={(v) => seen.push(v)} />);
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze");
await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons");
const last = seen.at(-1)!;
expect(last).toEqual(
expect.arrayContaining([
{ lang: "en", label: "Bronze" },
{ lang: "sv", label: "Brons" },
]),
);
// an editor with only EN filled emits just the EN entry
expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true);
});
```
- [ ] **Step 3: Implement**`web/src/components/label-editor.tsx`
```tsx
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type LabelInput = components["schemas"]["LabelInput"];
/** Controlled sv/en label editor. Emits LabelInput[] with only the non-empty langs. */
export function LabelEditor({
value, onChange,
}: {
value: LabelInput[];
onChange: (labels: LabelInput[]) => void;
}) {
const { t } = useTranslation();
const valueFor = (lang: string) => value.find((l) => l.lang === lang)?.label ?? "";
const set = (lang: string, label: string) => {
const others = value.filter((l) => l.lang !== lang);
onChange(label ? [...others, { lang, label }] : others);
};
return (
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="label-en">{t("labels.en")}</Label>
<Input id="label-en" value={valueFor("en")} onChange={(e) => set("en", e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="label-sv">{t("labels.sv")}</Label>
<Input id="label-sv" value={valueFor("sv")} onChange={(e) => set("sv", e.target.value)} />
</div>
</div>
);
}
```
(Controlled: parent owns the `value` array. `set` replaces the entry for that lang or drops it when cleared, so empty langs never appear in the emitted array.)
- [ ] **Step 4: Run**`pnpm test src/components/label-editor.test.tsx` → PASS. Full `pnpm test`/typecheck/lint/build clean.
- [ ] **Step 5: Commit**
```bash
cd ..
git add web
git commit -m "feat(web): shared sv/en LabelEditor"
```
---
## Task 3: Vocabularies screen (two-pane) + route + nav enable
**Files:**
- Create: `web/src/vocab/vocabularies-page.tsx`, `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/vocab/vocabularies.test.tsx`
- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json`
- [ ] **Step 1: i18n** — merge a `vocab` namespace into `en.json`:
```json
"vocab": {
"title": "Vocabularies", "newVocabulary": "New vocabulary", "key": "Key",
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
"terms": "Terms", "addTerm": "Add term", "empty": "No vocabularies yet",
"noTerms": "No terms yet", "loadError": "Could not load"
}
```
`sv.json`:
```json
"vocab": {
"title": "Vokabulär", "newVocabulary": "Ny vokabulär", "key": "Nyckel",
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
"terms": "Termer", "addTerm": "Lägg till term", "empty": "Inga vokabulärer ännu",
"noTerms": "Inga termer ännu", "loadError": "Kunde inte ladda"
}
```
Keep parity.
- [ ] **Step 2: Write the failing test** `web/src/vocab/vocabularies.test.tsx`
```tsx
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Routes, Route } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { VocabulariesPage } from "./vocabularies-page";
import { VocabularyTerms } from "./vocabulary-terms";
import { SelectPrompt } from "../objects/select-prompt";
function tree() {
return (
<Routes>
<Route path="/vocabularies" element={<VocabulariesPage />}>
<Route index element={<div>pick a vocabulary</div>} />
<Route path=":id" element={<VocabularyTerms />} />
</Route>
</Routes>
);
}
test("lists vocabularies and creates one", async () => {
let body: unknown;
server.use(
http.post("/api/admin/vocabularies", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "v-c", key: "colour" }, { status: 201 });
}),
);
renderApp(tree(), { route: "/vocabularies" });
expect(await screen.findByText("material")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/key/i), "colour");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => expect((body as { key: string })?.key).toBe("colour"));
});
test("selecting a vocabulary shows its terms and adds one", async () => {
let termBody: unknown;
server.use(
http.post("/api/admin/vocabularies/:id/terms", async ({ request }) => {
termBody = await request.json();
return HttpResponse.json({ id: "t-c" }, { status: 201 });
}),
);
renderApp(tree(), { route: "/vocabularies/v-material" });
// material terms come from the default MSW handler (materialTerms: Bronze, Wood)
expect(await screen.findByText("Bronze")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Stone");
await userEvent.click(screen.getByRole("button", { name: /add term/i }));
await waitFor(() =>
expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"),
);
});
```
- [ ] **Step 3: Implement `VocabulariesPage`**`web/src/vocab/vocabularies-page.tsx`
```tsx
import { Outlet } from "react-router-dom";
import { VocabularyList } from "./vocabulary-list";
export function VocabulariesPage() {
return (
<div className="grid h-full grid-cols-[20rem_1fr]">
<div className="overflow-hidden border-r">
<VocabularyList />
</div>
<div className="overflow-hidden">
<Outlet />
</div>
</div>
);
}
```
- [ ] **Step 4: Implement `VocabularyList`**`web/src/vocab/vocabulary-list.tsx`
```tsx
import { useState, type FormEvent } from "react";
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useVocabularies, useCreateVocabulary } from "../api/queries";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function VocabularyList() {
const { t } = useTranslation();
const { data, isLoading, isError } = useVocabularies();
const create = useCreateVocabulary();
const [key, setKey] = useState("");
const onCreate = (event: FormEvent) => {
event.preventDefault();
if (!key.trim()) return;
create.mutate({ key: key.trim() }, { onSuccess: () => setKey("") });
};
return (
<div className="flex h-full flex-col">
<form onSubmit={onCreate} className="space-y-1 border-b p-3">
<Label htmlFor="vocab-key">{t("vocab.key")}</Label>
<div className="flex gap-2">
<Input id="vocab-key" value={key} onChange={(e) => setKey(e.target.value)} />
<Button type="submit" size="sm" disabled={create.isPending}>{t("vocab.create")}</Button>
</div>
</form>
<ul className="flex-1 overflow-auto">
{isLoading && <li className="p-3 text-sm text-neutral-400"></li>}
{isError && <li className="p-3 text-sm text-red-600">{t("vocab.loadError")}</li>}
{data?.length === 0 && <li className="p-3 text-sm text-neutral-500">{t("vocab.empty")}</li>}
{data?.map((v) => (
<li key={v.id}>
<NavLink to={`/vocabularies/${v.id}`}
className={({ isActive }) =>
`block border-b px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`}>
{v.key}
</NavLink>
</li>
))}
</ul>
</div>
);
}
```
- [ ] **Step 5: Implement `VocabularyTerms`**`web/src/vocab/vocabulary-terms.tsx`
```tsx
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 } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type LabelInput = components["schemas"]["LabelInput"];
type LabelView = components["schemas"]["LabelView"];
function labelText(labels: LabelView[], lang: string): string {
return labels.find((l) => l.lang === lang)?.label ?? labels.find((l) => l.lang === "en")?.label ?? labels[0]?.label ?? "";
}
export function VocabularyTerms() {
const { t, i18n } = useTranslation();
const { id } = useParams();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const { data: terms } = useTerms(id);
const addTerm = useAddTerm();
const [labels, setLabels] = useState<LabelInput[]>([]);
const [uri, setUri] = useState("");
const [error, setError] = useState(false);
const onAdd = (event: FormEvent) => {
event.preventDefault();
if (!labels.some((l) => l.lang === "en" && l.label)) { setError(true); return; }
setError(false);
addTerm.mutate(
{ vocabularyId: id!, external_uri: uri.trim() || null, labels },
{ onSuccess: () => { setLabels([]); setUri(""); } },
);
};
return (
<div className="overflow-auto p-4">
<h3 className="mb-2 text-sm font-medium uppercase text-neutral-500">{t("vocab.terms")}</h3>
<ul className="mb-4">
{terms?.length === 0 && <li className="text-sm text-neutral-500">{t("vocab.noTerms")}</li>}
{terms?.map((term) => (
<li key={term.id} className="border-b py-1 text-sm">{labelText(term.labels, lang)}</li>
))}
</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" value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
<Button type="submit" size="sm" disabled={addTerm.isPending}>{t("vocab.addTerm")}</Button>
</form>
</div>
);
}
```
(`form.required` exists from M2. The EN-required check reads the `labels` array. `useTerms(id)` reuses the existing hook + key.)
- [ ] **Step 6: Wire the route + enable the Vocabularies nav**
In `web/src/app.tsx`, add inside the protected `AppShell` group:
```tsx
<Route path="/vocabularies" element={<VocabulariesPage />}>
<Route index element={<SelectVocabularyPrompt />} />
<Route path=":id" element={<VocabularyTerms />} />
</Route>
```
For the index prompt, reuse a small prompt — either import the Objects `SelectPrompt` or add a `vocab`-specific one. Simplest: create `web/src/vocab/select-vocabulary-prompt.tsx` rendering `t("vocab.selectPrompt")` (mirror `objects/select-prompt.tsx`), import as `SelectVocabularyPrompt`. (Adjust the test's index element to match if you reference it.)
In `web/src/shell/app-shell.tsx`, change the nav so `vocabularies` is an active `NavLink` to `/vocabularies` (like the Objects link), removing it from the disabled `FUTURE` list. Keep `authorities`, `fields`, `search` disabled for now (authorities is enabled in Task 4). E.g. render Objects + Vocabularies as `NavLink`s and `["authorities","fields","search"]` as disabled buttons.
- [ ] **Step 7: Run**`pnpm test src/vocab/vocabularies.test.tsx` → PASS (2). Update the app-shell test if it asserted `vocabularies` was a disabled button (it asserted `search` is disabled — unaffected; but if it checked vocabularies specifically, update it). Full `pnpm test`, typecheck, lint, build clean.
- [ ] **Step 8: Commit**
```bash
cd ..
git add web
git commit -m "feat(web): vocabularies two-pane screen (list/create + terms/add) + nav"
```
---
## Task 4: Authorities screen (kind tabs) + route + nav enable
**Files:**
- Create: `web/src/authorities/authorities-page.tsx`, `web/src/authorities/authorities.test.tsx`
- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json`
- [ ] **Step 1: i18n** — merge an `authorities` namespace into `en.json`:
```json
"authorities": {
"title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place",
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
}
```
`sv.json`:
```json
"authorities": {
"title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats",
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
}
```
Keep parity.
- [ ] **Step 2: Write the failing test** `web/src/authorities/authorities.test.tsx`
```tsx
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Routes, Route } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { AuthoritiesPage } from "./authorities-page";
function tree() {
return (
<Routes>
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
</Routes>
);
}
test("lists authorities for the kind and creates one", async () => {
let body: unknown;
server.use(
http.post("/api/admin/authorities", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: "a-c" }, { status: 201 });
}),
);
renderApp(tree(), { route: "/authorities/person" });
// default MSW handler returns personAuthorities (Ada Lovelace) for kind=person
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Carl von Linné");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => expect((body as { kind: string })?.kind).toBe("person"));
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné");
});
test("kind tabs link to the other kinds", async () => {
renderApp(tree(), { route: "/authorities/person" });
expect(await screen.findByRole("link", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
});
```
- [ ] **Step 3: Implement `AuthoritiesPage`**`web/src/authorities/authorities-page.tsx`
```tsx
import { useState, type FormEvent } from "react";
import { NavLink, 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 { Button } from "@/components/ui/button";
type LabelInput = components["schemas"]["LabelInput"];
type LabelView = components["schemas"]["LabelView"];
const KINDS = ["person", "organisation", "place"] as const;
function labelText(labels: LabelView[], lang: string): string {
return labels.find((l) => l.lang === lang)?.label ?? labels.find((l) => l.lang === "en")?.label ?? labels[0]?.label ?? "";
}
export function AuthoritiesPage() {
const { t, i18n } = useTranslation();
const { kind = "person" } = useParams();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const { data: authorities } = useAuthorities(kind);
const create = useCreateAuthority();
const [labels, setLabels] = useState<LabelInput[]>([]);
const [error, setError] = useState(false);
const onCreate = (event: FormEvent) => {
event.preventDefault();
if (!labels.some((l) => l.lang === "en" && l.label)) { setError(true); return; }
setError(false);
create.mutate(
{ kind, external_uri: null, labels },
{ onSuccess: () => setLabels([]) },
);
};
return (
<div className="overflow-auto p-4">
<div className="mb-3 flex gap-2">
{KINDS.map((k) => (
<NavLink key={k} to={`/authorities/${k}`}
className={({ isActive }) =>
`rounded px-3 py-1 text-sm ${isActive ? "bg-neutral-800 text-white" : "border"}`}>
{t(`authorities.${k}`)}
</NavLink>
))}
</div>
<ul className="mb-4">
{authorities?.length === 0 && <li className="text-sm text-neutral-500">{t("authorities.empty")}</li>}
{authorities?.map((a) => (
<li key={a.id} className="border-b py-1 text-sm">{labelText(a.labels, lang)}</li>
))}
</ul>
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
<div className="text-sm font-medium">{t("authorities.new")} · {t(`authorities.${kind}`)}</div>
<LabelEditor value={labels} onChange={setLabels} />
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
<Button type="submit" size="sm" disabled={create.isPending}>{t("authorities.create")}</Button>
</form>
</div>
);
}
```
(`useAuthorities(kind)` reuses the existing hook + key. The kind comes from the route param. Unknown-kind validation is handled by the route redirect in Step 4.)
- [ ] **Step 4: Wire routes + enable the Authorities nav**
In `web/src/app.tsx`, add inside `AppShell`:
```tsx
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
```
(`Navigate` is already imported in app.tsx.)
In `web/src/shell/app-shell.tsx`, make `authorities` an active `NavLink` to `/authorities` (alongside Objects + Vocabularies); keep `fields` + `search` disabled.
- [ ] **Step 5: Run**`pnpm test src/authorities/authorities.test.tsx` → PASS (2). Full `pnpm test`, typecheck, lint, build clean. (Update the app-shell test if it asserted authorities was disabled.)
- [ ] **Step 6: Commit**
```bash
cd ..
git add web
git commit -m "feat(web): authorities kind-tabbed screen (list/create) + nav"
```
---
## Task 5: i18n parity + full verification
**Files:** none expected (verification); fix-ups only if a check fails.
- [ ] **Step 1: i18n parity check**
```bash
cd web
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))"
```
Expected `PARITY OK`; fix any mismatch.
- [ ] **Step 2: app-shell nav test** — confirm `web/src/shell/app-shell.test.tsx` still passes; the Vocabularies + Authorities items are now `NavLink`s (role=link) and `fields`/`search` remain disabled buttons. If the existing test asserted vocabularies/authorities were disabled, update those assertions to expect links; keep asserting `search`/`fields` disabled.
- [ ] **Step 3: Full verification**
```bash
cd web
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
```
Expected: clean; all tests pass; bundle ≤150 KB gz (report the number — the new screens are small; if it exceeds, lazy-load the vocab/authorities routes via `React.lazy` in `app.tsx` like the M2 forms, and re-verify).
- [ ] **Step 4: Commit** — only if Steps 12 required a fix:
```bash
cd ..
git add web
git commit -m "chore(web): m4 i18n parity + nav test updates"
```
---
## Self-Review (completed)
**Spec coverage:**
- Nav stubs enabled + routes → Tasks 3, 4. ✓
- Vocabularies list/create + terms list/add (two-pane) → Task 3. ✓
- Authorities kind-tabbed list/create → Task 4. ✓
- Shared sv/en `LabelEditor`, EN-required → Task 2 (+ EN-required enforced in Tasks 3, 4 forms). ✓
- 4 new hooks + invalidation of the existing `["terms",id]`/`["authorities",kind]`/`["vocabularies"]` keys → Task 1. ✓
- Create-only (no edit/delete) → respected throughout. ✓
- Error/loading/empty states → Tasks 3, 4. ✓
- i18n sv/en parity → Tasks 24 + Task 5 check. ✓
- Testing Vitest+RTL+MSW → Tasks 14. ✓
- Bundle budget → Task 5. ✓
**Placeholder scan:** none — complete code in every step; the "verify path/body types against schema.d.ts" and "reuse SelectPrompt or add a vocab prompt" notes are concrete verification/choice instructions.
**Type consistency:** `LabelInput`/`LabelView` used consistently; hooks `useVocabularies`/`useCreateVocabulary`/`useAddTerm`/`useCreateAuthority` defined in Task 1 and consumed in Tasks 34; `useAddTerm` takes `{vocabularyId, external_uri, labels}` and `useCreateAuthority` `{kind, external_uri, labels}` consistently across plan + tests; `LabelEditor` `value`/`onChange` contract consistent; invalidation keys (`["terms",vocabularyId]`, `["authorities",kind]`, `["vocabularies"]`) match the existing read hooks; routes (`/vocabularies`, `/vocabularies/:id`, `/authorities/:kind`) consistent across Tasks 34 + app.tsx.
## Notes for follow-on
- Edit/delete of vocab/term/authority needs backend endpoints — file a backend follow-up when M4 lands.
- Audit of vocab/authority creation (#21); searchable pickers (#27); enum typing (#29).