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
+78 -48
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,55 +50,72 @@ 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 (
<ul className="overflow-auto"> <div className="flex h-full flex-col">
{entries.map(([group, defs]) => ( <div className="border-b p-2">
<li key={group}> <Input
<div className="border-b bg-muted px-3 py-1 label-caption"> aria-label={t("common.filter")}
{group} placeholder={t("common.filter")}
</div> value={filter}
<ul> onChange={(e) => setFilter(e.target.value)}
{defs.map((def) => ( />
<li </div>
key={def.key} {filtered.length === 0 ? (
className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${ <p className="p-3 text-sm text-muted-foreground">{t("common.noMatches")}</p>
def.key === selectedKey ? "bg-primary/10" : "" ) : (
}`} <ul className="overflow-auto">
> {entries.map(([group, defs]) => (
<button <li key={group}>
type="button" <div className="flex items-center justify-between border-b bg-muted px-3 py-1 label-caption">
className="flex flex-1 items-center gap-2 text-left" <span>{group}</span>
aria-pressed={def.key === selectedKey} <Badge variant="secondary">{defs.length}</Badge>
onClick={() => onSelect(def)} </div>
> <ul>
<span className="font-medium">{labelText(def.labels, lang)}</span> {[...defs].sort(byLabel(lang)).map((def) => (
<span className="text-xs text-muted-foreground">{def.key}</span> <li
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"> key={def.key}
{t(`fields.types.${def.data_type}`)} className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
</span> def.key === selectedKey ? "bg-primary/10" : ""
{def.required && ( }`}
<span >
className="text-xs text-destructive" <button
title={t("fields.required")} type="button"
aria-label={t("fields.required")} className="flex flex-1 items-center gap-2 text-left"
aria-pressed={def.key === selectedKey}
onClick={() => onSelect(def)}
> >
* <span className="font-medium">{labelText(def.labels, lang)}</span>
</span> <span className="text-xs text-muted-foreground">{def.key}</span>
)} <span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
</button> {t(`fields.types.${def.data_type}`)}
<DeleteConfirmDialog </span>
description={t("actions.confirmDeleteField")} {def.required && (
onConfirm={() => deleteField.mutateAsync(def.key)} <span
/> className="text-xs text-destructive"
</li> title={t("fields.required")}
))} aria-label={t("fields.required")}
</ul> >
</li> *
))} </span>
</ul> )}
</button>
<DeleteConfirmDialog
description={t("actions.confirmDeleteField")}
onConfirm={() => deleteField.mutateAsync(def.key)}
/>
</li>
))}
</ul>
</li>
))}
</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;