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) => (