import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import ReactFlow, { MiniMap, Controls, Background, useNodesState, useEdgesState, addEdge, Panel, useReactFlow, ReactFlowProvider, Handle, Position, BaseEdge, EdgeLabelRenderer, getBezierPath } from 'reactflow'; import 'reactflow/dist/style.css'; // Import the TableCreationPopup component import TableCreationPopup from './TableCreationPopup'; // Add custom CSS for React Flow const generateCustomStyles = () => { return ` .react-flow__node { z-index: 1; margin: 20px; /* Add margin to all nodes */ } /* Ensure tables don't overlap */ .react-flow__node.table-node { margin: 30px; min-height: 120px; } .schema-background-node { z-index: -1; pointer-events: all; border-radius: 25px; cursor: grab; user-select: none; transition: all 0.2s ease; padding: 30px; /* Add padding inside schema */ } .schema-background-node:hover { box-shadow: 0 0 30px rgba(0, 0, 0, 0.08); } .schema-background-node:active { cursor: grabbing; } .schema-background-node.dragging { opacity: 0.8; } .schema-label { position: absolute; top: 20px; left: 20px; background: rgba(255, 255, 255, 0.9); padding: 10px 15px; border-radius: 8px; font-size: 14px; font-weight: bold; box-shadow: 0 4px 8px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 10px; z-index: 10; } /* Ensure proper spacing between nodes */ .react-flow__node-table { margin-bottom: 30px !important; margin-right: 30px !important; } /* Styling for draggable new tables */ .draggable-new-table { cursor: grab !important; transition: all 0.2s ease !important; z-index: 10 !important; } .draggable-new-table:hover { transform: scale(1.02) !important; box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important; } .draggable-new-table:active { cursor: grabbing !important; } /* Process node styling */ .react-flow__node-process { z-index: 5 !important; background: white !important; border-radius: 8px !important; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important; } /* Edge styling for left-to-right data flow */ .react-flow__edge { z-index: 4 !important; } .react-flow__edge-path { stroke-width: 1.5 !important; } /* Encourage horizontal edges for left-to-right flow */ .react-flow__edge-path { --react-flow-bezier-edge-control-point-distance: 60 !important; } /* Edge label styling */ .react-flow__edge-textbg { background-color: white !important; padding: 2px !important; border-radius: 4px !important; } .react-flow__edge-text { font-size: 8px !important; } `; }; // Import icons from react-icons import { FaTable, FaArrowRight, FaExchangeAlt, FaFilter, FaCode, FaSync, FaCalculator, FaChartLine } from 'react-icons/fa'; import { BiSolidData, BiTransfer } from 'react-icons/bi'; import { AiFillFolder } from 'react-icons/ai'; import { TbTransform } from 'react-icons/tb'; import { CustomDatabaseIcon, CustomDocumentIcon, CustomDimensionIcon, CustomProcessIcon, CustomSchemaIcon } from './CustomIcons'; // Import API data import mockApiData, { useApiData } from './mockData'; // Import ProcessForm component import ProcessForm from './ProcessForm'; // Schema Background Node const SchemaBackgroundNode = ({ data }) => { // Different background colors for each schema const bgColor = data.slug === 'edw_schema' ? 'rgba(24, 144, 255, 0.1)' // Light blue for Schema_1 : 'rgba(82, 196, 26, 0.1)'; // Light green for Schema_2 // Border color based on schema const borderColor = data.slug === 'edw_schema' ? '#1890ff' // Blue for Schema_1 : '#52c41a'; // Green for Schema_2 return (
{data.name}
); }; // Custom edge with animated arrow const CustomEdge = ({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, data, markerEnd }) => { // For left-to-right data flow, use a gentler curve const curvature = 0.2; // Reduced curvature for straighter, more horizontal flow const [edgePath, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, curvature }); // Check if the process is active const isActive = data?.isActive !== false; // Default to active if not specified // Determine if this is a source or target edge const isSourceEdge = data?.edgeType === 'source'; // Get the process name and mappings const processName = data?.processName || 'Process'; const mappings = data?.mappings || []; // Adjust label style based on active status const labelBgColor = isActive ? 'white' : '#f5f5f5'; const labelOpacity = isActive ? 1 : 0.8; return ( <>
{/* Process name in small text */}
{processName} {!isActive && ( inactive )}
{/* Column mappings - show fewer columns to save space */}
{mappings.length > 0 ? ( // Only show up to 3 mappings to save space mappings.slice(0, 3).map((column, index) => ( {column} )) ) : ( No mappings )} {mappings.length > 3 && ( +{mappings.length - 3} more )}
); }; // Process Node (represents ETL or data transformation) const ProcessNode = ({ data, id }) => { // Function to handle process edit const handleProcessEdit = () => { // Find the process in the data // We'll use the data that's passed to the component directly if (data && data.fullProcessData) { // We'll use window.processEditCallback which will be set in the main component if (window.processEditCallback) { window.processEditCallback(data.fullProcessData); } } }; // Determine status color const statusColor = data.status === 'active' ? '#52c41a' : '#ff4d4f'; const isActive = data.status === 'active'; // Define colors const primaryColor = isActive ? '#fa8c16' : '#aaaaaa'; const secondaryColor = isActive ? '#ff4d4f' : '#cccccc'; // Determine which icon to use based on process type or other properties const getProcessIcon = () => { // You can customize this logic based on your data model const processType = data.processType || 'default'; const iconSize = 40; // Reduced icon size to prevent overlapping const iconColor = isActive ? primaryColor : '#cccccc'; switch(processType.toLowerCase()) { case 'transform': return ; case 'filter': return ; case 'aggregate': return ; case 'sync': return ; case 'code': return ; case 'analytics': return ; case 'transfer': return ; case 'exchange': return ; default: return ; } }; return (
{/* Connection handles */} {/* Process Icon with integrated content */}
{getProcessIcon()} {/* Status indicator (small dot) */}
{/* Process Label (below icon) */}
{data.label}
); }; // Table Node const TableNode = ({ data, id }) => { // Set up drag functionality for new tables const onDragStart = (event) => { // Only allow dragging for tables that are not in a schema yet if (!event.target.closest('.draggable-new-table')) { return; } event.dataTransfer.setData('application/reactflow', id); event.dataTransfer.effectAllowed = 'move'; }; // Determine table type const isStage = data.type === 'stage'; const isFact = data.type === 'fact'; const isDim = data.type === 'dimension'; // Different colors for different table types let tableColor, tableLabel, tableIcon; if (isStage) { tableColor = '#fa8c16'; // Orange for STAGE tables tableLabel = 'STAGE'; tableIcon = ; } else if (isFact) { tableColor = '#1890ff'; // Blue for FACT tables tableLabel = 'FACT'; tableIcon = ; } else { tableColor = '#52c41a'; // Green for DIM tables tableLabel = 'DIM'; tableIcon = ; } const background = '#ffffff'; return (
{/* Connection handles */}
{/* Different icons for different table types */} {tableIcon} {data.label}
{tableLabel}
{data.columns && (
Columns:
{data.columns.slice(0, 5).map((col, index) => ( {col} ))} {data.columns.length > 5 && ( +{data.columns.length - 5} more )}
)}
); }; const DataflowCanvas = () => { // React Flow refs and state const reactFlowWrapper = useRef(null); // const popupRef = useRef(null); const [reactFlowInstance, setReactFlowInstance] = useState(null); const { fitView, setViewport, getViewport } = useReactFlow(); // Get data from API const { data: apiData, loading, error } = useApiData(); // All state declarations must be at the top level // State for infinite canvas const [scale, setScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); // State for selected database const [selectedDatabase, setSelectedDatabase] = useState(null); // State for connection mode const [isConnectionMode, setIsConnectionMode] = useState(false); const [connectionSource, setConnectionSource] = useState(null); const [connectionType, setConnectionType] = useState('default'); // State for process form const [showProcessForm, setShowProcessForm] = useState(false); const [selectedProcessForEdit, setSelectedProcessForEdit] = useState(null); // State for process details popup const [showProcessPopup, setShowProcessPopup] = useState(false); const [selectedProcess, setSelectedProcess] = useState(null); const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 }); // State for table creation popup const [showTablePopup, setShowTablePopup] = useState(false); const [isDragging, setIsDragging] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); // Read selected database from localStorage on component mount useEffect(() => { try { const dbData = localStorage.getItem('selectedDatabase'); if (dbData) { const parsedData = JSON.parse(dbData); setSelectedDatabase(parsedData); console.log('DataFlow view initialized with database:', parsedData); } } catch (error) { console.error('Error reading database from localStorage:', error); } }, []); // Use API data when available useEffect(() => { if (apiData && !loading) { console.log('API data loaded successfully:', apiData); } }, [apiData, loading]); // Create a loading component const LoadingComponent = () => (
Loading data from API...
); // Log error but continue with mockApiData as fallback useEffect(() => { if (error) { console.error('Error loading API data:', error); } }, [error]); // Create a loading component // const LoadingComponent = () => ( //
//
Loading data from API...
//
// ); // Log error but continue with mockApiData as fallback useEffect(() => { if (error) { console.error('Error loading API data:', error); } }, [error]); // Set up a global callback for process editing // This is used by the ProcessNode component useEffect(() => { window.processEditCallback = (process) => { setSelectedProcessForEdit(process); setShowProcessForm(true); }; return () => { // Clean up when component unmounts window.processEditCallback = null; }; }, []); // Define node types const nodeTypes = useMemo(() => ({ table: TableNode, process: ProcessNode, schemaBackground: SchemaBackgroundNode, }), []); // Define edge types const edgeTypes = useMemo(() => ({ custom: CustomEdge, }), []); // Create initial nodes from tables data const initialNodes = useMemo(() => { // Calculate schema boundaries based on their tables const schemaBoundaries = {}; // Use API data when available, otherwise fall back to mock data const schemas = apiData?.schemas || mockApiData.schemas; const tables = apiData?.tables || mockApiData.tables; // Initialize with default values from schema definitions schemas.forEach(schema => { schemaBoundaries[schema.slug] = { minX: schema.position.x, minY: schema.position.y, maxX: schema.position.x + schema.width, maxY: schema.position.y + schema.height }; }); // Update boundaries based on table positions tables.forEach(table => { const schemaSlug = table.schema; if (schemaBoundaries[schemaSlug]) { // Add more padding around tables (250px on each side for better spacing) const tableMinX = table.orientation.x - 250; const tableMinY = table.orientation.y - 250; const tableMaxX = table.orientation.x + 450; // Table width + padding const tableMaxY = table.orientation.y + 450; // Table height + padding schemaBoundaries[schemaSlug].minX = Math.min(schemaBoundaries[schemaSlug].minX, tableMinX); schemaBoundaries[schemaSlug].minY = Math.min(schemaBoundaries[schemaSlug].minY, tableMinY); schemaBoundaries[schemaSlug].maxX = Math.max(schemaBoundaries[schemaSlug].maxX, tableMaxX); schemaBoundaries[schemaSlug].maxY = Math.max(schemaBoundaries[schemaSlug].maxY, tableMaxY); } }); // Schema background nodes (add these first so they appear behind other nodes) const schemaBackgroundNodes = schemas.map(schema => { const bounds = schemaBoundaries[schema.slug]; const width = bounds.maxX - bounds.minX; const height = bounds.maxY - bounds.minY; // Add 60% extra size for better spacing and to ensure all elements are contained const extraWidth = width * 0.6; const extraHeight = height * 0.6; return { id: `schema-bg-${schema.slug}`, type: 'schemaBackground', data: { name: schema.name, color: schema.color, slug: schema.slug }, position: { x: bounds.minX - (extraWidth / 2), y: bounds.minY - (extraHeight / 2) }, style: { width: width + extraWidth, height: height + extraHeight, zIndex: -1 // Ensure it's behind other nodes }, draggable: true, selectable: true, zIndex: -1, }; }); // Table nodes with additional spacing const tableNodes = tables.map(table => ({ id: table.slug, type: 'table', data: { label: table.name, type: table.type, columns: table.columns, slug: table.slug, schema: table.schema // Include schema information }, position: { x: table.orientation.x, y: table.orientation.y }, parentNode: `schema-bg-${table.schema}`, // Connect to parent schema extent: 'parent', // Keep within parent boundaries style: { marginBottom: '30px', // Add margin to prevent overlapping marginRight: '30px' // Add margin to prevent overlapping }, className: 'table-node' // Add class for styling })); return [...schemaBackgroundNodes, ...tableNodes]; }, []); // Create process nodes const processNodes = useMemo(() => { // Use API data when available, otherwise fall back to mock data const processes = apiData?.processes || mockApiData.processes; const tables = apiData?.tables || mockApiData.tables; return processes.map((process, index) => { // Calculate position between source and destination tables const sourceTable = tables.find(t => t.slug === process.source_table[0]); const destTable = tables.find(t => t.slug === process.destination_table[0]); let x = 300; let y = 200; let parentSchema = null; if (sourceTable && destTable) { // For a left-to-right data flow, position the process node between the source and destination // but slightly closer to the source to maintain the flow direction // Calculate the position 1/3 of the way from source to destination const sourceX = sourceTable.orientation.x; const sourceY = sourceTable.orientation.y; const destX = destTable.orientation.x; const destY = destTable.orientation.y; // Position process node at 1/3 of the distance from source to destination // This creates a clearer left-to-right flow x = sourceX + (destX - sourceX) / 3 + 100; // Add offset to position after source table // Keep the y-coordinate aligned with the source table for a cleaner look // If source and destination are at different y positions, adjust slightly if (Math.abs(sourceY - destY) > 50) { // If vertical difference is significant, position between them y = sourceY + (destY - sourceY) / 2; } else { // Otherwise keep aligned with source y = sourceY; } // Determine which schema this process belongs to // If source and destination are in the same schema, use that schema if (sourceTable.schema === destTable.schema) { parentSchema = sourceTable.schema; } else { // If they're in different schemas, use the source schema parentSchema = sourceTable.schema; } } else if (sourceTable) { parentSchema = sourceTable.schema; } else if (destTable) { parentSchema = destTable.schema; } return { id: process.slug, type: 'process', data: { label: process.name, description: process.description, type: process.type, status: process.status, processType: process.type, // Pass process type to the component fullProcessData: process // Include the full process data for editing }, position: { x, y }, parentNode: parentSchema ? `schema-bg-${parentSchema}` : undefined, extent: parentSchema ? 'parent' : undefined, style: { zIndex: 5, // Ensure process nodes are above edges margin: '20px', // Add margin to prevent overlapping background: 'white', // Ensure background is white borderRadius: '8px', // Rounded corners boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)' // Add shadow for better visibility }, className: 'process-node' // Add class for styling }; }); }, []); // Create edges between tables and processes const initialEdges = useMemo(() => { const edges = []; // Use API data when available, otherwise fall back to mock data const processes = apiData?.processes || mockApiData.processes; processes.forEach(process => { // Determine if process is active or inactive const isActive = process.status === 'active'; // Set colors and animation based on process status const sourceColor = isActive ? '#52c41a' : '#aaaaaa'; // Green or gray const destColor = isActive ? '#1890ff' : '#aaaaaa'; // Blue or gray const animated = isActive; // Only animate if active const strokeWidth = isActive ? 2 : 1.5; // Slightly thinner if inactive // Get the mappings for this process const mappings = process.mappings || []; // Format source columns for display const sourceColumns = mappings.map(m => m.source).join(', '); // Format target columns for display const targetColumns = mappings.map(m => m.target).join(', '); // Create edges from source tables to process process.source_table.forEach(sourceId => { edges.push({ id: `e-${sourceId}-${process.slug}`, source: sourceId, target: process.slug, type: 'custom', animated: animated, style: { stroke: sourceColor, strokeWidth: strokeWidth, opacity: isActive ? 0.9 : 0.6, // Reduced opacity for better visual clarity zIndex: 4 // Ensure edges are below nodes }, markerEnd: { type: 'arrowclosed', width: 15, // Smaller arrow height: 15, // Smaller arrow color: sourceColor, }, data: { label: sourceColumns || 'No mappings', processName: process.name, isActive: isActive, // Pass status to the edge component edgeType: 'source', mappings: mappings.map(m => m.source) } }); }); // Create edges from process to destination tables process.destination_table.forEach(destId => { edges.push({ id: `e-${process.slug}-${destId}`, source: process.slug, target: destId, type: 'custom', animated: animated, style: { stroke: destColor, strokeWidth: strokeWidth, opacity: isActive ? 0.9 : 0.6, // Reduced opacity for better visual clarity zIndex: 4 // Ensure edges are below nodes }, markerEnd: { type: 'arrowclosed', width: 15, // Smaller arrow height: 15, // Smaller arrow color: destColor, }, data: { label: targetColumns || 'No mappings', processName: process.name, isActive: isActive, // Pass status to the edge component edgeType: 'target', mappings: mappings.map(m => m.target) } }); }); }); return edges; }, []); // Use React Flow's node state management const [nodes, setNodes, onNodesChangeDefault] = useNodesState([...initialNodes, ...processNodes]); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); // Custom nodes change handler to update schema boundaries when nodes move const onNodesChange = useCallback( (changes) => { // Apply the default node changes onNodesChangeDefault(changes); // Check if any of the changes are position changes const hasPositionChanges = changes.some( change => change.type === 'position' && change.dragging ); // If there are position changes, force a re-render of the schema wrapper if (hasPositionChanges) { // Use a small timeout to ensure the node positions are updated first setTimeout(() => { setNodes((nds) => [...nds]); }, 0); } }, [onNodesChangeDefault, setNodes] ); // Track viewport changes const onMove = useCallback((event, viewport) => { setScale(viewport.zoom); setPosition({ x: viewport.x, y: viewport.y }); }, []); const onConnect = useCallback( (params) => { // Find the source and target nodes const sourceNode = nodes.find(node => node.id === params.source); const targetNode = nodes.find(node => node.id === params.target); // Determine if this is a process connection let isActive = true; // Check if either source or target is a process node if (sourceNode?.type === 'process') { isActive = sourceNode.data.status === 'active'; } else if (targetNode?.type === 'process') { isActive = targetNode.data.status === 'active'; } // Create a custom edge with the selected connection type const edgeColor = connectionType === 'reference' ? '#00a99d' : connectionType === 'dependency' ? '#ff4d4f' : '#722ed1'; // If inactive, use gray color const finalColor = isActive ? edgeColor : '#aaaaaa'; const edgeLabel = connectionType === 'reference' ? 'references' : connectionType === 'dependency' ? 'depends on' : 'connects to'; const newEdge = { ...params, id: `e-custom-${params.source}-${params.target}`, type: 'custom', animated: isActive, // Only animate if active style: { stroke: finalColor, strokeWidth: isActive ? 2 : 1.5, opacity: isActive ? 1 : 0.7 }, markerEnd: { type: 'arrowclosed', width: 20, height: 20, color: finalColor, }, data: { label: edgeLabel, isActive: isActive } }; // Add the new edge to the edges array setEdges(eds => [...eds, newEdge]); // If in connection mode, exit it after creating a connection if (isConnectionMode) { setIsConnectionMode(false); setConnectionSource(null); } console.log(`Connection created: ${params.source} -> ${params.target} (${connectionType}), Active: ${isActive}`); }, [setEdges, connectionType, isConnectionMode, nodes] ); // Handle connection creation const onConnectStart = (event, params) => { console.log('Connection started:', params); setConnectionSource(params); }; // Handle connection completion const onConnectEnd = (event) => { console.log('Connection ended'); }; // Initialize the flow const onInit = (instance) => { setReactFlowInstance(instance); // Set the viewport from the API data or fallback to mock data const viewportSettings = apiData?.viewportSettings || mockApiData.viewportSettings; const { x, y, zoom } = viewportSettings; instance.setViewport({ x, y, zoom }); setTimeout(() => { // Use a larger padding and lower maxZoom to ensure all nodes are visible fitView({ padding: 1.0, maxZoom: 0.4 }); }, 500); }; const popupRef = useRef(null); // Drag handlers for the popup const handleMouseDown = (e) => { if (popupRef.current && !e.target.closest('button')) { const rect = popupRef.current.getBoundingClientRect(); setIsDragging(true); setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top }); e.preventDefault(); } }; const handleMouseMove = (e) => { if (isDragging && popupRef.current) { const newX = e.clientX - dragOffset.x; const newY = e.clientY - dragOffset.y; // Keep popup within viewport bounds const rect = popupRef.current.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const boundedX = Math.max(0, Math.min(newX, viewportWidth - rect.width)); const boundedY = Math.max(0, Math.min(newY, viewportHeight - rect.height)); setPopupPosition({ x: boundedX, y: boundedY }); } }; const handleMouseUp = () => { setIsDragging(false); }; // Add and remove event listeners for dragging useEffect(() => { if (showProcessPopup) { window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; } }, [showProcessPopup, isDragging, dragOffset]); // Handle node click const onNodeClick = (event, node) => { console.log('Node clicked:', node); // Use API data when available, otherwise fall back to mock data const tables = apiData?.tables || mockApiData.tables; const processes = apiData?.processes || mockApiData.processes; // If it's a table node, show details if (node.type === 'table') { const table = tables.find(t => t.slug === node.id); if (table) { alert(`Table: ${table.name}\nType: ${table.type}\nColumns: ${table.columns.join(', ')}`); } } // If it's a process node, show details in a custom popup if (node.type === 'process') { // Use the full process data from the node data if available const process = node.data.fullProcessData || processes.find(p => p.slug === node.id); if (process) { // Get source and destination table names const sourceTables = process.source_table.map(slug => { const table = tables.find(t => t.slug === slug); return table ? table.name : slug; }); const destTables = process.destination_table.map(slug => { const table = tables.find(t => t.slug === slug); return table ? table.name : slug; }); // Add table names to the process object const processWithTableNames = { ...process, source_table_names: sourceTables, destination_table_names: destTables }; setSelectedProcess(processWithTableNames); // Position the popup near the node but not directly on top of it const nodeElement = document.querySelector(`[data-id="${node.id}"]`); if (nodeElement) { const rect = nodeElement.getBoundingClientRect(); setPopupPosition({ x: rect.right + 20, y: rect.top }); } else { setPopupPosition({ x: event.clientX, y: event.clientY }); } setShowProcessPopup(true); } } }; // Handle edit process button click in the popup const handleEditProcess = (process) => { setSelectedProcessForEdit(process); setShowProcessForm(true); setShowProcessPopup(false); }; // Show the table creation popup const addTableNode = () => { setShowTablePopup(true); }; // Handle table creation from the popup const handleCreateTable = (tableData) => { // Generate a unique ID for the table const tableId = `table-${Date.now()}`; // Create the new table node const newTable = { id: tableId, type: 'table', data: { label: tableData.name, type: tableData.type, columns: tableData.columns, slug: tableId // Add a slug for reference }, // Position the table outside any schema, at the top-left corner with some margin position: { x: 50, y: 50 }, // Make sure it's not initially part of any schema parentNode: undefined, extent: undefined, // Add draggable class to indicate it can be dragged into a schema className: 'table-node draggable-new-table', // Add styling to make it stand out style: { border: '2px dashed #1890ff', boxShadow: '0 0 10px rgba(24, 144, 255, 0.5)', zIndex: 10 } }; // Add the new table to the nodes setNodes(nodes => [...nodes, newTable]); }; // Get canvas boundaries for process placement const getSchemasBoundaries = () => { // Calculate based on existing nodes let minX = 100; let minY = 100; let maxX = 500; let maxY = 500; if (nodes.length > 0) { minX = Math.min(...nodes.map(node => node.position.x)); minY = Math.min(...nodes.map(node => node.position.y)); maxX = Math.max(...nodes.map(node => node.position.x + 200)); // Assuming node width is ~200px maxY = Math.max(...nodes.map(node => node.position.y + 200)); // Assuming node height is ~200px } return { minX, minY, maxX, maxY }; }; // We don't need the moveSchemaNodes function anymore // Open the process form to create a new process const addProcessNode = () => { setSelectedProcessForEdit(null); setShowProcessForm(true); }; // Handle saving a new process from the form const handleSaveProcess = (processData) => { const { minX, minY, maxX, maxY } = getSchemasBoundaries(); // Place the new process outside of all schemas // Either to the right of all schemas or below all schemas let newX, newY; // Get the current viewport to help position the new node in visible area const viewport = reactFlowInstance ? reactFlowInstance.getViewport() : { x: 0, y: 0, zoom: 1 }; // Determine placement position - right side is preferred newX = maxX + 200; newY = minY + (maxY - minY) / 2; // If we're editing an existing process, find it and update it if (selectedProcessForEdit) { const updatedNodes = nodes.map(node => { if (node.id === selectedProcessForEdit.slug) { return { ...node, data: { ...node.data, label: processData.name, description: processData.description, type: processData.type, status: processData.status, mappings: processData.mappings, filters: processData.filters, aggregations: processData.aggregations } }; } return node; }); setNodes(updatedNodes); // Update edges if source or destination tables have changed const processes = apiData?.processes || mockApiData.processes; const oldProcess = processes.find(p => p.slug === selectedProcessForEdit.slug); // Remove old edges if (oldProcess) { const edgesToRemove = edges.filter(edge => edge.source === oldProcess.slug || edge.target === oldProcess.slug ); if (edgesToRemove.length > 0) { const remainingEdges = edges.filter(edge => !edgesToRemove.some(e => e.id === edge.id) ); setEdges(remainingEdges); } } // Create new edges const newEdges = []; // Determine if process is active or inactive const isActive = processData.status === 'active'; // Set colors and animation based on process status const sourceColor = isActive ? '#52c41a' : '#aaaaaa'; // Green or gray const destColor = isActive ? '#1890ff' : '#aaaaaa'; // Blue or gray const animated = isActive; // Only animate if active const strokeWidth = isActive ? 2 : 1.5; // Slightly thinner if inactive const opacity = isActive ? 1 : 0.7; // Slightly transparent if inactive // Create edges from source tables to process processData.source_table.forEach(sourceId => { newEdges.push({ id: `e-${sourceId}-${processData.slug}`, source: sourceId, target: processData.slug, type: 'custom', animated: animated, style: { stroke: sourceColor, strokeWidth: strokeWidth, opacity: opacity }, markerEnd: { type: 'arrowclosed', width: 20, height: 20, color: sourceColor, }, data: { label: processData.name, isActive: isActive } }); }); // Create edges from process to destination tables processData.destination_table.forEach(destId => { newEdges.push({ id: `e-${processData.slug}-${destId}`, source: processData.slug, target: destId, type: 'custom', animated: animated, style: { stroke: destColor, strokeWidth: strokeWidth, opacity: opacity }, markerEnd: { type: 'arrowclosed', width: 20, height: 20, color: destColor, }, data: { label: processData.name, isActive: isActive } }); }); setEdges(eds => [...eds, ...newEdges]); // Update the data in memory // Note: In a real application, you would make an API call to update the data on the server // For now, we'll just update the mockApiData for consistency const processIndex = mockApiData.processes.findIndex(p => p.slug === processData.slug); if (processIndex !== -1) { mockApiData.processes[processIndex] = processData; } // If we have API data, update it too if (apiData && apiData.processes) { const apiProcessIndex = apiData.processes.findIndex(p => p.slug === processData.slug); if (apiProcessIndex !== -1) { apiData.processes[apiProcessIndex] = processData; } } } else { // Create a new process node const newProcess = { id: processData.slug, type: 'process', data: { label: processData.name, description: processData.description, type: processData.type, status: processData.status, mappings: processData.mappings, filters: processData.filters, aggregations: processData.aggregations }, position: { x: newX, y: newY }, }; setNodes(nodes => [...nodes, newProcess]); // Create edges const newEdges = []; // Determine if process is active or inactive const isActive = processData.status === 'active'; // Set colors and animation based on process status const sourceColor = isActive ? '#52c41a' : '#aaaaaa'; // Green or gray const destColor = isActive ? '#1890ff' : '#aaaaaa'; // Blue or gray const animated = isActive; // Only animate if active const strokeWidth = isActive ? 2 : 1.5; // Slightly thinner if inactive const opacity = isActive ? 1 : 0.7; // Slightly transparent if inactive // Create edges from source tables to process processData.source_table.forEach(sourceId => { newEdges.push({ id: `e-${sourceId}-${processData.slug}`, source: sourceId, target: processData.slug, type: 'custom', animated: animated, style: { stroke: sourceColor, strokeWidth: strokeWidth, opacity: opacity }, markerEnd: { type: 'arrowclosed', width: 20, height: 20, color: sourceColor, }, data: { label: processData.name, isActive: isActive } }); }); // Create edges from process to destination tables processData.destination_table.forEach(destId => { newEdges.push({ id: `e-${processData.slug}-${destId}`, source: processData.slug, target: destId, type: 'custom', animated: animated, style: { stroke: destColor, strokeWidth: strokeWidth, opacity: opacity }, markerEnd: { type: 'arrowclosed', width: 20, height: 20, color: destColor, }, data: { label: processData.name, isActive: isActive } }); }); setEdges(eds => [...eds, ...newEdges]); // Add to mock data mockApiData.processes.push(processData); // If we have API data, update it too if (apiData && apiData.processes) { apiData.processes.push(processData); } } }; // Removed schema-related functions // Handle dragging tables into schemas const onDragOver = useCallback((event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }, []); const onDrop = useCallback((event) => { event.preventDefault(); // Get the node ID from the data transfer const nodeId = event.dataTransfer.getData('application/reactflow'); if (!nodeId || !reactFlowInstance) { return; } // Get the drop position const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY, }); // Find the node being dragged const draggedNode = nodes.find(node => node.id === nodeId); if (!draggedNode) { return; } // Check if the drop position is within any schema const schemas = apiData?.schemas || mockApiData.schemas; let targetSchema = null; for (const schema of schemas) { const schemaNode = nodes.find(node => node.id === `schema-bg-${schema.slug}`); if (schemaNode) { const { x, y } = schemaNode.position; const { width, height } = schemaNode.style; // Check if the drop position is within this schema if ( position.x >= x && position.x <= x + width && position.y >= y && position.y <= y + height ) { targetSchema = schema; break; } } } // Update the node with the new position and parent schema const updatedNodes = nodes.map(node => { if (node.id === nodeId) { return { ...node, position, parentNode: targetSchema ? `schema-bg-${targetSchema.slug}` : undefined, extent: targetSchema ? 'parent' : undefined, // Remove the draggable styling style: { ...node.style, border: null, boxShadow: null }, className: 'table-node' }; } return node; }); setNodes(updatedNodes); }, [nodes, reactFlowInstance]); // Add the custom CSS to the document useEffect(() => { // Create a style element const styleElement = document.createElement('style'); styleElement.innerHTML = generateCustomStyles(); document.head.appendChild(styleElement); // Clean up on unmount return () => { document.head.removeChild(styleElement); }; }, []); // Create a style element with our custom styles const customStyles = useMemo(() => generateCustomStyles(), []); // Render the component return (
{/* Show loading state if data is still loading */} {loading ? ( ) : ( <> {/* Database Header */} {selectedDatabase && (

{selectedDatabase.name} Database

Data Flow View
)} {/* Empty Database Message */} {nodes.length === 0 && selectedDatabase && (

{selectedDatabase.name} Database is Empty

This database doesn't have any tables or data flows yet. Start by adding tables and processes to visualize your data flow.

)} {/* Process Details Popup */} {showProcessPopup && selectedProcess && (
e.stopPropagation()} >
{/* Drag handle indicator */}

{selectedProcess.name}

Process ID: {selectedProcess.slug}
Type: {selectedProcess.type}
Status: {selectedProcess.status}

Description

{selectedProcess.description || 'No description available'}

Source Tables

    {selectedProcess.source_table_names.map((name, index) => (
  • {name}
  • ))}

Destination Tables

    {selectedProcess.destination_table_names.map((name, index) => (
  • {name}
  • ))}
)} { if (n.type === 'table') return n.data.type === 'fact' ? '#1890ff' : '#52c41a'; return '#fa8c16'; }} nodeColor={(n) => { if (n.type === 'table') return n.data.type === 'fact' ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)'; return 'rgba(250, 140, 22, 0.2)'; }} maskColor="rgba(255, 255, 255, 0.7)" style={{ background: '#f0f0f0', border: '1px solid #ddd' }} />
{/* Process Form */} setShowProcessForm(false)} onSave={handleSaveProcess} tables={apiData?.tables || mockApiData.tables} existingProcess={selectedProcessForEdit} /> {/* Table Creation Popup */} {showTablePopup && ( setShowTablePopup(false)} onCreateTable={handleCreateTable} /> )} )}
); }; // Wrap with ReactFlowProvider const DataflowCanvasWrapper = () => { return ( ); }; export default DataflowCanvasWrapper;