feat(web): field-list filter, within-group label sort, group order, count badges (#50)

This commit is contained in:
2026-06-08 09:06:17 +02:00
parent 75e7cf9047
commit 882d0c828f
2 changed files with 137 additions and 48 deletions
+37 -7
View File
@@ -1,9 +1,13 @@
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { components } from "../api/schema"; import type { components } from "../api/schema";
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries"; import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
import { labelText } from "../lib/labels"; import { labelText } from "../lib/labels";
import { byLabel, compareStrings } from "../lib/sort";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; 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"; import { ListSkeleton } from "@/components/ui/skeletons";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
@@ -19,6 +23,7 @@ export function FieldList({
const { data, isLoading, isError } = useFieldDefinitions(); const { data, isLoading, isError } = useFieldDefinitions();
const deleteField = useDeleteFieldDefinition(); const deleteField = useDeleteFieldDefinition();
const lang = i18n.language.startsWith("sv") ? "sv" : "en"; const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const [filter, setFilter] = useState("");
if (isLoading) return <ListSkeleton rows={6} />; if (isLoading) return <ListSkeleton rows={6} />;
@@ -26,9 +31,17 @@ export function FieldList({
if (!data || data.length === 0) if (!data || data.length === 0)
return <p className="p-4 text-sm text-muted-foreground">{t("fields.empty")}</p>; return <p className="p-4 text-sm text-muted-foreground">{t("fields.empty")}</p>;
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<string, FieldDefinitionView[]>(); const groups = new Map<string, FieldDefinitionView[]>();
for (const def of data) { for (const def of filtered) {
const key = def.group?.trim() ? def.group : t("fields.other"); const key = def.group?.trim() ? def.group : t("fields.other");
const bucket = groups.get(key) ?? []; const bucket = groups.get(key) ?? [];
@@ -37,19 +50,34 @@ export function FieldList({
} }
const otherLabel = t("fields.other"); const otherLabel = t("fields.other");
const entries = [...groups.entries()].sort((a, b) => const entries = [...groups.entries()].sort((a, b) => {
a[0] === otherLabel ? 1 : b[0] === otherLabel ? -1 : 0, if (a[0] === otherLabel) return 1;
); if (b[0] === otherLabel) return -1;
return compareStrings(lang, a[0], b[0]);
});
return ( return (
<div className="flex h-full flex-col">
<div className="border-b p-2">
<Input
aria-label={t("common.filter")}
placeholder={t("common.filter")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{filtered.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</p>
) : (
<ul className="overflow-auto"> <ul className="overflow-auto">
{entries.map(([group, defs]) => ( {entries.map(([group, defs]) => (
<li key={group}> <li key={group}>
<div className="border-b bg-muted px-3 py-1 label-caption"> <div className="flex items-center justify-between border-b bg-muted px-3 py-1 label-caption">
{group} <span>{group}</span>
<Badge variant="secondary">{defs.length}</Badge>
</div> </div>
<ul> <ul>
{defs.map((def) => ( {[...defs].sort(byLabel(lang)).map((def) => (
<li <li
key={def.key} key={def.key}
className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${ className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
@@ -87,5 +115,7 @@ export function FieldList({
</li> </li>
))} ))}
</ul> </ul>
)}
</div>
); );
} }
+59
View File
@@ -28,6 +28,65 @@ test("lists field definitions grouped, with an Other heading for ungrouped", asy
expect(screen.getByText(/^Other$/i)).toBeInTheDocument(); 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 () => { test("creates a text field — posts the body and clears the key input", async () => {
let body: { key: string; data_type: string } | undefined; let body: { key: string; data_type: string } | undefined;