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 (
-
- {entries.map(([group, defs]) => (
- -
-
- {group}
-
-
-
- ))}
-
+ {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;