diff --git a/web/src/app.tsx b/web/src/app.tsx index 952925d..1eaa60d 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -22,6 +22,10 @@ const ObjectEditForm = lazy(() => import("./objects/object-edit-form").then((m) => ({ default: m.ObjectEditForm })), ); +const FieldsPage = lazy(() => + import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })), +); + function FormFallback() { return
Loading…
; } @@ -63,6 +67,14 @@ export function App() { } /> } /> + }> + + + } + /> } /> diff --git a/web/src/fields/field-form.tsx b/web/src/fields/field-form.tsx new file mode 100644 index 0000000..7d16bc0 --- /dev/null +++ b/web/src/fields/field-form.tsx @@ -0,0 +1,158 @@ +import { useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { useCreateFieldDefinition, useVocabularies } 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 { Checkbox } from "@/components/ui/checkbox"; + +type LabelInput = components["schemas"]["LabelInput"]; + +const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const; +const KINDS = ["person", "organisation", "place"] as const; + +export function FieldForm() { + const { t } = useTranslation(); + const create = useCreateFieldDefinition(); + const { data: vocabularies } = useVocabularies(); + + const [key, setKey] = useState(""); + const [labels, setLabels] = useState([]); + const [dataType, setDataType] = useState("text"); + const [vocabularyId, setVocabularyId] = useState(""); + const [authorityKind, setAuthorityKind] = useState(""); + const [group, setGroup] = useState(""); + const [required, setRequired] = useState(false); + const [error, setError] = useState(false); + + const reset = () => { + setKey(""); + setLabels([]); + setDataType("text"); + setVocabularyId(""); + setAuthorityKind(""); + setGroup(""); + setRequired(false); + }; + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + + const hasEn = labels.some((l) => l.lang === "en" && l.label); + const termNeedsVocab = dataType === "term" && !vocabularyId; + + if (!key.trim() || !hasEn || termNeedsVocab) { + setError(true); + return; + } + + setError(false); + create.mutate( + { + key: key.trim(), + data_type: dataType, + vocabulary_id: dataType === "term" ? vocabularyId : null, + authority_kind: dataType === "authority" ? authorityKind || null : null, + required, + group: group.trim() || null, + labels, + }, + { onSuccess: reset }, + ); + }; + + return ( +
+
{t("fields.newField")}
+ +
+ + setKey(e.target.value)} /> +
+ + + +
+ + +
+ + {dataType === "term" && ( +
+ + +
+ )} + + {dataType === "authority" && ( +
+ + +
+ )} + +
+ + setGroup(e.target.value)} /> +
+ + + + {error && ( +

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

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

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

+ )} + + + + ); +} diff --git a/web/src/fields/field-list.tsx b/web/src/fields/field-list.tsx new file mode 100644 index 0000000..6bee9d1 --- /dev/null +++ b/web/src/fields/field-list.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { useFieldDefinitions } from "../api/queries"; +import { Skeleton } from "@/components/ui/skeleton"; + +type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; + +function labelText(labels: FieldDefinitionView["labels"], lang: string): string { + return ( + labels.find((l) => l.lang === lang)?.label ?? + labels.find((l) => l.lang === "en")?.label ?? + labels[0]?.label ?? + "" + ); +} + +export function FieldList() { + const { t, i18n } = useTranslation(); + const { data, isLoading, isError } = useFieldDefinitions(); + const lang = i18n.language.startsWith("sv") ? "sv" : "en"; + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ); + } + + if (isError) return

{t("fields.loadError")}

; + if (!data || data.length === 0) + return

{t("fields.empty")}

