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/app.tsx b/web/src/app.tsx index 5508f09..e882e83 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -7,6 +7,10 @@ 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"; +import { AuthoritiesPage } from "./authorities/authorities-page"; const ObjectNewPage = lazy(() => import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })), @@ -47,6 +51,12 @@ 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..06d03f2 --- /dev/null +++ b/web/src/authorities/authorities-page.tsx @@ -0,0 +1,97 @@ +import { useState, type FormEvent } from "react"; +import { NavLink, Navigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { useAuthorities, useCreateAuthority } from "../api/queries"; +import { LabelEditor } from "../components/label-editor"; +import { Button } from "@/components/ui/button"; +import { labelText } from "../lib/labels"; + +type LabelInput = components["schemas"]["LabelInput"]; + +const KINDS = ["person", "organisation", "place"] as const; + +export function AuthoritiesPage() { + const { t, i18n } = useTranslation(); + const { kind } = useParams(); + const lang = i18n.language.startsWith("sv") ? "sv" : "en"; + + const isValidKind = (KINDS as readonly string[]).includes(kind ?? ""); + + const { data: authorities } = useAuthorities(isValidKind ? (kind as string) : "person"); + const create = useCreateAuthority(); + + const [labels, setLabels] = useState([]); + const [error, setError] = useState(false); + + if (!isValidKind) return ; + + const onCreate = (event: FormEvent) => { + event.preventDefault(); + + if (!labels.some((l) => l.lang === "en" && l.label)) { + setError(true); + return; + } + + setError(false); + create.mutate( + { kind: kind as string, 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..58e49c3 --- /dev/null +++ b/web/src/authorities/authorities.test.tsx @@ -0,0 +1,57 @@ +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"); +}); + +test("create without EN label shows required alert and does not POST", async () => { + let posted = false; + server.use( + http.post("/api/admin/authorities", () => { + posted = true; + return HttpResponse.json({ id: "a-x" }, { status: 201 }); + }), + ); + renderApp(tree(), { route: "/authorities/person" }); + expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /create/i })); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(posted).toBe(false); +}); + +test("unknown kind redirects to person list", async () => { + renderApp(tree(), { route: "/authorities/banana" }); + expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); +}); diff --git a/web/src/components/label-editor.test.tsx b/web/src/components/label-editor.test.tsx new file mode 100644 index 0000000..35bf81c --- /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[seen.length - 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..1c07bc7 --- /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.trim() ? [...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..eb1d289 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -7,6 +7,17 @@ "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)" }, + "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" + }, + "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 93e15b8..56e4f7e 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -7,6 +7,17 @@ "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)" }, + "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" + }, + "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/lib/labels.ts b/web/src/lib/labels.ts new file mode 100644 index 0000000..6485cd5 --- /dev/null +++ b/web/src/lib/labels.ts @@ -0,0 +1,12 @@ +import type { components } from "../api/schema"; + +type LabelView = components["schemas"]["LabelView"]; + +export 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 ?? + "" + ); +} diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 2ff6c69..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 FUTURE = ["vocabularies", "authorities", "fields", "search"] as const; +const DISABLED_NAV = ["fields", "search"] as const; export function AppShell() { const { t } = useTranslation(); @@ -30,7 +30,23 @@ export function AppShell() { > {t("nav.objects")} - {FUTURE.map((key) => ( + + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}` + } + > + {t("nav.vocabularies")} + + + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}` + } + > + {t("nav.authorities")} + + {DISABLED_NAV.map((key) => ( + + {create.isError && ( +

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

+ )} + +
    + {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..f6cb86f --- /dev/null +++ b/web/src/vocab/vocabulary-terms.tsx @@ -0,0 +1,92 @@ +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"; +import { labelText } from "../lib/labels"; + +type LabelInput = components["schemas"]["LabelInput"]; + +export function VocabularyTerms() { + const { t, i18n } = useTranslation(); + + const { id } = useParams(); + + const lang = i18n.language.startsWith("sv") ? "sv" : "en"; + + const { data: terms } = useTerms(id); + + const addTerm = useAddTerm(); + + const [labels, setLabels] = useState([]); + + const [uri, setUri] = useState(""); + + const [error, setError] = useState(false); + + if (!id) return null; + + 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")} +

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

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

+ )} + + +
+ ); +}