286 lines
16 KiB
TypeScript
286 lines
16 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Menu } from "@base-ui/react/menu";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { AgGridReact } from "ag-grid-react";
|
|
import type { ColDef, ICellRendererParams } from "ag-grid-community";
|
|
import { ChevronDown, KeyRound, Link2, Pencil, RefreshCw, Save, X } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "../../components/ui/button";
|
|
import { ApiError, apiErrorMessage } from "../../api/client";
|
|
import {
|
|
consolidateCatalogDescriptions,
|
|
listCatalogColumns,
|
|
startDescriptionGenerationRun,
|
|
updateCatalogColumnMetadata,
|
|
type CatalogColumn,
|
|
type CatalogTable,
|
|
type DescriptionGenerationRun,
|
|
} from "../../api/catalog-databases";
|
|
import type { DatabaseNavigationState } from "./model";
|
|
|
|
interface Props {
|
|
databaseId: string;
|
|
table: CatalogTable;
|
|
canManage: boolean;
|
|
selectedMetadataModel: string | null;
|
|
descriptionGenerationActive: boolean;
|
|
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
|
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
|
onSync: () => void;
|
|
}
|
|
|
|
interface GridContext {
|
|
canManage: boolean;
|
|
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
|
|
}
|
|
|
|
function KeyCell({ data }: ICellRendererParams<CatalogColumn>) {
|
|
if (!data) return null;
|
|
return (
|
|
<div className="flex h-full items-center gap-1.5">
|
|
{data.isPrimaryKey ? <span className="inline-flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary"><KeyRound className="size-3" />PK{data.primaryKeyPosition && data.primaryKeyPosition > 1 ? ` ${data.primaryKeyPosition}` : ""}</span> : null}
|
|
{data.isForeignKey ? <span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs font-semibold text-muted-foreground"><Link2 className="size-3" />FK{data.foreignKeyCount > 1 ? ` ${data.foreignKeyCount}` : ""}</span> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
|
|
if (!data || !context) return null;
|
|
return (
|
|
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
|
|
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} onClick={(event) => context.onEdit(data, event.currentTarget)}>
|
|
<Pencil aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DatabaseColumns({
|
|
databaseId,
|
|
table,
|
|
canManage,
|
|
selectedMetadataModel,
|
|
descriptionGenerationActive,
|
|
onDescriptionGenerationRunStarted,
|
|
onNavigationStateChange,
|
|
onSync,
|
|
}: Props) {
|
|
const queryClient = useQueryClient();
|
|
const queryKey = ["catalog-columns", databaseId, table.id] as const;
|
|
const { data = [], isLoading, isFetching, refetch } = useQuery({
|
|
queryKey,
|
|
queryFn: () => listCatalogColumns(databaseId, table.id),
|
|
retry: false,
|
|
});
|
|
const [search, setSearch] = useState("");
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [description, setDescription] = useState("");
|
|
const [generatedDescription, setGeneratedDescription] = useState("");
|
|
const [baseline, setBaseline] = useState("");
|
|
const [version, setVersion] = useState<number | null>(null);
|
|
const [stale, setStale] = useState(false);
|
|
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const gridRef = useRef<AgGridReact<CatalogColumn>>(null);
|
|
const originRef = useRef<HTMLButtonElement | null>(null);
|
|
const active = editingId ? data.find((column) => column.id === editingId) : undefined;
|
|
const fingerprint = JSON.stringify([description, generatedDescription]);
|
|
const dirty = Boolean(editingId && fingerprint !== baseline);
|
|
|
|
useEffect(() => { onNavigationStateChange({ dirty, busy }); }, [busy, dirty, onNavigationStateChange]);
|
|
useEffect(() => {
|
|
if (!editingId || !active || version === active.version || busy) return;
|
|
setStale(true);
|
|
setStaleBannerOpen(true);
|
|
}, [active, busy, editingId, version]);
|
|
|
|
const edit = (column: CatalogColumn, origin: HTMLButtonElement) => {
|
|
originRef.current = origin;
|
|
setEditingId(column.id);
|
|
setDescription(column.description ?? "");
|
|
setGeneratedDescription(column.generatedDescription ?? "");
|
|
setBaseline(JSON.stringify([column.description ?? "", column.generatedDescription ?? ""]));
|
|
setVersion(column.version);
|
|
setStale(false);
|
|
setStaleBannerOpen(true);
|
|
};
|
|
const closeEditor = () => {
|
|
if (busy) return;
|
|
if (dirty && !window.confirm("Discard unsaved column metadata?")) return;
|
|
setEditingId(null);
|
|
window.setTimeout(() => originRef.current?.focus(), 0);
|
|
};
|
|
const save = async () => {
|
|
if (!active || version === null) return;
|
|
setBusy(true);
|
|
try {
|
|
const updated = await updateCatalogColumnMetadata(
|
|
databaseId, table.id, active.id, version,
|
|
description.trim() || null, generatedDescription.trim() || null,
|
|
);
|
|
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map((column) => column.id === updated.id ? updated : column));
|
|
setDescription(updated.description ?? "");
|
|
setGeneratedDescription(updated.generatedDescription ?? "");
|
|
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
|
|
setVersion(updated.version);
|
|
setStale(false);
|
|
toast.success("Column metadata saved");
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.code === "column_stale") {
|
|
await refetch();
|
|
setStale(true);
|
|
setStaleBannerOpen(true);
|
|
} else toast.error(apiErrorMessage(error));
|
|
} finally { setBusy(false); }
|
|
};
|
|
const reloadColumn = async () => {
|
|
if (!editingId) return;
|
|
setBusy(true);
|
|
try {
|
|
const result = await refetch();
|
|
const latest = result.data?.find((column) => column.id === editingId);
|
|
if (!latest) { closeEditor(); return; }
|
|
setDescription(latest.description ?? "");
|
|
setGeneratedDescription(latest.generatedDescription ?? "");
|
|
setBaseline(JSON.stringify([latest.description ?? "", latest.generatedDescription ?? ""]));
|
|
setVersion(latest.version);
|
|
setStale(false);
|
|
setStaleBannerOpen(true);
|
|
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
|
|
};
|
|
const consolidateDescriptions = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const counts = await consolidateCatalogDescriptions(databaseId, "columns", selectedIds);
|
|
await queryClient.invalidateQueries({ queryKey, exact: true });
|
|
gridRef.current?.api.deselectAll();
|
|
setSelectedIds([]);
|
|
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
} finally { setBusy(false); }
|
|
};
|
|
const generateDescriptions = async () => {
|
|
if (selectedIds.length === 0 || !selectedMetadataModel) return;
|
|
setBusy(true);
|
|
try {
|
|
const run = await startDescriptionGenerationRun(
|
|
databaseId,
|
|
selectedMetadataModel,
|
|
"selected_columns",
|
|
selectedIds,
|
|
);
|
|
onDescriptionGenerationRunStarted(run);
|
|
toast.success(`Description generation started for ${selectedIds.length} column${selectedIds.length === 1 ? "" : "s"}`);
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
} finally { setBusy(false); }
|
|
};
|
|
|
|
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
|
|
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
|
|
{ field: "name", headerName: "Name", minWidth: 190, flex: 1, cellClass: "font-mono text-xs" },
|
|
{ field: "dataType", headerName: "Type", minWidth: 150, flex: 0.8, cellClass: "font-mono text-xs" },
|
|
{ headerName: "Keys", minWidth: 125, width: 125, sortable: false, filter: false, cellRenderer: KeyCell },
|
|
{ headerName: "Nullable", minWidth: 100, width: 100, valueGetter: ({ data: row }) => row ? (row.isNullable ? "Yes" : "No") : "" },
|
|
{ field: "sourceComment", headerName: "Source comment", minWidth: 220, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
|
|
{ field: "generatedDescription", headerName: "Generated description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
|
|
{ field: "description", headerName: "Description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
|
|
{ colId: "actions", headerName: "", width: 64, maxWidth: 64, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: ActionCell },
|
|
], []);
|
|
const context = useMemo<GridContext>(() => ({ canManage, onEdit: edit }), [canManage, data]);
|
|
|
|
if (editingId && active) {
|
|
return (
|
|
<div className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-5">
|
|
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={closeEditor}>← Back to columns</Button>
|
|
<p className="thot-label">{table.name} · column</p>
|
|
<h3 className="mt-1 font-heading text-2xl font-semibold">{active.name}</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground">Physical schema fields are read-only. Review fields can be edited.</p>
|
|
{stale ? (
|
|
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
|
|
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
|
|
) : null}
|
|
<div className="mt-5 grid gap-4 border-t border-border pt-5 sm:grid-cols-2">
|
|
<label className="grid gap-1.5 text-sm font-semibold">Physical column name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.name} readOnly /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold">Native type<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.dataType} readOnly /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold">Ordinal position<input className="h-9 rounded-md border border-input bg-muted/35 px-3" value={active.ordinalPosition} readOnly /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold">Default expression<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.defaultExpression ?? ""} readOnly /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={active.sourceComment ?? ""} readOnly /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Generated description<textarea className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
|
|
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy} onChange={(event) => setDescription(event.target.value)} /></label>
|
|
</div>
|
|
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
|
|
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
|
|
<Button type="button" disabled={!canManage || !dirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
|
|
{selectedIds.length > 0 ? (
|
|
<>
|
|
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
|
|
<Menu.Root>
|
|
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
|
|
<Menu.Portal>
|
|
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
|
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
|
<Menu.Item
|
|
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
|
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy}
|
|
onClick={() => void generateDescriptions()}
|
|
>
|
|
Generate {selectedIds.length === 1 ? "description" : "descriptions"}
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
|
disabled={!canManage || busy}
|
|
onClick={() => void consolidateDescriptions()}
|
|
>
|
|
Move generated description to Description
|
|
</Menu.Item>
|
|
</Menu.Popup>
|
|
</Menu.Positioner>
|
|
</Menu.Portal>
|
|
</Menu.Root>
|
|
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="thot-label whitespace-nowrap">Catalog columns</span>
|
|
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
|
|
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
|
|
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
|
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="relative min-h-[280px] flex-1">
|
|
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
|
|
<AgGridReact<CatalogColumn>
|
|
ref={gridRef}
|
|
rowData={data}
|
|
columnDefs={columns}
|
|
context={context}
|
|
loading={isLoading}
|
|
quickFilterText={search}
|
|
defaultColDef={{ sortable: true, filter: true, resizable: true }}
|
|
getRowId={({ data: row }) => row.id}
|
|
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
|
|
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((column) => column.id))}
|
|
rowHeight={44}
|
|
headerHeight={38}
|
|
animateRows={false}
|
|
overlayNoRowsTemplate="No columns synchronized for this table."
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|