docs(plan): frontend SPA milestone 4 — task-by-task implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,727 @@
|
|||||||
|
# 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 master–detail (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 (M1–M3 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 1–2 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 2–4 + Task 5 check. ✓
|
||||||
|
- Testing Vitest+RTL+MSW → Tasks 1–4. ✓
|
||||||
|
- 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 3–4; `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 3–4 + 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).
|
||||||
Reference in New Issue
Block a user