diff --git a/web/src/fields/field-list.tsx b/web/src/fields/field-list.tsx index b0eb8b1..110259c 100644 --- a/web/src/fields/field-list.tsx +++ b/web/src/fields/field-list.tsx @@ -1,9 +1,13 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { components } from "../api/schema"; import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries"; import { labelText } from "../lib/labels"; +import { byLabel, compareStrings } from "../lib/sort"; import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; import { ListSkeleton } from "@/components/ui/skeletons"; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; @@ -19,6 +23,7 @@ export function FieldList({ const { data, isLoading, isError } = useFieldDefinitions(); const deleteField = useDeleteFieldDefinition(); const lang = i18n.language.startsWith("sv") ? "sv" : "en"; + const [filter, setFilter] = useState(""); if (isLoading) return ; @@ -26,9 +31,17 @@ export function FieldList({ if (!data || data.length === 0) return

{t("fields.empty")}

; + const q = filter.trim().toLowerCase(); + const filtered = (data ?? []).filter( + (d) => + !q || + labelText(d.labels, lang).toLowerCase().includes(q) || + d.key.toLowerCase().includes(q), + ); + const groups = new Map(); - for (const def of data) { + for (const def of filtered) { const key = def.group?.trim() ? def.group : t("fields.other"); const bucket = groups.get(key) ?? []; @@ -37,55 +50,72 @@ export function FieldList({ } const otherLabel = t("fields.other"); - const entries = [...groups.entries()].sort((a, b) => - a[0] === otherLabel ? 1 : b[0] === otherLabel ? -1 : 0, - ); + const entries = [...groups.entries()].sort((a, b) => { + if (a[0] === otherLabel) return 1; + if (b[0] === otherLabel) return -1; + return compareStrings(lang, a[0], b[0]); + }); return ( - + {labelText(def.labels, lang)} + {def.key} + + {t(`fields.types.${def.data_type}`)} + + {def.required && ( + + * + + )} + + deleteField.mutateAsync(def.key)} + /> + + ))} + + + ))} + + )} + ); } diff --git a/web/src/fields/fields.test.tsx b/web/src/fields/fields.test.tsx index b939570..1b399e4 100644 --- a/web/src/fields/fields.test.tsx +++ b/web/src/fields/fields.test.tsx @@ -28,6 +28,65 @@ test("lists field definitions grouped, with an Other heading for ungrouped", asy expect(screen.getByText(/^Other$/i)).toBeInTheDocument(); }); +test("sorts fields within a group alphabetically by label", async () => { + server.use( + http.get("/api/admin/field-definitions", () => + HttpResponse.json([ + { + key: "weight", + data_type: "text", + vocabulary_id: null, + authority_kind: null, + required: false, + group: "Description", + labels: [{ lang: "en", label: "Weight" }], + }, + { + key: "color", + data_type: "text", + vocabulary_id: null, + authority_kind: null, + required: false, + group: "Description", + labels: [{ lang: "en", label: "Color" }], + }, + ]), + ), + ); + renderApp(tree(), { route: "/fields" }); + + await screen.findByText("Color"); + const labels = screen.getAllByText(/^(Color|Weight)$/).map((el) => el.textContent); + + expect(labels).toEqual(["Color", "Weight"]); +}); + +test("shows a count badge in each group header", async () => { + renderApp(tree(), { route: "/fields" }); + + const otherHeading = await screen.findByText(/^Other$/i); + const header = otherHeading.closest("div") as HTMLElement; + + // 6 ungrouped fields fall under "Other" in the fixture. + expect(within(header).getByText("6")).toBeInTheDocument(); +}); + +test("filter narrows the visible fields", async () => { + renderApp(tree(), { route: "/fields" }); + + await screen.findByText("Inscription"); + + await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "inscription"); + + expect(screen.getByText("Inscription")).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText("Material")).not.toBeInTheDocument()); + + await userEvent.clear(screen.getByRole("textbox", { name: /filter/i })); + await userEvent.type(screen.getByRole("textbox", { name: /filter/i }), "zzzznomatch"); + + expect(await screen.findByText(/no matches/i)).toBeInTheDocument(); +}); + test("creates a text field — posts the body and clears the key input", async () => { let body: { key: string; data_type: string } | undefined;