62c569741f
Five small design/layout nits from the UI sweep:
- form.selectPlaceholder "— select —" → "Select…" / "Välj…", matching
the affordance style of every other placeholder (Filter…, Search…).
- FieldForm in edit mode now explains its locked controls with a muted
fields.lockedNote caption ("Key and type can't be changed after
creation.") instead of leaving four silently disabled inputs.
- FieldList rows truncate long labels (min-w-0 on the row button +
truncate on the label, shrink-0 on the badge and required marker)
instead of overflowing the 20rem column.
- The sidebar collapse toggle is hidden on narrow viewports (hidden
md:flex) instead of rendered permanently disabled/grayed — the rail
is forced collapsed there anyway.
- PageTitle gains text-balance so long titles wrap evenly.
Closes #73
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import type { components } from "../api/schema";
|
|
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
|
|
import { useLang } from "../lib/use-lang";
|
|
import { rowStateClass } from "../lib/class-recipes";
|
|
import { labelText } from "../lib/labels";
|
|
import { byLabel, compareStrings } from "../lib/sort";
|
|
import { focusRing } from "../lib/focus-ring";
|
|
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ListSkeleton } from "@/components/ui/skeletons";
|
|
|
|
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
|
|
|
export function FieldList({
|
|
selectedKey,
|
|
onSelect,
|
|
}: {
|
|
selectedKey: string | null;
|
|
onSelect: (def: FieldDefinitionView) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { data, isLoading, isError } = useFieldDefinitions();
|
|
const deleteField = useDeleteFieldDefinition();
|
|
const lang = useLang();
|
|
const [filter, setFilter] = useState("");
|
|
|
|
if (isLoading) return <ListSkeleton rows={6} />;
|
|
|
|
if (isError) return <p className="p-4 text-sm text-destructive">{t("fields.loadError")}</p>;
|
|
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 filtered) {
|
|
const key = def.group?.trim() ? def.group : t("fields.other");
|
|
const bucket = groups.get(key) ?? [];
|
|
|
|
bucket.push(def);
|
|
groups.set(key, bucket);
|
|
}
|
|
|
|
const otherLabel = t("fields.other");
|
|
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 (
|
|
<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 ${rowStateClass(
|
|
def.key === selectedKey,
|
|
)}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex min-w-0 flex-1 items-center gap-2 rounded-sm text-left",
|
|
focusRing,
|
|
)}
|
|
aria-pressed={def.key === selectedKey}
|
|
onClick={() => onSelect(def)}
|
|
>
|
|
<span className="min-w-0 truncate font-medium">
|
|
{labelText(def.labels, lang)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">{def.key}</span>
|
|
<Badge variant="secondary" className="shrink-0">
|
|
{t(`fields.types.${def.data_type}`)}
|
|
</Badge>
|
|
{def.required && (
|
|
<span
|
|
className="shrink-0 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>
|
|
);
|
|
}
|