672 lines
32 KiB
TypeScript
672 lines
32 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, Sparkles, X } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "../../components/ui/button";
|
|
import { ApiError, apiErrorMessage } from "../../api/client";
|
|
import {
|
|
consolidateCatalogDescriptions,
|
|
listCatalogColumns,
|
|
startCatalogSync,
|
|
startDescriptionGenerationRun,
|
|
updateCatalogColumnMetadata,
|
|
updateCatalogColumnSensitive,
|
|
type CatalogColumn,
|
|
type CatalogSyncRun,
|
|
type CatalogTable,
|
|
type DescriptionGenerationRun,
|
|
type SensitivityAnalysisRequest,
|
|
} from "../../api/catalog-databases";
|
|
import type { DatabaseNavigationState } from "./model";
|
|
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
|
|
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
|
import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationModelSelector";
|
|
|
|
interface Props {
|
|
databaseId: string;
|
|
table: CatalogTable;
|
|
databaseVersion: number;
|
|
canManage: boolean;
|
|
selectedMetadataModel: string | null;
|
|
descriptionGenerationActive: boolean;
|
|
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
|
onCatalogSyncRunStarted?: (run: CatalogSyncRun) => void;
|
|
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
|
onSuggestSensitive: (selection: SensitivityAnalysisRequest, scopeLabel: string) => Promise<void>;
|
|
catalogOperationActive?: boolean;
|
|
onCatalogMetricsChanged?: () => void | Promise<void>;
|
|
presentation?: "legacy" | "fleet";
|
|
}
|
|
|
|
interface GridContext {
|
|
canManage: boolean;
|
|
busy: boolean;
|
|
presentation: "legacy" | "fleet";
|
|
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
|
|
onSensitiveChange: (column: CatalogColumn, sensitive: boolean) => 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()}>
|
|
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}>
|
|
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} onClick={(event) => context.onEdit(data, event.currentTarget)}>
|
|
<Pencil aria-hidden="true" />
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unknown, GridContext>) {
|
|
if (!data || !context) return null;
|
|
return (
|
|
<div className="flex h-full items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
className="size-4 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
checked={data.sensitive}
|
|
disabled={!context.canManage || context.busy}
|
|
aria-label={`Sensitive data for ${data.name}`}
|
|
title="Mark this column as sensitive; this does not select it for actions."
|
|
onChange={(event) => context.onSensitiveChange(data, event.target.checked)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ColumnMetadataFieldMatrix({
|
|
column,
|
|
description,
|
|
generatedDescription,
|
|
canManage,
|
|
busy,
|
|
onDescriptionChange,
|
|
onGeneratedDescriptionChange,
|
|
}: {
|
|
column: CatalogColumn;
|
|
description: string;
|
|
generatedDescription: string;
|
|
canManage: boolean;
|
|
busy: boolean;
|
|
onDescriptionChange: (value: string) => void;
|
|
onGeneratedDescriptionChange: (value: string) => void;
|
|
}) {
|
|
const fieldClass = [
|
|
"thot-column-metadata-field flex min-h-0 flex-col gap-1.5",
|
|
"text-sm font-semibold",
|
|
].join(" ");
|
|
const editableClass = [
|
|
"min-h-28 flex-1 resize-none rounded-md border border-input bg-card px-3 py-2",
|
|
"text-sm font-normal outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15",
|
|
].join(" ");
|
|
const readOnlyClass = [
|
|
"min-h-28 flex-1 resize-none rounded-md border border-input bg-muted/35 px-3 py-2",
|
|
"text-sm font-normal",
|
|
].join(" ");
|
|
return (
|
|
<div
|
|
role="group"
|
|
aria-label="Column metadata fields"
|
|
className="thot-column-metadata-matrix mt-5 grid min-h-0 gap-4 sm:grid-cols-2"
|
|
>
|
|
<label className={fieldClass}>
|
|
Source comment
|
|
<textarea className={readOnlyClass} value={column.sourceComment ?? ""} readOnly />
|
|
</label>
|
|
<label className={fieldClass}>
|
|
Description
|
|
<textarea
|
|
className={editableClass}
|
|
value={description}
|
|
disabled={!canManage || busy}
|
|
onChange={(event) => onDescriptionChange(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label className={fieldClass}>
|
|
Generated description
|
|
<textarea
|
|
className={editableClass}
|
|
value={generatedDescription}
|
|
disabled={!canManage || busy}
|
|
onChange={(event) => onGeneratedDescriptionChange(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label className={fieldClass}>
|
|
Sensitive motivation
|
|
<textarea
|
|
className={readOnlyClass}
|
|
value={column.sensitivityReason ?? ""}
|
|
placeholder="No sensitivity motivation recorded."
|
|
readOnly
|
|
/>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DatabaseColumns({
|
|
databaseId,
|
|
table,
|
|
databaseVersion,
|
|
canManage,
|
|
selectedMetadataModel,
|
|
descriptionGenerationActive,
|
|
onDescriptionGenerationRunStarted,
|
|
onCatalogSyncRunStarted,
|
|
onNavigationStateChange,
|
|
onSuggestSensitive,
|
|
catalogOperationActive = false,
|
|
onCatalogMetricsChanged,
|
|
presentation = "legacy",
|
|
}: Props) {
|
|
const queryClient = useQueryClient();
|
|
const queryKey = ["catalog-columns", databaseId, table.id] as const;
|
|
const { data = [], error, isError, 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 [sensitiveDrafts, setSensitiveDrafts] = useState<Record<string, boolean>>({});
|
|
const [sensitiveAction, setSensitiveAction] = useState<"suggest" | "save" | null>(null);
|
|
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 editorDirty = Boolean(editingId && fingerprint !== baseline);
|
|
const changedSensitiveColumns = data.filter((column) => (
|
|
Object.hasOwn(sensitiveDrafts, column.id)
|
|
&& sensitiveDrafts[column.id] !== column.sensitive
|
|
));
|
|
const dirty = editorDirty || changedSensitiveColumns.length > 0;
|
|
const displayedColumns = useMemo(() => data.map((column) => (
|
|
Object.hasOwn(sensitiveDrafts, column.id)
|
|
? { ...column, sensitive: sensitiveDrafts[column.id]! }
|
|
: column
|
|
)), [data, sensitiveDrafts]);
|
|
|
|
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 (editorDirty && !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);
|
|
void onCatalogMetricsChanged?.();
|
|
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([]);
|
|
void onCatalogMetricsChanged?.();
|
|
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 changeSensitive = (column: CatalogColumn, sensitive: boolean) => {
|
|
const persisted = data.find((candidate) => candidate.id === column.id);
|
|
if (!persisted) return;
|
|
setSensitiveDrafts((current) => {
|
|
const next = { ...current };
|
|
if (sensitive === persisted.sensitive) delete next[column.id];
|
|
else next[column.id] = sensitive;
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const suggestSensitive = async () => {
|
|
if (selectedIds.length === 0) return;
|
|
setBusy(true);
|
|
setSensitiveAction("suggest");
|
|
try {
|
|
await onSuggestSensitive(
|
|
{ scope: "selected_columns", targetIds: selectedIds },
|
|
`${selectedIds.length} selected column${selectedIds.length === 1 ? "" : "s"} in ${table.name}`,
|
|
);
|
|
} catch {
|
|
// The page-level request reports the safe, specific reason and preserves the selection.
|
|
} finally {
|
|
setSensitiveAction(null);
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const saveSensitive = async () => {
|
|
if (changedSensitiveColumns.length === 0) return;
|
|
setBusy(true);
|
|
setSensitiveAction("save");
|
|
try {
|
|
const saved: CatalogColumn[] = [];
|
|
const failed: unknown[] = [];
|
|
for (const column of changedSensitiveColumns) {
|
|
try {
|
|
saved.push(await updateCatalogColumnSensitive(
|
|
databaseId,
|
|
table.id,
|
|
column.id,
|
|
column.version,
|
|
sensitiveDrafts[column.id]!,
|
|
));
|
|
} catch (error) {
|
|
failed.push(error);
|
|
}
|
|
}
|
|
if (saved.length > 0) {
|
|
const savedById = new Map(saved.map((column) => [column.id, column]));
|
|
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map(
|
|
(column) => savedById.get(column.id) ?? column,
|
|
));
|
|
setSensitiveDrafts((current) => {
|
|
const next = { ...current };
|
|
for (const column of saved) delete next[column.id];
|
|
return next;
|
|
});
|
|
void onCatalogMetricsChanged?.();
|
|
}
|
|
if (failed.length > 0) {
|
|
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[0])}`);
|
|
} else {
|
|
toast.success("Sensitive fields saved");
|
|
}
|
|
} finally {
|
|
setSensitiveAction(null);
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
type FleetColumnAction =
|
|
| "sync-columns"
|
|
| "generate-descriptions"
|
|
| "consolidate-descriptions"
|
|
| "suggest-sensitive";
|
|
|
|
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
|
|
{
|
|
id: "sync-columns",
|
|
label: "Synchronize columns for this table",
|
|
group: "Synchronization",
|
|
scopeLabel: "Selected table",
|
|
runLabel: "Synchronize",
|
|
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
|
|
disabledReason: !canManage
|
|
? "You do not have permission to synchronize the catalog."
|
|
: selectedIds.length === 0
|
|
? "Select at least one column."
|
|
: catalogOperationActive
|
|
? "Wait for the active catalog operation to finish."
|
|
: busy
|
|
? "Another action is running."
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "generate-descriptions",
|
|
label: "Generate column descriptions",
|
|
group: "Descriptions",
|
|
runLabel: "Generate",
|
|
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
|
|
disabledReason: !canManage
|
|
? "You do not have permission to generate descriptions."
|
|
: selectedIds.length === 0
|
|
? "Select at least one column."
|
|
: !selectedMetadataModel
|
|
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
|
|
: descriptionGenerationActive || catalogOperationActive
|
|
? "Wait for the active catalog operation to finish."
|
|
: busy
|
|
? "Another action is running."
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "consolidate-descriptions",
|
|
label: "Copy generated descriptions to Description",
|
|
group: "Descriptions",
|
|
runLabel: "Move",
|
|
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
|
|
disabledReason: !canManage
|
|
? "You do not have permission to update metadata."
|
|
: selectedIds.length === 0
|
|
? "Select at least one column."
|
|
: catalogOperationActive
|
|
? "Wait for the active catalog operation to finish."
|
|
: busy
|
|
? "Another action is running."
|
|
: undefined,
|
|
},
|
|
{
|
|
id: "suggest-sensitive",
|
|
label: "Analyze sensitive fields",
|
|
group: "Sensitive data",
|
|
runLabel: "Analyze",
|
|
disabled: !canManage || selectedIds.length === 0 || descriptionGenerationActive || catalogOperationActive || busy,
|
|
disabledReason: !canManage
|
|
? "You do not have permission to review sensitive data."
|
|
: selectedIds.length === 0
|
|
? "Select at least one column."
|
|
: descriptionGenerationActive || catalogOperationActive
|
|
? "Wait for the active catalog operation to finish."
|
|
: busy
|
|
? "Another action is running."
|
|
: undefined,
|
|
},
|
|
];
|
|
|
|
const runFleetAction = async (action: FleetColumnAction) => {
|
|
if (action === "sync-columns") await synchronizeColumns();
|
|
else if (action === "generate-descriptions") await generateDescriptions();
|
|
else if (action === "consolidate-descriptions") await consolidateDescriptions();
|
|
else await suggestSensitive();
|
|
};
|
|
|
|
const clearSelection = () => {
|
|
gridRef.current?.api.deselectAll();
|
|
setSelectedIds([]);
|
|
};
|
|
|
|
const synchronizeColumns = async () => {
|
|
if (selectedIds.length === 0) return;
|
|
setBusy(true);
|
|
try {
|
|
const run = await startCatalogSync(databaseId, databaseVersion, "columns", [table.id]);
|
|
onCatalogSyncRunStarted?.(run);
|
|
gridRef.current?.api.deselectAll();
|
|
setSelectedIds([]);
|
|
toast.success("Column synchronization started");
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
|
|
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
|
|
{ field: "sensitive", headerName: "Sensitive", headerTooltip: "Mark this column as sensitive; this does not select it for actions.", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
|
|
{ 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: "Actions", width: 64, maxWidth: 64, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: ActionCell },
|
|
], []);
|
|
const context = useMemo<GridContext>(() => ({
|
|
canManage,
|
|
busy: busy || catalogOperationActive,
|
|
presentation,
|
|
onEdit: edit,
|
|
onSensitiveChange: changeSensitive,
|
|
}), [busy, canManage, catalogOperationActive, data, presentation]);
|
|
const fleetQueryError = presentation === "fleet" && isError ? (
|
|
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
|
|
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
|
|
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
|
|
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
|
|
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
|
|
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry loading columns
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
if (editingId && active && presentation === "legacy") {
|
|
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}
|
|
<ColumnMetadataFieldMatrix
|
|
column={active}
|
|
description={description}
|
|
generatedDescription={generatedDescription}
|
|
canManage={canManage}
|
|
busy={busy}
|
|
onDescriptionChange={setDescription}
|
|
onGeneratedDescriptionChange={setGeneratedDescription}
|
|
/>
|
|
<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 || !editorDirty || 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={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
|
|
{selectedIds.length > 0 ? (
|
|
presentation === "fleet" ? (
|
|
<>
|
|
<FleetActionSelector
|
|
actions={fleetActions}
|
|
selectedCount={selectedIds.length}
|
|
busy={busy}
|
|
busyLabel={sensitiveAction === "suggest" ? "Suggesting…" : sensitiveAction === "save" ? "Saving…" : "Running…"}
|
|
onRun={runFleetAction}
|
|
onClear={clearSelection}
|
|
label="Column action"
|
|
/>
|
|
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
<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 || !selectedIds.length || catalogOperationActive || busy} onClick={() => void synchronizeColumns()}>Synchronize columns for this table</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 || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "column description" : "column 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()}>Copy generated descriptions to Description</Menu.Item>
|
|
</Menu.Popup>
|
|
</Menu.Positioner>
|
|
</Menu.Portal>
|
|
</Menu.Root>
|
|
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{sensitiveAction === "suggest" ? "Analyzing…" : "Analyze sensitive fields"}</Button>
|
|
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
|
|
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
|
|
</>
|
|
)
|
|
) : (
|
|
presentation === "fleet" ? (
|
|
<>
|
|
<FleetActionSelector
|
|
actions={fleetActions}
|
|
selectedCount={0}
|
|
busy={busy}
|
|
busyLabel={sensitiveAction === "save" ? "Saving…" : "Running…"}
|
|
onRun={runFleetAction}
|
|
onClear={clearSelection}
|
|
label="Column action"
|
|
/>
|
|
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
|
|
<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>
|
|
</>
|
|
) : (
|
|
<>
|
|
<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>
|
|
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
|
|
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
|
</>
|
|
)
|
|
)}
|
|
</div>
|
|
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
|
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
|
|
<AgGridReact<CatalogColumn>
|
|
ref={gridRef}
|
|
rowData={displayedColumns}
|
|
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 }}
|
|
selectionColumnDef={{
|
|
headerName: "Select",
|
|
headerTooltip: "Select columns for actions",
|
|
width: 72,
|
|
minWidth: 72,
|
|
maxWidth: 72,
|
|
pinned: "left",
|
|
sortable: false,
|
|
resizable: false,
|
|
suppressMovable: true,
|
|
}}
|
|
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((column) => column.id))}
|
|
rowHeight={44}
|
|
headerHeight={38}
|
|
animateRows={false}
|
|
overlayNoRowsTemplate="No columns synchronized for this table."
|
|
/>
|
|
</div>
|
|
</div>}
|
|
{presentation === "fleet" && active ? (
|
|
<FleetLedgerDrawer
|
|
open
|
|
eyebrow="Catalog metadata"
|
|
title="Review column description"
|
|
description="Physical schema fields are read-only. Review fields can be edited."
|
|
onClose={closeEditor}
|
|
closeLabel="Close column metadata"
|
|
busy={busy}
|
|
bodyClassName="thot-column-metadata-drawer-body"
|
|
footer={(
|
|
<>
|
|
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
|
|
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save aria-hidden="true" />{busy ? "Saving…" : "Save metadata"}</Button>
|
|
</>
|
|
)}
|
|
>
|
|
<p className="thot-label">{table.name} · column</p>
|
|
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{active.name}</h3>
|
|
{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}
|
|
<ColumnMetadataFieldMatrix
|
|
column={active}
|
|
description={description}
|
|
generatedDescription={generatedDescription}
|
|
canManage={canManage}
|
|
busy={busy}
|
|
onDescriptionChange={setDescription}
|
|
onGeneratedDescriptionChange={setGeneratedDescription}
|
|
/>
|
|
</FleetLedgerDrawer>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|