Files
ThothII/frontend/src/widgets/SchemaColumnsDialog.tsx
T
9fe1c9321b feat(frontend): suggested-first ordering + filter box in the schema-linking columns modal
Wide tables (e.g. 116 columns, 3 suggested) made curation a scroll hunt. Now the
modal lists suggested columns first (stable sort on the descriptor flag, so
toggling never reorders rows) and adds a filter box matching name + description,
with a "shown/total" count. Selection stays keyed by column name, so the response
column order is unchanged (catalog order). Applies to read-only (excluded) tables too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:29:21 +02:00

96 lines
3.6 KiB
TypeScript

import { useMemo, useState, type ReactElement } from "react";
import type { SchemaTable } from "../api/types";
import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog";
export function SchemaColumnsDialog({
table,
open,
readOnly,
selected,
onToggle,
onClose,
}: {
table: SchemaTable;
open: boolean;
readOnly: boolean;
selected: Set<string>;
onToggle: (column: string) => void;
onClose: () => void;
}): ReactElement {
const [query, setQuery] = useState("");
// Suggested-first, then catalog order. Sorting on the descriptor's `suggested`
// flag (not the live selection) keeps the list stable: toggling a checkbox never
// reorders rows. For excluded tables (all suggested=false) this leaves catalog order.
const ordered = useMemo(
() =>
table.columns
.map((c, i) => ({ c, i }))
.sort((a, b) => Number(!!b.c.suggested) - Number(!!a.c.suggested) || a.i - b.i)
.map((x) => x.c),
[table.columns],
);
const q = query.trim().toLowerCase();
const displayed = q
? ordered.filter(
(c) => c.name.toLowerCase().includes(q) || (c.description ?? "").toLowerCase().includes(q),
)
: ordered;
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="grid max-h-[70vh] w-[70vw] max-w-[46rem] grid-rows-[auto_auto_1fr] gap-3 sm:max-w-[46rem]">
<DialogTitle className="font-mono text-sm">{table.name}</DialogTitle>
<div className="flex items-center gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Filter columns"
placeholder="Filter columns…"
className="min-w-0 flex-1 rounded-md border border-input bg-card px-2.5 py-1.5 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-2 focus:ring-ring/20"
/>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{displayed.length}/{table.columns.length}
</span>
</div>
<div className="min-h-0 overflow-auto">
<ul className="flex flex-col divide-y divide-border/50">
{displayed.map((col) => {
const on = selected.has(col.name);
const strong = on && !readOnly;
return (
<li key={col.name} className="flex items-start gap-3 py-2">
{!readOnly && (
<input
type="checkbox"
className="mt-0.5 size-4 shrink-0 accent-[oklch(var(--primary))]"
checked={on}
onChange={() => onToggle(col.name)}
aria-label={col.name}
/>
)}
<div className="min-w-0">
<div className={`font-mono text-sm ${strong ? "font-bold text-foreground" : "text-foreground/90"}`}>
{col.name}
</div>
{col.description ? (
<p className={`text-sm ${strong ? "font-semibold text-foreground" : "text-muted-foreground"}`}>
{col.description}
</p>
) : null}
</div>
</li>
);
})}
{displayed.length === 0 && (
<li className="py-3 text-sm text-muted-foreground">No columns match the filter.</li>
)}
</ul>
</div>
</DialogContent>
</Dialog>
);
}