600 lines
32 KiB
TypeScript
600 lines
32 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { AgGridReact } from "ag-grid-react";
|
|
import type { ColDef, ICellRendererParams } from "ag-grid-community";
|
|
import { ArrowLeft, History, Info, Play, Plus, RefreshCw } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "../../components/ui/button";
|
|
import { ApiError, apiErrorMessage } from "../../api/client";
|
|
import {
|
|
createCatalogRelationship,
|
|
deleteCatalogRelationship,
|
|
listCatalogRelationships,
|
|
listCatalogColumns,
|
|
listCatalogTables,
|
|
rebuildGeneratedRelationships,
|
|
setCatalogRelationshipStatus,
|
|
startCatalogSync,
|
|
type CatalogDatabase,
|
|
type CatalogRelationship,
|
|
type CatalogSyncRun,
|
|
} from "../../api/catalog-databases";
|
|
import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
|
import type { DatabaseNavigationState } from "./model";
|
|
|
|
interface Props {
|
|
database: CatalogDatabase;
|
|
canManage: boolean;
|
|
onBackToDatabases: () => void;
|
|
onOpenOverview: () => void;
|
|
onOpenTables: () => void;
|
|
onRunStarted: (run: CatalogSyncRun) => void;
|
|
onOpenSync: () => void;
|
|
onOpenDescriptionHistory?: () => void;
|
|
onOpenSensitiveHistory?: () => void;
|
|
onCatalogMetricsChanged?: () => void | Promise<void>;
|
|
onNavigationStateChange?: (state: DatabaseNavigationState) => void;
|
|
presentation?: "legacy" | "fleet";
|
|
}
|
|
|
|
interface RelationshipGridContext {
|
|
presentation: "legacy" | "fleet";
|
|
onView: (relationship: CatalogRelationship, origin: HTMLButtonElement) => void;
|
|
}
|
|
|
|
type RelationshipStatusFilter = "active" | "excluded" | "all";
|
|
|
|
function titleCase(value: string): string {
|
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
}
|
|
|
|
function relationshipLabel(relationship: CatalogRelationship): string {
|
|
const pair = relationship.columns[0];
|
|
const source = `${relationship.sourceTableName}${pair ? `.${pair.sourceColumnName}` : ""}`;
|
|
const target = `${relationship.targetTableName}${pair ? `.${pair.targetColumnName}` : ""}`;
|
|
return `${source} to ${target}`;
|
|
}
|
|
|
|
function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogRelationship, unknown, RelationshipGridContext>) {
|
|
if (!data || !context || context.presentation !== "fleet") return null;
|
|
return (
|
|
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-lg"
|
|
className="thot-fleet-icon-action"
|
|
data-tooltip="Details"
|
|
aria-label={`View relationship ${relationshipLabel(data)}`}
|
|
onClick={(event) => context.onView(data, event.currentTarget)}
|
|
>
|
|
<Info aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DatabaseRelationships({
|
|
database,
|
|
canManage,
|
|
onBackToDatabases,
|
|
onOpenOverview,
|
|
onOpenTables,
|
|
onRunStarted,
|
|
onOpenSync,
|
|
onOpenDescriptionHistory,
|
|
onOpenSensitiveHistory,
|
|
onCatalogMetricsChanged,
|
|
onNavigationStateChange,
|
|
presentation = "legacy",
|
|
}: Props) {
|
|
const databaseId = database.id!;
|
|
const queryClient = useQueryClient();
|
|
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
|
|
queryKey: ["catalog-relationships", databaseId],
|
|
queryFn: () => listCatalogRelationships(databaseId),
|
|
retry: false,
|
|
});
|
|
const [search, setSearch] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<RelationshipStatusFilter>("active");
|
|
const [busy, setBusy] = useState(false);
|
|
const [selectedAction, setSelectedAction] = useState<"" | "rebuild-generated" | "relationships">("");
|
|
const [activeRelationshipId, setActiveRelationshipId] = useState<string | null>(null);
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [sourceTableId, setSourceTableId] = useState("");
|
|
const [sourceColumnId, setSourceColumnId] = useState("");
|
|
const [targetTableId, setTargetTableId] = useState("");
|
|
const [targetColumnId, setTargetColumnId] = useState("");
|
|
const [addError, setAddError] = useState<string | null>(null);
|
|
const [targetColumnError, setTargetColumnError] = useState<string | null>(null);
|
|
const [pendingMutation, setPendingMutation] = useState<"exclude" | "delete" | null>(null);
|
|
const originRef = useRef<HTMLButtonElement | null>(null);
|
|
const statusFilterRef = useRef<HTMLSelectElement | null>(null);
|
|
const activeRelationship = activeRelationshipId
|
|
? data.find((relationship) => relationship.id === activeRelationshipId)
|
|
: undefined;
|
|
const visibleRelationships = useMemo(() => statusFilter === "all"
|
|
? data
|
|
: data.filter((relationship) => relationship.status === statusFilter), [data, statusFilter]);
|
|
const tablesQuery = useQuery({
|
|
queryKey: ["catalog-tables", databaseId],
|
|
queryFn: () => listCatalogTables(databaseId),
|
|
enabled: addOpen,
|
|
retry: false,
|
|
});
|
|
const sourceColumnsQuery = useQuery({
|
|
queryKey: ["catalog-columns", databaseId, sourceTableId],
|
|
queryFn: () => listCatalogColumns(databaseId, sourceTableId),
|
|
enabled: addOpen && Boolean(sourceTableId),
|
|
retry: false,
|
|
});
|
|
const targetColumnsQuery = useQuery({
|
|
queryKey: ["catalog-columns", databaseId, targetTableId],
|
|
queryFn: () => listCatalogColumns(databaseId, targetTableId),
|
|
enabled: addOpen && Boolean(targetTableId),
|
|
retry: false,
|
|
});
|
|
const targetColumns = (targetColumnsQuery.data ?? []).filter((column) => column.isPrimaryKey);
|
|
useEffect(() => {
|
|
if (!pendingMutation) return;
|
|
const timer = window.setTimeout(() => document
|
|
.getElementById("relationship-mutation-confirm-button")?.focus(), 0);
|
|
return () => window.clearTimeout(timer);
|
|
}, [pendingMutation]);
|
|
useEffect(() => {
|
|
onNavigationStateChange?.({ dirty: false, busy });
|
|
return () => onNavigationStateChange?.({ dirty: false, busy: false });
|
|
}, [busy, onNavigationStateChange]);
|
|
const selectedActionUnavailable = !selectedAction
|
|
? null
|
|
: !canManage
|
|
? "You do not have permission to manage catalog relationships."
|
|
: database.activeSyncRun
|
|
? "Wait for the active synchronization to finish."
|
|
: null;
|
|
const addUnavailable = !canManage
|
|
? "You do not have permission to add catalog relationships."
|
|
: database.activeSyncRun
|
|
? "Wait for the active synchronization to finish."
|
|
: busy
|
|
? "Wait for the current relationship operation to finish."
|
|
: null;
|
|
|
|
const synchronize = async (scope: "relationships") => {
|
|
setBusy(true);
|
|
try {
|
|
const run = await startCatalogSync(databaseId, database.version, scope);
|
|
onRunStarted(run);
|
|
toast.success("Relationship synchronization started");
|
|
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
|
|
};
|
|
|
|
const openRelationship = (relationship: CatalogRelationship, origin: HTMLButtonElement) => {
|
|
originRef.current = origin;
|
|
setPendingMutation(null);
|
|
setAddOpen(false);
|
|
setActiveRelationshipId(relationship.id);
|
|
};
|
|
|
|
const closeRelationship = () => {
|
|
if (busy) return;
|
|
setPendingMutation(null);
|
|
setActiveRelationshipId(null);
|
|
window.setTimeout(() => {
|
|
if (originRef.current?.isConnected) originRef.current.focus();
|
|
else statusFilterRef.current?.focus();
|
|
}, 0);
|
|
};
|
|
|
|
const changeRelationshipStatus = async (status: "active" | "excluded") => {
|
|
if (!activeRelationship || activeRelationship.origin === "physical") return;
|
|
setBusy(true);
|
|
try {
|
|
await setCatalogRelationshipStatus(databaseId, activeRelationship.id, status);
|
|
await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true });
|
|
await onCatalogMetricsChanged?.();
|
|
setPendingMutation(null);
|
|
setActiveRelationshipId(null);
|
|
window.setTimeout(() => {
|
|
if (originRef.current?.isConnected) originRef.current.focus();
|
|
else statusFilterRef.current?.focus();
|
|
}, 0);
|
|
toast.success(status === "excluded"
|
|
? "Relationship excluded. Future builds will leave it excluded."
|
|
: "Relationship restored.");
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const permanentlyDeleteRelationship = async () => {
|
|
if (!activeRelationship || activeRelationship.origin === "physical") return;
|
|
setBusy(true);
|
|
try {
|
|
await deleteCatalogRelationship(databaseId, activeRelationship.id);
|
|
await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true });
|
|
await onCatalogMetricsChanged?.();
|
|
setPendingMutation(null);
|
|
setActiveRelationshipId(null);
|
|
window.setTimeout(() => {
|
|
if (originRef.current?.isConnected) originRef.current.focus();
|
|
else statusFilterRef.current?.focus();
|
|
}, 0);
|
|
toast.success("Relationship deleted permanently. A future build may infer it again.");
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const resetAddForm = () => {
|
|
setSourceTableId("");
|
|
setSourceColumnId("");
|
|
setTargetTableId("");
|
|
setTargetColumnId("");
|
|
setAddError(null);
|
|
setTargetColumnError(null);
|
|
};
|
|
|
|
const closeAdd = () => {
|
|
if (busy) return;
|
|
setAddOpen(false);
|
|
resetAddForm();
|
|
};
|
|
|
|
const addRelationship = async () => {
|
|
if (!sourceColumnId || !targetColumnId) return;
|
|
setBusy(true);
|
|
setAddError(null);
|
|
setTargetColumnError(null);
|
|
try {
|
|
await createCatalogRelationship(databaseId, sourceColumnId, targetColumnId);
|
|
await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true });
|
|
await onCatalogMetricsChanged?.();
|
|
setAddOpen(false);
|
|
resetAddForm();
|
|
toast.success("Relationship added.");
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.code === "relationship_target_not_unique") {
|
|
setTargetColumnError("The target column must be the only primary-key column of its table.");
|
|
} else if (error instanceof ApiError && error.code === "relationship_type_incompatible") {
|
|
setTargetColumnError("Source and target column types are not compatible.");
|
|
} else if (error instanceof ApiError && error.code === "relationship_duplicate") {
|
|
setAddError("This relationship already exists.");
|
|
} else {
|
|
setAddError(apiErrorMessage(error));
|
|
}
|
|
toast.error(apiErrorMessage(error));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const runSelectedAction = async () => {
|
|
if (!selectedAction) return;
|
|
if (selectedAction === "rebuild-generated") {
|
|
setBusy(true);
|
|
try {
|
|
const result = await rebuildGeneratedRelationships(databaseId);
|
|
await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true });
|
|
await onCatalogMetricsChanged?.();
|
|
const noFindings = result.added === 0
|
|
&& result.alreadyPresent === 0
|
|
&& result.excluded === 0
|
|
&& result.ambiguous === 0;
|
|
toast.success(noFindings
|
|
? "Build complete: no new relationships found."
|
|
: `Build complete: ${result.added} added, ${result.alreadyPresent} already present, ${result.excluded} excluded, ${result.ambiguous} ambiguous.`);
|
|
} catch (error) {
|
|
toast.error(apiErrorMessage(error));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
} else {
|
|
await synchronize(selectedAction);
|
|
}
|
|
setSelectedAction("");
|
|
};
|
|
|
|
const columns = useMemo<ColDef<CatalogRelationship>[]>(() => [
|
|
...(presentation === "legacy" ? [{ field: "constraintName", headerName: "Constraint", minWidth: 220, flex: 1, cellClass: "font-mono text-xs" } satisfies ColDef<CatalogRelationship>] : []),
|
|
{ field: "sourceTableName", headerName: "Source table", minWidth: 200, flex: 1 },
|
|
{ headerName: "Source column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" },
|
|
{ field: "targetTableName", headerName: "Target table", minWidth: 200, flex: 1 },
|
|
{ headerName: "Target column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
|
|
{ field: "origin", headerName: "Origin", minWidth: 120, width: 120, valueFormatter: ({ value }) => titleCase(String(value ?? "")) },
|
|
{ field: "status", headerName: "Status", minWidth: 110, width: 110, valueFormatter: ({ value }) => titleCase(String(value ?? "")) },
|
|
...(presentation === "legacy" ? [
|
|
{ field: "updateRule", headerName: "On update", minWidth: 125, width: 125 },
|
|
{ field: "deleteRule", headerName: "On delete", minWidth: 125, width: 125 },
|
|
] satisfies ColDef<CatalogRelationship>[] : []),
|
|
...(presentation === "fleet" ? [{ colId: "actions", headerName: "Actions", width: 64, minWidth: 64, maxWidth: 64, pinned: "right" as const, lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: RelationshipActionsCell }] : []),
|
|
], [presentation]);
|
|
const context = useMemo<RelationshipGridContext>(() => ({ presentation, onView: openRelationship }), [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 relationships 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 relationships
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<section aria-label={`Relationships for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
|
{presentation === "legacy" ? <div className="px-4 pt-5 sm:px-5">
|
|
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={onBackToDatabases}><ArrowLeft />Back to databases</Button>
|
|
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
|
<div>
|
|
<p className="thot-label mb-1">Database management</p>
|
|
<h2 className="font-heading text-2xl font-semibold tracking-tight">{database.workspaceName}</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">{database.databaseName} · {database.schema}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{database.activeSyncRun ? <Button type="button" variant="outline" onClick={onOpenSync}><RefreshCw className="animate-spin" />View sync</Button> : null}
|
|
<Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>
|
|
</div>
|
|
</div>
|
|
<nav aria-label="Database sections" className="flex gap-1 border-b border-border" role="tablist">
|
|
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenOverview}>Overview</button>
|
|
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenTables}>Tables</button>
|
|
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">Relationships</button>
|
|
</nav>
|
|
</div> : null}
|
|
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}>
|
|
<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`}>
|
|
<span className="thot-label whitespace-nowrap">Relationship map</span>
|
|
<label className="sr-only" htmlFor="relationship-status-filter">Relationship status</label>
|
|
<select
|
|
ref={statusFilterRef}
|
|
id="relationship-status-filter"
|
|
className="h-8 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="Relationship status"
|
|
value={statusFilter}
|
|
onChange={(event) => setStatusFilter(event.target.value as RelationshipStatusFilter)}
|
|
>
|
|
<option value="active">Active</option>
|
|
<option value="excluded">Excluded</option>
|
|
<option value="all">All</option>
|
|
</select>
|
|
<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 relationships" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
|
|
<span className="text-xs tabular-nums text-muted-foreground">{visibleRelationships.length}</span>
|
|
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
|
{presentation === "fleet" ? (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="thot-fleet-action-selector__scope" aria-live="polite">
|
|
<strong>{selectedAction === "relationships" ? "Physical relationships in this database" : "This database"}</strong>
|
|
<span>Action scope</span>
|
|
</div>
|
|
<label className="sr-only" htmlFor="relationship-action">Relationship action</label>
|
|
<select
|
|
id="relationship-action"
|
|
className="thot-fleet-action-selector__select"
|
|
value={selectedAction}
|
|
disabled={busy}
|
|
onChange={(event) => setSelectedAction(event.target.value as "" | "rebuild-generated" | "relationships")}
|
|
>
|
|
<option value="">Choose an action…</option>
|
|
<optgroup label="Logical relationships">
|
|
<option value="rebuild-generated">Build generated relationships</option>
|
|
</optgroup>
|
|
<optgroup label="Synchronization">
|
|
<option value="relationships">Synchronize physical relationships</option>
|
|
</optgroup>
|
|
</select>
|
|
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? "Building…" : "Running…" : "Run action"}</Button>
|
|
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
|
|
{database.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
|
|
</Button>
|
|
{onOpenDescriptionHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenDescriptionHistory}><History aria-hidden="true" /> Description history</Button> : null}
|
|
{onOpenSensitiveHistory ? <Button type="button" size="sm" variant="outline" onClick={onOpenSensitiveHistory}><History aria-hidden="true" /> Sensitive history</Button> : null}
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={Boolean(addUnavailable)}
|
|
aria-describedby={addUnavailable ? "relationship-add-reason" : undefined}
|
|
onClick={() => {
|
|
setActiveRelationshipId(null);
|
|
setPendingMutation(null);
|
|
setAddOpen(true);
|
|
}}
|
|
>
|
|
<Plus aria-hidden="true" /> Add relationship
|
|
</Button>
|
|
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{addUnavailable}</span> : null}
|
|
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
|
|
</div>
|
|
) : <Button type="button" disabled={!canManage || busy || Boolean(database.activeSyncRun)} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</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<CatalogRelationship> rowData={visibleRelationships} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate={statusFilter === "active" ? "No relationships yet. Build from column names or add one manually." : statusFilter === "excluded" ? "No excluded relationships." : "No relationships for this database."} />
|
|
</div>
|
|
</div>}
|
|
</div>
|
|
{presentation === "fleet" && addOpen ? (
|
|
<FleetLedgerDrawer
|
|
open
|
|
eyebrow="Logical relationship"
|
|
title="Add relationship"
|
|
description="Connect one source column to one primary-key target column."
|
|
onClose={closeAdd}
|
|
closeLabel="Close relationship form"
|
|
busy={busy}
|
|
footer={(
|
|
<>
|
|
<Button type="button" variant="outline" disabled={busy} onClick={closeAdd}>Cancel</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={busy || !sourceTableId || !sourceColumnId || !targetTableId || !targetColumnId}
|
|
onClick={() => void addRelationship()}
|
|
>
|
|
{busy ? "Adding…" : "Add relationship"}
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
<div className="grid gap-4 text-sm sm:grid-cols-2">
|
|
<label className="grid gap-1.5">
|
|
<span className="font-semibold">Source table</span>
|
|
<select
|
|
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
|
|
aria-label="Source table"
|
|
value={sourceTableId}
|
|
disabled={busy || tablesQuery.isLoading}
|
|
onChange={(event) => {
|
|
setSourceTableId(event.target.value);
|
|
setSourceColumnId("");
|
|
setAddError(null);
|
|
}}
|
|
>
|
|
<option value="">Select source table…</option>
|
|
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.id}>{table.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="grid gap-1.5">
|
|
<span className="font-semibold">Source column</span>
|
|
<select
|
|
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
|
|
aria-label="Source column"
|
|
value={sourceColumnId}
|
|
disabled={busy || !sourceTableId || sourceColumnsQuery.isLoading}
|
|
onChange={(event) => {
|
|
setSourceColumnId(event.target.value);
|
|
setAddError(null);
|
|
setTargetColumnError(null);
|
|
}}
|
|
>
|
|
<option value="">Select source column…</option>
|
|
{(sourceColumnsQuery.data ?? []).map((column) => <option key={column.id} value={column.id}>{column.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="grid gap-1.5">
|
|
<span className="font-semibold">Target table</span>
|
|
<select
|
|
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
|
|
aria-label="Target table"
|
|
value={targetTableId}
|
|
disabled={busy || tablesQuery.isLoading}
|
|
onChange={(event) => {
|
|
setTargetTableId(event.target.value);
|
|
setTargetColumnId("");
|
|
setAddError(null);
|
|
setTargetColumnError(null);
|
|
}}
|
|
>
|
|
<option value="">Select target table…</option>
|
|
{(tablesQuery.data ?? []).map((table) => <option key={table.id} value={table.id}>{table.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="grid gap-1.5">
|
|
<span className="font-semibold">Target column</span>
|
|
<select
|
|
className="h-9 rounded-md border border-input bg-background px-3 outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
|
|
aria-label="Target column"
|
|
aria-invalid={Boolean(targetColumnError)}
|
|
aria-describedby={targetColumnError ? "target-column-error" : undefined}
|
|
value={targetColumnId}
|
|
disabled={busy || !targetTableId || targetColumnsQuery.isLoading}
|
|
onChange={(event) => {
|
|
setTargetColumnId(event.target.value);
|
|
setAddError(null);
|
|
setTargetColumnError(null);
|
|
}}
|
|
>
|
|
<option value="">Select target column…</option>
|
|
{targetColumns.map((column) => <option key={column.id} value={column.id}>{column.name}</option>)}
|
|
</select>
|
|
{targetColumnError ? <span id="target-column-error" className="text-xs text-destructive" role="alert">{targetColumnError}</span> : null}
|
|
</label>
|
|
{tablesQuery.isError || sourceColumnsQuery.isError || targetColumnsQuery.isError ? (
|
|
<p className="sm:col-span-2 text-sm text-destructive" role="alert">Catalog tables or columns could not be loaded.</p>
|
|
) : null}
|
|
{addError ? <p className="sm:col-span-2 text-sm text-destructive" role="alert">{addError}</p> : null}
|
|
</div>
|
|
</FleetLedgerDrawer>
|
|
) : null}
|
|
{presentation === "fleet" && activeRelationship ? (
|
|
<FleetLedgerDrawer
|
|
open
|
|
eyebrow={`${titleCase(activeRelationship.origin)} relationship`}
|
|
title="Relationship details"
|
|
description={activeRelationship.origin === "physical"
|
|
? "This information is synchronized from the source schema and is read-only."
|
|
: activeRelationship.origin === "generated"
|
|
? "This relationship was inferred from catalog column names."
|
|
: "This relationship was added manually."}
|
|
onClose={closeRelationship}
|
|
closeLabel="Close relationship details"
|
|
busy={busy}
|
|
footer={pendingMutation ? (
|
|
<div
|
|
className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
|
|
role="group"
|
|
aria-labelledby="relationship-mutation-confirmation"
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape" && !busy) setPendingMutation(null);
|
|
}}
|
|
>
|
|
<p id="relationship-mutation-confirmation" className="mr-auto max-w-xl text-sm text-muted-foreground">
|
|
{pendingMutation === "exclude"
|
|
? "The relationship will move to Excluded. Builds will not recreate it, and you can restore it later."
|
|
: "The relationship record will be erased. A future build may infer it again."}
|
|
</p>
|
|
<Button type="button" variant="ghost" disabled={busy} onClick={() => setPendingMutation(null)}>Cancel</Button>
|
|
<Button
|
|
id="relationship-mutation-confirm-button"
|
|
type="button"
|
|
variant={pendingMutation === "delete" ? "destructive" : "default"}
|
|
disabled={busy}
|
|
onClick={() => void (pendingMutation === "exclude"
|
|
? changeRelationshipStatus("excluded")
|
|
: permanentlyDeleteRelationship())}
|
|
>
|
|
{pendingMutation === "exclude" ? "Exclude relationship" : "Delete permanently"}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Button type="button" variant="outline" disabled={busy} onClick={closeRelationship}>Close</Button>
|
|
{activeRelationship.origin !== "physical" && activeRelationship.status === "active" ? (
|
|
<Button type="button" variant="outline" disabled={!canManage || busy} onClick={() => setPendingMutation("exclude")}>Exclude</Button>
|
|
) : null}
|
|
{activeRelationship.origin !== "physical" && activeRelationship.status === "excluded" ? (
|
|
<Button type="button" disabled={!canManage || busy} onClick={() => void changeRelationshipStatus("active")}>Restore</Button>
|
|
) : null}
|
|
{activeRelationship.origin !== "physical" ? (
|
|
<Button type="button" variant="destructive" disabled={!canManage || busy} onClick={() => setPendingMutation("delete")}>Delete permanently</Button>
|
|
) : null}
|
|
</>
|
|
)}
|
|
>
|
|
<dl className="grid gap-4 text-sm sm:grid-cols-2">
|
|
<div><dt className="thot-label">Origin</dt><dd className="mt-1">{titleCase(activeRelationship.origin)}</dd></div>
|
|
<div><dt className="thot-label">Status</dt><dd className="mt-1">{titleCase(activeRelationship.status)}</dd></div>
|
|
{activeRelationship.constraintName ? <div className="sm:col-span-2"><dt className="thot-label">Constraint</dt><dd className="mt-1 break-words font-mono text-xs">{activeRelationship.constraintName}</dd></div> : null}
|
|
<div><dt className="thot-label">Source table</dt><dd className="mt-1 break-words">{activeRelationship.sourceTableName}</dd></div>
|
|
<div><dt className="thot-label">Target table</dt><dd className="mt-1 break-words">{activeRelationship.targetTableName}</dd></div>
|
|
{activeRelationship.origin === "physical" ? <><div><dt className="thot-label">On update</dt><dd className="mt-1">{activeRelationship.updateRule ?? "Not specified"}</dd></div><div><dt className="thot-label">On delete</dt><dd className="mt-1">{activeRelationship.deleteRule ?? "Not specified"}</dd></div></> : null}
|
|
<div className="sm:col-span-2">
|
|
<dt className="thot-label">Column mapping</dt>
|
|
<dd className="mt-2 overflow-hidden rounded-md border border-border">
|
|
<table className="w-full text-left text-xs">
|
|
<thead className="bg-muted/60 font-sans text-muted-foreground"><tr><th className="px-3 py-2">Source</th><th className="px-3 py-2">Target</th></tr></thead>
|
|
<tbody>{activeRelationship.columns.map((pair) => <tr key={`${pair.sourceColumnName}:${pair.targetColumnName}`} className="border-t border-border"><td className="px-3 py-2 font-mono">{pair.sourceColumnName}</td><td className="px-3 py-2 font-mono">{pair.targetColumnName}</td></tr>)}</tbody>
|
|
</table>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</FleetLedgerDrawer>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|