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) { if (!data) return null; return (
{data.isPrimaryKey ? PK{data.primaryKeyPosition && data.primaryKeyPosition > 1 ? ` ${data.primaryKeyPosition}` : ""} : null} {data.isForeignKey ? FK{data.foreignKeyCount > 1 ? ` ${data.foreignKeyCount}` : ""} : null}
); } function ActionCell({ data, context }: ICellRendererParams) { if (!data || !context) return null; return (
event.stopPropagation()}>
); } 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([]); const [editingId, setEditingId] = useState(null); const [description, setDescription] = useState(""); const [generatedDescription, setGeneratedDescription] = useState(""); const [baseline, setBaseline] = useState(""); const [version, setVersion] = useState(null); const [stale, setStale] = useState(false); const [staleBannerOpen, setStaleBannerOpen] = useState(true); const [busy, setBusy] = useState(false); const gridRef = useRef>(null); const originRef = useRef(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(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[]>(() => [ { 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(() => ({ canManage, onEdit: edit }), [canManage, data]); if (editingId && active) { return (

{table.name} · column

{active.name}

Physical schema fields are read-only. Review fields can be edited.

{stale ? ( staleBannerOpen ?

Newer column metadata is available.

Your draft is preserved until you reload.

:
Reload the latest value before this metadata can be saved.
) : null}