feat(web): field-list filter, within-group label sort, group order, count badges (#50)
This commit is contained in:
@@ -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 <ListSkeleton rows={6} />;
|
||||
|
||||
@@ -26,9 +31,17 @@ export function FieldList({
|
||||
if (!data || data.length === 0)
|
||||
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[]>();
|
||||
|
||||
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 (
|
||||
<ul className="overflow-auto">
|
||||
{entries.map(([group, defs]) => (
|
||||
<li key={group}>
|
||||
<div className="border-b bg-muted px-3 py-1 label-caption">
|
||||
{group}
|
||||
</div>
|
||||
<ul>
|
||||
{defs.map((def) => (
|
||||
<li
|
||||
key={def.key}
|
||||
className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
|
||||
def.key === selectedKey ? "bg-primary/10" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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 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">
|
||||
{t(`fields.types.${def.data_type}`)}
|
||||
</span>
|
||||
{def.required && (
|
||||
<span
|
||||
className="text-xs text-destructive"
|
||||
title={t("fields.required")}
|
||||
aria-label={t("fields.required")}
|
||||
<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">
|
||||
{entries.map(([group, defs]) => (
|
||||
<li key={group}>
|
||||
<div className="flex items-center justify-between border-b bg-muted px-3 py-1 label-caption">
|
||||
<span>{group}</span>
|
||||
<Badge variant="secondary">{defs.length}</Badge>
|
||||
</div>
|
||||
<ul>
|
||||
{[...defs].sort(byLabel(lang)).map((def) => (
|
||||
<li
|
||||
key={def.key}
|
||||
className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
|
||||
def.key === selectedKey ? "bg-primary/10" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
aria-pressed={def.key === selectedKey}
|
||||
onClick={() => onSelect(def)}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<DeleteConfirmDialog
|
||||
description={t("actions.confirmDeleteField")}
|
||||
onConfirm={() => deleteField.mutateAsync(def.key)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span className="font-medium">{labelText(def.labels, lang)}</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">
|
||||
{t(`fields.types.${def.data_type}`)}
|
||||
</span>
|
||||
{def.required && (
|
||||
<span
|
||||
className="text-xs text-destructive"
|
||||
title={t("fields.required")}
|
||||
aria-label={t("fields.required")}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<DeleteConfirmDialog
|
||||
description={t("actions.confirmDeleteField")}
|
||||
onConfirm={() => deleteField.mutateAsync(def.key)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user