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) => ( + {t("vocab.selectPrompt")} + + ); +} diff --git a/web/src/vocab/vocabularies-page.tsx b/web/src/vocab/vocabularies-page.tsx new file mode 100644 index 0000000..ac24fcc --- /dev/null +++ b/web/src/vocab/vocabularies-page.tsx @@ -0,0 +1,16 @@ +import { Outlet } from "react-router-dom"; + +import { VocabularyList } from "./vocabulary-list"; + +export function VocabulariesPage() { + return ( + + + + + + + + + ); +} diff --git a/web/src/vocab/vocabularies.test.tsx b/web/src/vocab/vocabularies.test.tsx new file mode 100644 index 0000000..15eb2d6 --- /dev/null +++ b/web/src/vocab/vocabularies.test.tsx @@ -0,0 +1,53 @@ +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 { SelectVocabularyPrompt } from "./select-vocabulary-prompt"; + +function tree() { + return ( + + }> + } /> + } /> + + + ); +} + +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" }); + 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"), + ); +}); diff --git a/web/src/vocab/vocabulary-list.tsx b/web/src/vocab/vocabulary-list.tsx new file mode 100644 index 0000000..5d63f90 --- /dev/null +++ b/web/src/vocab/vocabulary-list.tsx @@ -0,0 +1,67 @@ +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 ( + + + {t("vocab.key")} + + setKey(e.target.value)} + /> + + {t("vocab.create")} + + + + + {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")} + + + {t("labels.externalUri")} + setUri(e.target.value)} + /> + + {error && ( + + {t("form.required")} + + )} + + {t("vocab.addTerm")} + + + + ); +}
+ {t("form.required")} +