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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user