From 6afc3583342b072b324ea96d5bc5efe4a42e2c18 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 09:08:15 +0200 Subject: [PATCH 1/7] feat(web): vocabulary/term/authority list+create hooks + MSW handlers Co-Authored-By: Claude Sonnet 4.6 --- web/src/api/queries.ts | 81 ++++++++++++++++++++++++++++++ web/src/api/queries.vocab.test.tsx | 50 ++++++++++++++++++ web/src/test/fixtures.ts | 7 +++ web/src/test/handlers.ts | 16 +++++- 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 web/src/api/queries.vocab.test.tsx diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index b777b8a..a23651c 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -200,6 +200,87 @@ export function useDeleteObject() { }); } +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: (_result, { 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: (_result, { kind }) => + qc.invalidateQueries({ queryKey: ["authorities", kind] }), + }); +} + type Visibility = "draft" | "internal" | "public"; /** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */ diff --git a/web/src/api/queries.vocab.test.tsx b/web/src/api/queries.vocab.test.tsx new file mode 100644 index 0000000..3d7299b --- /dev/null +++ b/web/src/api/queries.vocab.test.tsx @@ -0,0 +1,50 @@ +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 {children}; +} + +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"); + }); +}); diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts index d6a650f..a53fdad 100644 --- a/web/src/test/fixtures.ts +++ b/web/src/test/fixtures.ts @@ -62,3 +62,10 @@ export const materialTerms: TermView[] = [ export const personAuthorities: AuthorityView[] = [ { id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] }, ]; + +export type VocabularyView = components["schemas"]["VocabularyView"]; + +export const vocabularies: VocabularyView[] = [ + { id: "v-material", key: "material" }, + { id: "v-technique", key: "technique" }, +]; diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts index dce24de..db0599f 100644 --- a/web/src/test/handlers.ts +++ b/web/src/test/handlers.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; -import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities } from "./fixtures"; +import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, vocabularies } from "./fixtures"; export const handlers = [ http.get("/api/admin/me", () => @@ -17,6 +17,8 @@ export const handlers = [ http.get("/api/admin/field-definitions", () => HttpResponse.json(fieldDefinitions)), + http.get("/api/admin/vocabularies", () => HttpResponse.json(vocabularies)), + http.get("/api/admin/vocabularies/:id/terms", () => HttpResponse.json(materialTerms)), http.get("/api/admin/authorities", ({ request }) => { @@ -25,6 +27,18 @@ export const handlers = [ return HttpResponse.json(kind === "person" ? personAuthorities : []); }), + 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 }), + ), + http.post("/api/admin/objects", () => HttpResponse.json({ id: "11111111-1111-1111-1111-111111111111" }, { status: 201 }), ), From 8d2323ed954e51e3c43e0786ffed5b81838c12e8 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 09:14:16 +0200 Subject: [PATCH 2/7] feat(web): shared sv/en LabelEditor Co-Authored-By: Claude Sonnet 4.6 --- web/src/components/label-editor.test.tsx | 37 +++++++++++++++++++ web/src/components/label-editor.tsx | 47 ++++++++++++++++++++++++ web/src/i18n/en.json | 1 + web/src/i18n/sv.json | 1 + web/tsconfig.app.json | 4 +- 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 web/src/components/label-editor.test.tsx create mode 100644 web/src/components/label-editor.tsx diff --git a/web/src/components/label-editor.test.tsx b/web/src/components/label-editor.test.tsx new file mode 100644 index 0000000..47d22c9 --- /dev/null +++ b/web/src/components/label-editor.test.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import { expect, test } 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 }) { + const [value, setValue] = useState([]); + return ( + { + setValue(v); + onChange(v); + }} + /> + ); +} + +test("typing EN and SV emits both labels; empty langs are omitted", async () => { + const seen: LabelInput[][] = []; + renderApp( 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" }, + ]), + ); + expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true); +}); diff --git a/web/src/components/label-editor.tsx b/web/src/components/label-editor.tsx new file mode 100644 index 0000000..3636382 --- /dev/null +++ b/web/src/components/label-editor.tsx @@ -0,0 +1,47 @@ +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 ( +
+
+ + set("en", e.target.value)} + /> +
+
+ + set("sv", e.target.value)} + /> +
+
+ ); +} diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 21f404a..fed225e 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -7,6 +7,7 @@ "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "flexibleHeading": "Catalogue fields" }, "actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }, + "labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" }, "publish": { "heading": "Visibility", "advanceInternal": "Advance to internal", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 93e15b8..87427be 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -7,6 +7,7 @@ "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "flexibleHeading": "Katalogfält" }, "actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }, + "labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" }, "publish": { "heading": "Synlighet", "advanceInternal": "Gör intern", diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index cc58243..97295a4 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -1,9 +1,9 @@ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", + "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, From e8d173a18f7eb773f87c9beb3e43beb5ea9b92d0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 09:19:27 +0200 Subject: [PATCH 3/7] refactor(web): LabelEditor ignores blank labels; revert gratuitous tsconfig ES2022 bump Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/components/label-editor.test.tsx | 2 +- web/src/components/label-editor.tsx | 2 +- web/tsconfig.app.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/components/label-editor.test.tsx b/web/src/components/label-editor.test.tsx index 47d22c9..35bf81c 100644 --- a/web/src/components/label-editor.test.tsx +++ b/web/src/components/label-editor.test.tsx @@ -26,7 +26,7 @@ test("typing EN and SV emits both labels; empty langs are omitted", async () => renderApp( 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)!; + const last = seen[seen.length - 1]!; expect(last).toEqual( expect.arrayContaining([ { lang: "en", label: "Bronze" }, diff --git a/web/src/components/label-editor.tsx b/web/src/components/label-editor.tsx index 3636382..1c07bc7 100644 --- a/web/src/components/label-editor.tsx +++ b/web/src/components/label-editor.tsx @@ -21,7 +21,7 @@ export function LabelEditor({ const set = (lang: string, label: string) => { const others = value.filter((l) => l.lang !== lang); - onChange(label ? [...others, { lang, label }] : others); + onChange(label.trim() ? [...others, { lang, label }] : others); }; return ( diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index 97295a4..cc58243 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -1,9 +1,9 @@ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", + "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, From ac30eadbb2d786b5f17a14e4b4820a753e58fca6 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 09:22:38 +0200 Subject: [PATCH 4/7] feat(web): vocabularies two-pane screen (list/create + terms/add) + nav Co-Authored-By: Claude Sonnet 4.6 --- web/src/app.tsx | 7 ++ web/src/i18n/en.json | 6 ++ web/src/i18n/sv.json | 6 ++ web/src/shell/app-shell.tsx | 12 ++- web/src/vocab/select-vocabulary-prompt.tsx | 11 +++ web/src/vocab/vocabularies-page.tsx | 16 ++++ web/src/vocab/vocabularies.test.tsx | 53 ++++++++++++ web/src/vocab/vocabulary-list.tsx | 67 +++++++++++++++ web/src/vocab/vocabulary-terms.tsx | 94 ++++++++++++++++++++++ 9 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 web/src/vocab/select-vocabulary-prompt.tsx create mode 100644 web/src/vocab/vocabularies-page.tsx create mode 100644 web/src/vocab/vocabularies.test.tsx create mode 100644 web/src/vocab/vocabulary-list.tsx create mode 100644 web/src/vocab/vocabulary-terms.tsx diff --git a/web/src/app.tsx b/web/src/app.tsx index 5508f09..e665902 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -7,6 +7,9 @@ import { AppShell } from "./shell/app-shell"; import { ObjectsPage } from "./objects/objects-page"; import { ObjectDetail } from "./objects/object-detail"; import { SelectPrompt } from "./objects/select-prompt"; +import { VocabulariesPage } from "./vocab/vocabularies-page"; +import { VocabularyTerms } from "./vocab/vocabulary-terms"; +import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt"; const ObjectNewPage = lazy(() => import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })), @@ -47,6 +50,10 @@ export function App() { } /> + }> + } /> + } /> + } /> diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index fed225e..d0df41c 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -8,6 +8,12 @@ "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "flexibleHeading": "Catalogue fields" }, "actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }, "labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" }, + "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" + }, "publish": { "heading": "Visibility", "advanceInternal": "Advance to internal", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 87427be..61fba36 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -8,6 +8,12 @@ "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "flexibleHeading": "Katalogfält" }, "actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }, "labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" }, + "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" + }, "publish": { "heading": "Synlighet", "advanceInternal": "Gör intern", diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 2ff6c69..68c0286 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -5,7 +5,7 @@ import { useLogout } from "../api/queries"; import { Button } from "@/components/ui/button"; import { LangSwitch } from "./lang-switch"; -const FUTURE = ["vocabularies", "authorities", "fields", "search"] as const; +const DISABLED_NAV = ["authorities", "fields", "search"] as const; export function AppShell() { const { t } = useTranslation(); @@ -30,7 +30,15 @@ export function AppShell() { > {t("nav.objects")} - {FUTURE.map((key) => ( + + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}` + } + > + {t("nav.vocabularies")} + + {DISABLED_NAV.map((key) => ( + + +
    + {isLoading && ( +
  • + )} + {isError && ( +
  • {t("vocab.loadError")}
  • + )} + {data?.length === 0 && ( +
  • {t("vocab.empty")}
  • + )} + {data?.map((v) => ( +
  • + + `block border-b px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}` + } + > + {v.key} + +
  • + ))} +
+ + ); +} diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx new file mode 100644 index 0000000..b2f1721 --- /dev/null +++ b/web/src/vocab/vocabulary-terms.tsx @@ -0,0 +1,94 @@ +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([]); + + 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 ( +
+

+ {t("vocab.terms")} +

+
    + {terms?.length === 0 && ( +
  • {t("vocab.noTerms")}
  • + )} + {terms?.map((term) => ( +
  • + {labelText(term.labels, lang)} +
  • + ))} +
+
+
{t("vocab.addTerm")}
+ +
+ + setUri(e.target.value)} + /> +
+ {error && ( +

+ {t("form.required")} +

+ )} + + +
+ ); +} From 02e4f34a1b571b4b2015190480ca7875739d0d7f Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 09:29:51 +0200 Subject: [PATCH 5/7] fix(web): vocab form-level error states, newVocabulary heading, id guard, EN-required test --- web/src/vocab/vocabularies.test.tsx | 15 +++++++++++++++ web/src/vocab/vocabulary-list.tsx | 6 ++++++ web/src/vocab/vocabulary-terms.tsx | 9 ++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/web/src/vocab/vocabularies.test.tsx b/web/src/vocab/vocabularies.test.tsx index 15eb2d6..9e7b30a 100644 --- a/web/src/vocab/vocabularies.test.tsx +++ b/web/src/vocab/vocabularies.test.tsx @@ -51,3 +51,18 @@ test("selecting a vocabulary shows its terms and adds one", async () => { expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"), ); }); + +test("add term without EN label shows required alert and does not POST", async () => { + let posted = false; + server.use( + http.post("/api/admin/vocabularies/:id/terms", () => { + posted = true; + return HttpResponse.json({ id: "t-x" }, { status: 201 }); + }), + ); + renderApp(tree(), { route: "/vocabularies/v-material" }); + expect(await screen.findByText("Bronze")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /add term/i })); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(posted).toBe(false); +}); diff --git a/web/src/vocab/vocabulary-list.tsx b/web/src/vocab/vocabulary-list.tsx index 5d63f90..41e07d5 100644 --- a/web/src/vocab/vocabulary-list.tsx +++ b/web/src/vocab/vocabulary-list.tsx @@ -27,6 +27,7 @@ export function VocabularyList() { return (
+
{t("vocab.newVocabulary")}
+ {create.isError && ( +

+ {t("form.rejected")} +

+ )}
    {isLoading && ( diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx index b2f1721..7c9d95b 100644 --- a/web/src/vocab/vocabulary-terms.tsx +++ b/web/src/vocab/vocabulary-terms.tsx @@ -38,6 +38,8 @@ export function VocabularyTerms() { const [error, setError] = useState(false); + if (!id) return null; + const onAdd = (event: FormEvent) => { event.preventDefault(); @@ -49,7 +51,7 @@ export function VocabularyTerms() { setError(false); addTerm.mutate( - { vocabularyId: id!, external_uri: uri.trim() || null, labels }, + { vocabularyId: id, external_uri: uri.trim() || null, labels }, { onSuccess: () => { setLabels([]); setUri(""); } }, ); }; @@ -85,6 +87,11 @@ export function VocabularyTerms() { {t("form.required")}

    )} + {addTerm.isError && ( +

    + {t("form.rejected")} +

    + )} From 38673e52ba5e4c687b30d1cd4fa51cdf6ea963f4 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 09:32:57 +0200 Subject: [PATCH 6/7] feat(web): authorities kind-tabbed screen (list/create) + nav Co-Authored-By: Claude Sonnet 4.6 --- web/src/app.tsx | 3 + web/src/authorities/authorities-page.tsx | 102 +++++++++++++++++++++++ web/src/authorities/authorities.test.tsx | 37 ++++++++ web/src/i18n/en.json | 4 + web/src/i18n/sv.json | 4 + web/src/shell/app-shell.tsx | 10 ++- 6 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 web/src/authorities/authorities-page.tsx create mode 100644 web/src/authorities/authorities.test.tsx diff --git a/web/src/app.tsx b/web/src/app.tsx index e665902..e882e83 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -10,6 +10,7 @@ import { SelectPrompt } from "./objects/select-prompt"; import { VocabulariesPage } from "./vocab/vocabularies-page"; import { VocabularyTerms } from "./vocab/vocabulary-terms"; import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt"; +import { AuthoritiesPage } from "./authorities/authorities-page"; const ObjectNewPage = lazy(() => import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })), @@ -54,6 +55,8 @@ export function App() { } /> } /> + } /> + } /> } /> diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx new file mode 100644 index 0000000..d9b5d05 --- /dev/null +++ b/web/src/authorities/authorities-page.tsx @@ -0,0 +1,102 @@ +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([]); + 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 ( +
    +
    + {KINDS.map((k) => ( + + `rounded px-3 py-1 text-sm ${isActive ? "bg-neutral-800 text-white" : "border"}` + } + > + {t(`authorities.${k}`)} + + ))} +
    + +
      + {authorities?.length === 0 && ( +
    • {t("authorities.empty")}
    • + )} + {authorities?.map((a) => ( +
    • + {labelText(a.labels, lang)} +
    • + ))} +
    + +
    +
    + {t("authorities.new")} · {t(`authorities.${kind}`)} +
    + + + + {error && ( +

    + {t("form.required")} +

    + )} + + {create.isError && ( +

    + {t("form.rejected")} +

    + )} + + + +
    + ); +} diff --git a/web/src/authorities/authorities.test.tsx b/web/src/authorities/authorities.test.tsx new file mode 100644 index 0000000..d9b45d0 --- /dev/null +++ b/web/src/authorities/authorities.test.tsx @@ -0,0 +1,37 @@ +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 ( + + } /> + + ); +} + +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" }); + 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"); +}); diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index d0df41c..eb1d289 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -14,6 +14,10 @@ "terms": "Terms", "addTerm": "Add term", "empty": "No vocabularies yet", "noTerms": "No terms yet", "loadError": "Could not load" }, + "authorities": { + "title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place", + "new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load" + }, "publish": { "heading": "Visibility", "advanceInternal": "Advance to internal", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 61fba36..56e4f7e 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -14,6 +14,10 @@ "terms": "Termer", "addTerm": "Lägg till term", "empty": "Inga vokabulärer ännu", "noTerms": "Inga termer ännu", "loadError": "Kunde inte ladda" }, + "authorities": { + "title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats", + "new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda" + }, "publish": { "heading": "Synlighet", "advanceInternal": "Gör intern", diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 68c0286..1ab6a0c 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -5,7 +5,7 @@ import { useLogout } from "../api/queries"; import { Button } from "@/components/ui/button"; import { LangSwitch } from "./lang-switch"; -const DISABLED_NAV = ["authorities", "fields", "search"] as const; +const DISABLED_NAV = ["fields", "search"] as const; export function AppShell() { const { t } = useTranslation(); @@ -38,6 +38,14 @@ export function AppShell() { > {t("nav.vocabularies")} + + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}` + } + > + {t("nav.authorities")} + {DISABLED_NAV.map((key) => (