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 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>
);
}