; + + const groups = new Map(); + + for (const def of data) { + const key = def.group?.trim() ? def.group : t("fields.other"); + const bucket = groups.get(key) ?? []; + + bucket.push(def); + groups.set(key, bucket); + } + + return ( +
    + {[...groups.entries()].map(([group, defs]) => ( +
  • +
    + {group} +
    +
      + {defs.map((def) => ( +
    • + {labelText(def.labels, lang)} + {def.key} + + {t(`fields.types.${def.data_type}`)} + + {def.required && *} +
    • + ))} +
    +
  • + ))} +
+ ); +} diff --git a/web/src/fields/fields-page.tsx b/web/src/fields/fields-page.tsx new file mode 100644 index 0000000..f845bf9 --- /dev/null +++ b/web/src/fields/fields-page.tsx @@ -0,0 +1,15 @@ +import { FieldList } from "./field-list"; +import { FieldForm } from "./field-form"; + +export function FieldsPage() { + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/web/src/fields/fields.test.tsx b/web/src/fields/fields.test.tsx new file mode 100644 index 0000000..87fdceb --- /dev/null +++ b/web/src/fields/fields.test.tsx @@ -0,0 +1,72 @@ +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 { Route, Routes } from "react-router-dom"; + +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { FieldsPage } from "./fields-page"; + +function tree() { + return ( + + } /> + + ); +} + +test("lists field definitions grouped, with an Other heading for ungrouped", async () => { + renderApp(tree(), { route: "/fields" }); + expect(await screen.findByText("Inscription")).toBeInTheDocument(); + expect(screen.getByText(/^Description$/i)).toBeInTheDocument(); + expect(screen.getByText(/^Other$/i)).toBeInTheDocument(); +}); + +test("creates a text field — posts the body and clears the key input", async () => { + let body: { key: string; data_type: string } | undefined; + + server.use( + http.post("/api/admin/field-definitions", async ({ request }) => { + body = (await request.json()) as { key: string; data_type: string }; + return HttpResponse.json({ key: "notes" }, { status: 201 }); + }), + ); + renderApp(tree(), { route: "/fields" }); + + await userEvent.type(screen.getByLabelText(/^key$/i), "notes"); + await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Notes"); + await userEvent.click(screen.getByRole("button", { name: /create field/i })); + + await waitFor(() => expect(body?.key).toBe("notes")); + expect(body?.data_type).toBe("text"); + await waitFor(() => expect(screen.getByLabelText(/^key$/i)).toHaveValue("")); +}); + +test("selecting Term reveals the vocabulary picker and blocks submit until chosen", async () => { + let posted = false; + + server.use( + http.post("/api/admin/field-definitions", () => { + posted = true; + return HttpResponse.json({ key: "x" }, { status: 201 }); + }), + ); + renderApp(tree(), { route: "/fields" }); + + await userEvent.type(screen.getByLabelText(/^key$/i), "material"); + await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Material"); + await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term"); + + const vocab = await screen.findByLabelText(/^vocabulary$/i); + + expect(vocab).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /create field/i })); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(posted).toBe(false); + + await userEvent.selectOptions(vocab, "v-material"); + await userEvent.click(screen.getByRole("button", { name: /create field/i })); + await waitFor(() => expect(posted).toBe(true)); +}); diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index bafe24b..a23cc69 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -29,6 +29,22 @@ "resultCount_other": "{{count}} results", "selectPrompt": "Select a result to see the full record" }, + "fields": { + "title": "Fields", + "newField": "New field definition", + "key": "Key", + "type": "Type", + "vocabulary": "Vocabulary", + "authorityKind": "Authority kind", + "anyKind": "Any", + "group": "Group", + "required": "Required", + "create": "Create field", + "empty": "No field definitions yet", + "loadError": "Could not load", + "other": "Other", + "types": { "text": "Text", "localized_text": "Localized text", "integer": "Integer", "date": "Date", "boolean": "Boolean", "term": "Term", "authority": "Authority" } + }, "publish": { "heading": "Visibility", "advanceInternal": "Advance to internal", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 2477583..e33f294 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -29,6 +29,22 @@ "resultCount_other": "{{count}} träffar", "selectPrompt": "Välj en träff för att se hela posten" }, + "fields": { + "title": "Fält", + "newField": "Nytt fältdefinition", + "key": "Nyckel", + "type": "Typ", + "vocabulary": "Vokabulär", + "authorityKind": "Auktoritetstyp", + "anyKind": "Alla", + "group": "Grupp", + "required": "Obligatoriskt", + "create": "Skapa fält", + "empty": "Inga fältdefinitioner ännu", + "loadError": "Kunde inte ladda", + "other": "Övrigt", + "types": { "text": "Text", "localized_text": "Lokaliserad text", "integer": "Heltal", "date": "Datum", "boolean": "Boolesk", "term": "Term", "authority": "Auktoritet" } + }, "publish": { "heading": "Synlighet", "advanceInternal": "Gör intern", diff --git a/web/src/shell/app-shell.test.tsx b/web/src/shell/app-shell.test.tsx index 9cc1ca2..5e60cd2 100644 --- a/web/src/shell/app-shell.test.tsx +++ b/web/src/shell/app-shell.test.tsx @@ -29,9 +29,9 @@ test("shows active and disabled nav and renders the outlet", async () => { renderApp(tree(), { route: "/objects" }); expect(await screen.findByText("objects outlet")).toBeInTheDocument(); expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument(); - // fields is still disabled; search is now a link + // fields and search are now links expect(screen.getByRole("link", { name: /search/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /fields/i })).toBeDisabled(); + expect(screen.getByRole("link", { name: /fields/i })).toBeInTheDocument(); }); test("language switch toggles to Swedish", async () => { diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 4a1aa13..43fb481 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -5,8 +5,6 @@ import { useLogout } from "../api/queries"; import { Button } from "@/components/ui/button"; import { LangSwitch } from "./lang-switch"; -const DISABLED_NAV = ["fields"] as const; - export function AppShell() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -54,16 +52,14 @@ export function AppShell() { > {t("nav.search")} - {DISABLED_NAV.map((key) => ( - - ))} + + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}` + } + > + {t("nav.fields")} +