import { useEffect, useState, useMemo, useCallback } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { ReactFlow, Background, Controls, useNodesState, useEdgesState, MarkerType, type Node, type Edge, ConnectionLineType } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import dagre from "dagre"; import { api } from "../lib/api"; import { Button } from "../components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"; import { Label } from "../components/ui/label"; import { ScrollArea } from "../components/ui/scroll-area"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../components/ui/dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { ArrowLeft, Table as TableIcon } from "lucide-react"; import { TableNode } from "../components/modeling/TableNode"; interface RawSchema { [table: string]: { name: string; type: string }[]; } interface Column { name: string; type: string; isCalculated: boolean; relationship?: string; expression?: string; properties?: Record; } interface Model { name: string; columns: Column[]; primaryKey?: string; properties?: Record; } interface Relationship { name: string; models: string[]; joinType: string; condition: string; } interface MDLManifest { catalog: string; schema: string; dataSource: string; models: Model[]; relationships: Relationship[]; } interface ModelDetailResponse { model: { name: string; tableReference?: { table: string; schema?: string; catalog?: string; } | null; primaryKey?: string; properties?: Record; columns: Column[]; }; relationships: { name: string; models: string[]; joinType: string; condition: string; properties?: Record; }[]; preview_rows: Record[]; } const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { // If there are few or no edges, use grid layout to spread out nodes if (edges.length === 0 || edges.length < nodes.length * 0.3) { const COLUMNS = 4; const ROW_HEIGHT = 400; // Height per row including spacing const COL_WIDTH = 300; // Width per column including spacing return { nodes: nodes.map((node, index) => { const col = index % COLUMNS; const row = Math.floor(index / COLUMNS); return { ...node, position: { x: col * COL_WIDTH, y: row * ROW_HEIGHT, }, }; }), edges, }; } // Otherwise use Dagre for connected graphs dagreGraph.setGraph({ rankdir: 'TB', nodesep: 100, ranksep: 120 }); nodes.forEach((node) => { // Estimating height based on column count const height = 50 + (node.data.columns as Column[]).length * 28; dagreGraph.setNode(node.id, { width: 240, height }); }); edges.forEach((edge) => { dagreGraph.setEdge(edge.source, edge.target); }); dagre.layout(dagreGraph); const layoutedNodes = nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id); return { ...node, position: { x: nodeWithPosition.x - 120, // center offset (width/2) y: nodeWithPosition.y - (nodeWithPosition.height / 2), }, }; }); return { nodes: layoutedNodes, edges }; }; export function Modeling() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [loading, setLoading] = useState(true); const [schema, setSchema] = useState(null); const [mdl, setMdl] = useState(null); const [selectedTables, setSelectedTables] = useState([]); const [selectedColumns, setSelectedColumns] = useState>({}); const [expandedTables, setExpandedTables] = useState>({}); const [step, setStep] = useState<"select" | "view">("select"); const [detailOpen, setDetailOpen] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [modelDetail, setModelDetail] = useState(null); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const nodeTypes = useMemo(() => ({ table: TableNode }), []); // Save layout to localStorage when nodes change (dragged) const onNodeDragStop = useCallback(() => { if (nodes.length > 0) { const layoutData = nodes.map(n => ({ id: n.id, position: n.position })); localStorage.setItem(`er-layout-${id}`, JSON.stringify(layoutData)); } }, [nodes, id]); useEffect(() => { fetchInitialData(); }, [id]); useEffect(() => { if (step === 'view' && mdl) { // Try to load saved layout const savedLayoutStr = localStorage.getItem(`er-layout-${id}`); let savedPositions: Record = {}; if (savedLayoutStr) { try { const parsed = JSON.parse(savedLayoutStr); if (Array.isArray(parsed)) { parsed.forEach((item: any) => { if (item.id && item.position) { savedPositions[item.id] = item.position; } }); } } catch (e) { console.error("Failed to parse saved layout", e); } } const initialNodes: Node[] = mdl.models.map((model) => ({ id: model.name, type: 'table', position: savedPositions[model.name] || { x: 0, y: 0 }, data: { name: model.name, columns: model.columns, onDetailClick: openModelDetail }, })); const initialEdges: Edge[] = mdl.relationships.map((rel, index) => { // Assuming rel.models has at least 2 elements if (rel.models.length < 2) return null; return { id: `e-${index}`, source: rel.models[0], target: rel.models[1], type: ConnectionLineType.SmoothStep, animated: false, label: rel.joinType, style: { stroke: '#94a3b8' }, labelStyle: { fill: '#64748b', fontSize: 11 }, markerEnd: { type: MarkerType.ArrowClosed, color: '#94a3b8', }, }; }).filter(Boolean) as Edge[]; // Only run auto-layout if we don't have saved positions for most nodes // or if user explicitly requests it (future feature) const hasSavedLayout = Object.keys(savedPositions).length >= initialNodes.length * 0.5; if (hasSavedLayout) { setNodes(initialNodes); setEdges(initialEdges); } else { const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( initialNodes, initialEdges ); setNodes(layoutedNodes); setEdges(layoutedEdges); } } }, [step, mdl, id]); const handleAutoLayout = () => { if (!mdl) return; const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( nodes, edges ); setNodes([...layoutedNodes]); setEdges([...layoutedEdges]); // Clear saved layout to prefer auto layout localStorage.removeItem(`er-layout-${id}`); }; const initSelectionFromSchema = (schemaRes: RawSchema) => { const tableNames = Object.keys(schemaRes); const columnsMap: Record = {}; const expanded: Record = {}; for (const tableName of tableNames) { columnsMap[tableName] = schemaRes[tableName].map((c) => c.name); expanded[tableName] = true; } setSchema(schemaRes); setSelectedTables(tableNames); setSelectedColumns(columnsMap); setExpandedTables(expanded); }; const fetchSchemaOnly = async () => { const schemaRes = await api.get(`/api/v1/semantic/${id}/schema`) as RawSchema; initSelectionFromSchema(schemaRes); setStep("select"); }; const fetchInitialData = async () => { try { setLoading(true); const mdlRes = await api.get(`/api/v1/semantic/${id}`) as any; if (mdlRes && mdlRes.models && mdlRes.models.length > 0) { setMdl(mdlRes as MDLManifest); setStep("view"); } else { await fetchSchemaOnly(); } } catch (error) { console.error("Failed to fetch modeling data:", error); try { await fetchSchemaOnly(); } catch (e) { console.error("Failed to fetch schema:", e); } } finally { setLoading(false); } }; const handleGenerate = async () => { try { setLoading(true); const res = await api.post(`/api/v1/semantic/${id}/generate`, { selected_tables: selectedTables, selected_columns: Object.fromEntries( selectedTables.map((table) => [table, selectedColumns[table] ?? []]) ), }) as MDLManifest; setMdl(res); setStep("view"); } catch (error) { console.error("Failed to generate MDL:", error); } finally { setLoading(false); } }; const toggleTable = (table: string) => { setSelectedTables((prev) => prev.includes(table) ? prev.filter((t) => t !== table) : [...prev, table] ); if (!schema) return; if (!selectedTables.includes(table) && (!selectedColumns[table] || selectedColumns[table].length === 0)) { setSelectedColumns((prev) => ({ ...prev, [table]: schema[table].map((c) => c.name), })); } }; const toggleColumn = (table: string, column: string) => { setSelectedColumns((prev) => { const current = prev[table] ?? []; const has = current.includes(column); const next = has ? current.filter((c) => c !== column) : [...current, column]; return { ...prev, [table]: next }; }); setSelectedTables((prev) => { const exists = prev.includes(table); const current = selectedColumns[table] ?? []; const has = current.includes(column); const nextLen = has ? current.length - 1 : current.length + 1; if (nextLen <= 0) { return prev.filter((t) => t !== table); } if (!exists) { return [...prev, table]; } return prev; }); }; const toggleExpandTable = (table: string) => { setExpandedTables((prev) => ({ ...prev, [table]: !prev[table] })); }; const handleSelectAll = () => { if (!schema) return; const tableNames = Object.keys(schema); setSelectedTables(tableNames); setSelectedColumns( Object.fromEntries( tableNames.map((table) => [table, schema[table].map((c) => c.name)]) ) ); }; const handleClearAll = () => { setSelectedTables([]); setSelectedColumns({}); }; const handleReselectTables = async () => { try { setLoading(true); await fetchSchemaOnly(); } finally { setLoading(false); } }; const openModelDetail = async (modelName: string) => { try { setDetailOpen(true); setDetailLoading(true); const detail = await api.get( `/api/v1/semantic/${id}/models/${encodeURIComponent(modelName)}?limit=10` ); setModelDetail(detail); } catch (error) { console.error("Failed to fetch model detail:", error); setModelDetail(null); } finally { setDetailLoading(false); } }; if (loading) { return
Loading modeling data...
; } return (
{/* Header */}

Data Modeling

DataSource ID: {id} • {step === "select" ? "Select Tables" : "Entity Relationship Diagram"}

{step === "view" && (
)}
{/* Content */}
{step === "select" ? (
Select tables to create data models

Choose the tables you want to include in your semantic model.

{selectedTables.length} / {schema ? Object.keys(schema).length : 0} selected
{schema && Object.keys(schema).map((table) => (
toggleTable(table)} />
{expandedTables[table] && (
{schema[table].map((col) => ( ))}
)}
))}
) : (
{/* Sidebar List */} Models ({mdl?.models.length})
{mdl?.models.map((model) => (
openModelDetail(model.name)} > {model.name}
))}
{/* Canvas Area (ReactFlow) */}
)}
{modelDetail?.model?.name ?? "Model Detail"} {detailLoading ? (
Loading model detail...
) : !modelDetail ? (
No metadata available.
) : (
Columns Metadata
Name Type Description {modelDetail.model.columns.map((col) => ( {col.name} {col.type} {String(col.properties?.description ?? "-")} ))}
Relationships ({modelDetail.relationships.length})
Name Models Type Condition {modelDetail.relationships.map((rel) => ( {rel.name} {rel.models.join(" ↔ ")} {rel.joinType} {rel.condition} ))}
Data Preview (Top 10)
{modelDetail.preview_rows.length === 0 ? (
No preview data.
) : ( {Object.keys(modelDetail.preview_rows[0]).map((key) => ( {key} ))} {modelDetail.preview_rows.map((row, idx) => ( {Object.keys(modelDetail.preview_rows[0]).map((key) => ( {String(row[key] ?? "")} ))} ))}
)}
)}
); }