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 (
);
};
// 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 = () => (
);
// 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
{
// Use hash-based routing to navigate back to explorer
window.location.hash = '#/explorer';
// Also dispatch the custom event as a fallback
const event = new CustomEvent('switchToTab', { detail: 'canvas' });
window.dispatchEvent(event);
}}
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: 'none',
color: 'white',
padding: '5px 10px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Back to Explorer
)}
{/* 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.
setShowTablePopup(true)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '5px',
padding: '8px 16px',
background: '#52c41a',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Add First Table
)}
{/* Process Details Popup */}
{showProcessPopup && selectedProcess && (
e.stopPropagation()}
>
{/* Drag handle indicator */}
{selectedProcess.name}
setShowProcessPopup(false)}
style={{
background: 'transparent',
border: 'none',
color: '#666',
fontSize: '16px',
cursor: 'pointer',
padding: '5px'
}}
>
✕
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}
))}
setShowProcessPopup(false)}
style={{
background: '#f0f0f0',
border: '1px solid #ccc',
color: '#333',
padding: '6px 12px',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Close
handleEditProcess(selectedProcess)}
style={{
background: '#fa8c16',
border: 'none',
color: '#fff',
padding: '6px 12px',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '5px',
boxShadow: '0 2px 5px rgba(250, 140, 22, 0.3)'
}}
>
Edit Process
)}
{
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' }}
/>
Add Table
Add Process
setConnectionType(connectionType === 'default' ? 'reference' : 'default')}
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '5px 10px',
background: connectionType === 'reference' ? '#00a99d' : '#722ed1',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
{connectionType === 'reference' ? 'Reference' : 'Default'} Connection
{/* 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;