1298 lines
39 KiB
JavaScript
1298 lines
39 KiB
JavaScript
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';
|
|
|
|
// Add custom CSS for React Flow
|
|
const generateCustomStyles = () => {
|
|
return `
|
|
.react-flow__node {
|
|
z-index: 1;
|
|
}
|
|
|
|
.schema-background-node {
|
|
z-index: -1;
|
|
pointer-events: all;
|
|
border-radius: 25px;
|
|
cursor: grab;
|
|
user-select: none;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.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: 15px;
|
|
left: 15px;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
z-index: 10;
|
|
}
|
|
`;
|
|
};
|
|
|
|
// Import icons from react-icons
|
|
import { FaDatabase, FaTable, FaArrowRight, FaExchangeAlt, FaLayerGroup } from 'react-icons/fa';
|
|
import { BiSolidData } from 'react-icons/bi';
|
|
import { AiFillFolder } from 'react-icons/ai';
|
|
import { BsFileEarmarkSpreadsheet } from 'react-icons/bs';
|
|
import { CustomProcessIcon } from './CustomIcons';
|
|
|
|
// Import mock data
|
|
import mockApiData 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 (
|
|
<div
|
|
className="schema-background-node"
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
background: bgColor,
|
|
border: `3px dashed ${data.color || borderColor}`,
|
|
boxShadow: `0 0 20px rgba(0, 0, 0, 0.05)`,
|
|
borderRadius: '25px', // Slightly larger border radius
|
|
}}
|
|
>
|
|
<div className="schema-label" style={{
|
|
color: data.color || borderColor,
|
|
fontSize: '14px', // Slightly larger font
|
|
padding: '8px 12px', // More padding
|
|
background: 'rgba(255, 255, 255, 0.9)', // More opaque background
|
|
boxShadow: '0 3px 6px rgba(0, 0, 0, 0.1)', // Stronger shadow
|
|
}}>
|
|
<FaLayerGroup size={14} />
|
|
{data.name}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Custom edge with animated arrow
|
|
const CustomEdge = ({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, data, markerEnd }) => {
|
|
const [edgePath, labelX, labelY] = getBezierPath({
|
|
sourceX,
|
|
sourceY,
|
|
sourcePosition,
|
|
targetX,
|
|
targetY,
|
|
targetPosition,
|
|
});
|
|
|
|
// 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 (
|
|
<>
|
|
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
|
|
<EdgeLabelRenderer>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
|
fontSize: 11,
|
|
pointerEvents: 'all',
|
|
backgroundColor: labelBgColor,
|
|
padding: '4px 8px',
|
|
borderRadius: '4px',
|
|
color: isActive ? '#333' : '#666',
|
|
fontWeight: isActive ? 500 : 400,
|
|
boxShadow: isActive ? '0 0 5px rgba(0,0,0,0.2)' : 'none',
|
|
border: `1px solid ${style.stroke || '#ccc'}`,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: '2px',
|
|
maxWidth: '180px',
|
|
opacity: labelOpacity
|
|
}}
|
|
className="nodrag nopan"
|
|
>
|
|
{/* Process name in small text */}
|
|
<div style={{
|
|
fontSize: '9px',
|
|
color: '#666',
|
|
fontStyle: 'italic',
|
|
marginBottom: '2px',
|
|
textAlign: 'center'
|
|
}}>
|
|
{processName}
|
|
{!isActive && (
|
|
<span style={{
|
|
fontSize: '8px',
|
|
background: '#f0f0f0',
|
|
padding: '0px 3px',
|
|
borderRadius: '3px',
|
|
color: '#999',
|
|
marginLeft: '2px'
|
|
}}>
|
|
inactive
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Column mappings */}
|
|
<div style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
justifyContent: 'center',
|
|
gap: '3px'
|
|
}}>
|
|
{mappings.length > 0 ? (
|
|
mappings.map((column, index) => (
|
|
<span
|
|
key={index}
|
|
style={{
|
|
background: isSourceEdge
|
|
? 'rgba(82, 196, 26, 0.1)'
|
|
: 'rgba(24, 144, 255, 0.1)',
|
|
color: isActive
|
|
? (isSourceEdge ? '#52c41a' : '#1890ff')
|
|
: '#aaaaaa',
|
|
padding: '1px 4px',
|
|
borderRadius: '3px',
|
|
fontSize: '10px',
|
|
border: `1px solid ${isActive
|
|
? (isSourceEdge ? 'rgba(82, 196, 26, 0.3)' : 'rgba(24, 144, 255, 0.3)')
|
|
: 'rgba(170, 170, 170, 0.3)'}`
|
|
}}
|
|
>
|
|
{column}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span style={{ color: '#999', fontSize: '10px' }}>No mappings</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</EdgeLabelRenderer>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// Process Node (represents ETL or data transformation)
|
|
const ProcessNode = ({ data, id }) => {
|
|
// Function to handle process edit
|
|
const handleProcessEdit = () => {
|
|
// Find the process in the mock data
|
|
const process = mockApiData.processes.find(p => p.slug === id);
|
|
if (process) {
|
|
// We'll use window.processEditCallback which will be set in the main component
|
|
if (window.processEditCallback) {
|
|
window.processEditCallback(process);
|
|
}
|
|
}
|
|
};
|
|
// Determine status color
|
|
const statusColor = data.status === 'active' ? '#52c41a' : '#ff4d4f';
|
|
const isActive = data.status === 'active';
|
|
|
|
// Define dimensions for the SVG
|
|
const width = 240;
|
|
const height = 140;
|
|
const borderWidth = 2;
|
|
|
|
// Define colors
|
|
const primaryColor = isActive ? '#fa8c16' : '#aaaaaa';
|
|
const secondaryColor = isActive ? '#ff4d4f' : '#cccccc';
|
|
const bgColor = '#ffffff';
|
|
const descBgColor = isActive ? 'rgba(250, 140, 22, 0.08)' : 'rgba(170, 170, 170, 0.08)';
|
|
|
|
// Create gradient ID
|
|
const gradientId = `process-gradient-${data.label.replace(/\s+/g, '-').toLowerCase()}`;
|
|
|
|
return (
|
|
<div className="process-node" style={{
|
|
position: 'relative',
|
|
width: `${width}px`,
|
|
height: `${height}px`,
|
|
}}>
|
|
{/* SVG Container */}
|
|
<svg
|
|
width={width}
|
|
height={height}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
style={{
|
|
filter: isActive
|
|
? 'drop-shadow(0 0 8px rgba(250, 140, 22, 0.3))'
|
|
: 'drop-shadow(0 0 5px rgba(170, 170, 170, 0.2))'
|
|
}}
|
|
>
|
|
{/* Define gradient */}
|
|
<defs>
|
|
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stopColor={primaryColor} />
|
|
<stop offset="100%" stopColor={secondaryColor} />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Main ellipse shape */}
|
|
<ellipse
|
|
cx={width / 2}
|
|
cy={height / 2}
|
|
rx={(width / 2) - borderWidth}
|
|
ry={(height / 2) - borderWidth}
|
|
fill={bgColor}
|
|
stroke={`url(#${gradientId})`}
|
|
strokeWidth={borderWidth}
|
|
/>
|
|
|
|
{/* Description background */}
|
|
<ellipse
|
|
cx={width / 2}
|
|
cy={height - 35}
|
|
rx={width / 2 - 20}
|
|
ry={20}
|
|
fill={descBgColor}
|
|
stroke="none"
|
|
/>
|
|
</svg>
|
|
|
|
{/* Connection handles */}
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="left"
|
|
style={{
|
|
background: primaryColor,
|
|
width: '10px',
|
|
height: '10px',
|
|
left: '-5px',
|
|
zIndex: 10
|
|
}}
|
|
isConnectable={true}
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="right"
|
|
style={{
|
|
background: primaryColor,
|
|
width: '10px',
|
|
height: '10px',
|
|
right: '-5px',
|
|
zIndex: 10
|
|
}}
|
|
isConnectable={true}
|
|
/>
|
|
|
|
{/* Content overlay */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: '15px',
|
|
pointerEvents: 'auto' // Enable clicks on the content
|
|
}}>
|
|
{/* Icon and title */}
|
|
<div style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
marginBottom: '5px'
|
|
}}>
|
|
<div
|
|
onClick={handleProcessEdit}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '40px',
|
|
height: '40px',
|
|
borderRadius: '50%',
|
|
background: `url(#${gradientId})`,
|
|
marginBottom: '8px',
|
|
boxShadow: '0 3px 6px rgba(0,0,0,0.15)',
|
|
cursor: 'pointer',
|
|
pointerEvents: 'auto',
|
|
zIndex: 100
|
|
}}
|
|
>
|
|
<CustomProcessIcon width="24" height="24" />
|
|
</div>
|
|
|
|
<div style={{
|
|
fontWeight: 'bold',
|
|
fontSize: '14px',
|
|
color: '#333',
|
|
textAlign: 'center',
|
|
maxWidth: '90%',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap'
|
|
}}>
|
|
{data.label}
|
|
</div>
|
|
|
|
{/* Status indicator */}
|
|
<div style={{
|
|
fontSize: '10px',
|
|
background: statusColor,
|
|
color: 'white',
|
|
padding: '2px 8px',
|
|
borderRadius: '10px',
|
|
marginTop: '4px'
|
|
}}>
|
|
{data.status}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div style={{
|
|
fontSize: '11px',
|
|
color: '#555',
|
|
textAlign: 'center',
|
|
maxWidth: '90%',
|
|
overflow: 'hidden',
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
marginTop: 'auto',
|
|
paddingTop: '5px'
|
|
}}>
|
|
{data.description || 'Data transformation process'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Table Node
|
|
const TableNode = ({ data }) => {
|
|
const isFact = data.type === 'fact';
|
|
|
|
// Use a consistent color theme for all tables
|
|
const tableColor = '#1890ff'; // Blue for all tables
|
|
const background = '#ffffff';
|
|
|
|
return (
|
|
<div className="table-node" style={{
|
|
padding: '10px',
|
|
borderRadius: '5px',
|
|
background: background,
|
|
border: `2px solid ${tableColor}`,
|
|
width: '180px',
|
|
position: 'relative',
|
|
boxShadow: '0 0 10px rgba(24, 144, 255, 0.3)',
|
|
color: '#333333'
|
|
}}>
|
|
{/* Connection handles */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="right"
|
|
style={{ background: tableColor, width: '10px', height: '10px' }}
|
|
isConnectable={true}
|
|
/>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="left"
|
|
style={{ background: tableColor, width: '10px', height: '10px' }}
|
|
isConnectable={true}
|
|
/>
|
|
|
|
<div style={{ fontWeight: 'bold', marginBottom: '5px', display: 'flex', alignItems: 'center' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', flex: 1 }}>
|
|
{/* Different icons for fact and dimension tables */}
|
|
{isFact ? (
|
|
<FaTable style={{ marginRight: '8px', color: tableColor, fontSize: '16px' }} />
|
|
) : (
|
|
<BsFileEarmarkSpreadsheet style={{ marginRight: '8px', color: tableColor, fontSize: '16px' }} />
|
|
)}
|
|
<span style={{ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{data.label}
|
|
</span>
|
|
<span style={{
|
|
fontSize: '10px',
|
|
background: tableColor,
|
|
color: 'white',
|
|
padding: '1px 4px',
|
|
borderRadius: '3px',
|
|
marginLeft: '5px',
|
|
verticalAlign: 'middle'
|
|
}}>
|
|
{isFact ? 'FACT' : 'DIM'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{data.columns && (
|
|
<div style={{
|
|
fontSize: '0.75em',
|
|
color: '#555',
|
|
background: 'rgba(24, 144, 255, 0.1)', // Same light blue background for all tables
|
|
padding: '5px',
|
|
borderRadius: '3px',
|
|
marginTop: '5px'
|
|
}}>
|
|
<div style={{ marginBottom: '3px', color: '#333', fontWeight: 'bold' }}>Columns:</div>
|
|
<div style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '3px'
|
|
}}>
|
|
{data.columns.slice(0, 5).map((col, index) => (
|
|
<span key={index} style={{
|
|
background: 'rgba(0, 0, 0, 0.05)',
|
|
padding: '1px 4px',
|
|
borderRadius: '2px',
|
|
fontSize: '10px',
|
|
color: '#333'
|
|
}}>
|
|
{col}
|
|
</span>
|
|
))}
|
|
{data.columns.length > 5 && (
|
|
<span style={{
|
|
padding: '1px 4px',
|
|
fontSize: '10px',
|
|
color: '#666'
|
|
}}>
|
|
+{data.columns.length - 5} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Main DataflowCanvas component
|
|
const DataflowCanvas = () => {
|
|
// State for nodes and edges
|
|
const [showProcessForm, setShowProcessForm] = useState(false);
|
|
const [selectedProcessForEdit, setSelectedProcessForEdit] = useState(null);
|
|
const [showTableForm, setShowTableForm] = useState(false);
|
|
const [selectedTableForEdit, setSelectedTableForEdit] = useState(null);
|
|
|
|
// Get the React Flow instance
|
|
const reactFlowInstance = useReactFlow();
|
|
|
|
// Define node types
|
|
const nodeTypes = useMemo(() => ({
|
|
schemaBackground: SchemaBackgroundNode,
|
|
process: ProcessNode,
|
|
table: TableNode
|
|
}), []);
|
|
|
|
// Define edge types
|
|
const edgeTypes = useMemo(() => ({
|
|
custom: CustomEdge
|
|
}), []);
|
|
|
|
// Create initial nodes from the mock data
|
|
const initialNodes = useMemo(() => {
|
|
// Create schema boundaries to properly size and position schema background nodes
|
|
const schemaBoundaries = {};
|
|
|
|
// Initialize boundaries with schema positions
|
|
mockApiData.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
|
|
mockApiData.tables.forEach(table => {
|
|
const schemaSlug = table.schema;
|
|
if (schemaBoundaries[schemaSlug]) {
|
|
// Add some padding around tables (100px on each side)
|
|
const tableMinX = table.orientation.x - 100;
|
|
const tableMinY = table.orientation.y - 100;
|
|
const tableMaxX = table.orientation.x + 280; // Table width + padding
|
|
const tableMaxY = table.orientation.y + 280; // 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 = mockApiData.schemas.map(schema => {
|
|
const bounds = schemaBoundaries[schema.slug];
|
|
const width = bounds.maxX - bounds.minX;
|
|
const height = bounds.maxY - bounds.minY;
|
|
|
|
// Add 30% extra size
|
|
const extraWidth = width * 0.3;
|
|
const extraHeight = height * 0.3;
|
|
|
|
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,
|
|
};
|
|
});
|
|
|
|
// Table nodes
|
|
const tableNodes = mockApiData.tables.map(table => {
|
|
return {
|
|
id: table.slug,
|
|
type: 'table',
|
|
data: {
|
|
label: table.name,
|
|
type: table.type,
|
|
columns: table.columns
|
|
},
|
|
position: {
|
|
x: table.orientation.x,
|
|
y: table.orientation.y
|
|
},
|
|
parentNode: `schema-bg-${table.schema}`,
|
|
extent: 'parent',
|
|
};
|
|
});
|
|
|
|
return [...schemaBackgroundNodes, ...tableNodes];
|
|
}, []);
|
|
|
|
// Create process nodes
|
|
const processNodes = useMemo(() => {
|
|
return mockApiData.processes.map(process => {
|
|
// Find source and destination tables to calculate position
|
|
const sourceTables = mockApiData.tables.filter(table =>
|
|
process.source_table.includes(table.slug)
|
|
);
|
|
|
|
const destTables = mockApiData.tables.filter(table =>
|
|
process.destination_table.includes(table.slug)
|
|
);
|
|
|
|
// Calculate average position between source and destination tables
|
|
let x = 0, y = 0, count = 0;
|
|
let parentSchema = null;
|
|
|
|
sourceTables.forEach(table => {
|
|
x += table.orientation.x;
|
|
y += table.orientation.y;
|
|
count++;
|
|
// If all tables are in the same schema, the process should be in that schema too
|
|
if (!parentSchema) parentSchema = table.schema;
|
|
else if (parentSchema !== table.schema) parentSchema = null; // Mixed schemas
|
|
});
|
|
|
|
destTables.forEach(table => {
|
|
x += table.orientation.x;
|
|
y += table.orientation.y;
|
|
count++;
|
|
// If all tables are in the same schema, the process should be in that schema too
|
|
if (!parentSchema) parentSchema = table.schema;
|
|
else if (parentSchema !== table.schema) parentSchema = null; // Mixed schemas
|
|
});
|
|
|
|
if (count > 0) {
|
|
x = x / count;
|
|
y = y / count;
|
|
}
|
|
|
|
// Position the process node between source and destination
|
|
// For source tables on the left and dest tables on the right
|
|
if (sourceTables.length > 0 && destTables.length > 0) {
|
|
const avgSourceX = sourceTables.reduce((sum, t) => sum + t.orientation.x, 0) / sourceTables.length;
|
|
const avgDestX = destTables.reduce((sum, t) => sum + t.orientation.x, 0) / destTables.length;
|
|
|
|
// If source is to the left of destination, place in between
|
|
if (avgSourceX < avgDestX) {
|
|
x = avgSourceX + (avgDestX - avgSourceX) * 0.5;
|
|
}
|
|
// If source is to the right of destination, place in between
|
|
else {
|
|
x = avgDestX + (avgSourceX - avgDestX) * 0.5;
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: process.slug,
|
|
type: 'process',
|
|
data: {
|
|
label: process.name,
|
|
description: process.description,
|
|
type: process.type,
|
|
status: process.status
|
|
},
|
|
position: { x, y },
|
|
parentNode: parentSchema ? `schema-bg-${parentSchema}` : undefined,
|
|
extent: parentSchema ? 'parent' : undefined,
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
// Create edges between tables and processes
|
|
const initialEdges = useMemo(() => {
|
|
const edges = [];
|
|
|
|
mockApiData.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);
|
|
|
|
// Format target columns for display
|
|
const targetColumns = mappings.map(m => m.target);
|
|
|
|
// 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 ? 1 : 0.7 // Slightly transparent if inactive
|
|
},
|
|
markerEnd: {
|
|
type: 'arrowclosed',
|
|
width: 20,
|
|
height: 20,
|
|
color: sourceColor,
|
|
},
|
|
data: {
|
|
label: sourceColumns.join(', ') || 'No mappings',
|
|
processName: process.name,
|
|
isActive: isActive, // Pass status to the edge component
|
|
edgeType: 'source',
|
|
mappings: sourceColumns
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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 ? 1 : 0.7 // Slightly transparent if inactive
|
|
},
|
|
markerEnd: {
|
|
type: 'arrowclosed',
|
|
width: 20,
|
|
height: 20,
|
|
color: destColor,
|
|
},
|
|
data: {
|
|
label: targetColumns.join(', ') || 'No mappings',
|
|
processName: process.name,
|
|
isActive: isActive, // Pass status to the edge component
|
|
edgeType: 'target',
|
|
mappings: targetColumns
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
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, we might want to update schema boundaries
|
|
// This is a placeholder for more complex logic if needed
|
|
if (hasPositionChanges) {
|
|
// console.log('Nodes are being moved');
|
|
}
|
|
},
|
|
[onNodesChangeDefault]
|
|
);
|
|
|
|
// Handle connecting nodes
|
|
const onConnect = useCallback(
|
|
(params) => {
|
|
// Only allow connections from tables to processes or processes to tables
|
|
const sourceNode = nodes.find(node => node.id === params.source);
|
|
const targetNode = nodes.find(node => node.id === params.target);
|
|
|
|
if (!sourceNode || !targetNode) return;
|
|
|
|
// Check if connection is valid (table -> process or process -> table)
|
|
const isSourceTable = sourceNode.type === 'table';
|
|
const isTargetProcess = targetNode.type === 'process';
|
|
const isSourceProcess = sourceNode.type === 'process';
|
|
const isTargetTable = targetNode.type === 'table';
|
|
|
|
if ((isSourceTable && isTargetProcess) || (isSourceProcess && isTargetTable)) {
|
|
// Create a new edge with custom styling
|
|
const newEdge = {
|
|
...params,
|
|
type: 'custom',
|
|
animated: true,
|
|
style: {
|
|
stroke: isSourceTable ? '#52c41a' : '#1890ff',
|
|
strokeWidth: 2
|
|
},
|
|
markerEnd: {
|
|
type: 'arrowclosed',
|
|
width: 20,
|
|
height: 20,
|
|
color: isSourceTable ? '#52c41a' : '#1890ff',
|
|
},
|
|
data: {
|
|
label: 'New Connection',
|
|
isActive: true
|
|
}
|
|
};
|
|
|
|
setEdges(eds => addEdge(newEdge, eds));
|
|
|
|
// TODO: Open a form to configure the connection
|
|
// This would be where you define mappings, transformations, etc.
|
|
}
|
|
},
|
|
[nodes, setEdges]
|
|
);
|
|
|
|
// Handle node click
|
|
const onNodeClick = useCallback((event, node) => {
|
|
// Prevent clicks on schema background nodes from opening forms
|
|
if (node.type === 'schemaBackground') return;
|
|
|
|
if (node.type === 'process') {
|
|
// Find the process in the mock data
|
|
const process = mockApiData.processes.find(p => p.slug === node.id);
|
|
if (process) {
|
|
setSelectedProcessForEdit(process);
|
|
setShowProcessForm(true);
|
|
}
|
|
} else if (node.type === 'table') {
|
|
// Find the table in the mock data
|
|
const table = mockApiData.tables.find(t => t.slug === node.id);
|
|
if (table) {
|
|
setSelectedTableForEdit(table);
|
|
setShowTableForm(true);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Add a new table
|
|
const addTableNode = () => {
|
|
const newTable = {
|
|
id: `table-${Date.now()}`,
|
|
type: 'table',
|
|
data: {
|
|
label: 'New Table',
|
|
type: 'dimension',
|
|
columns: ['id', 'name', 'description']
|
|
},
|
|
position: {
|
|
x: Math.random() * 500,
|
|
y: Math.random() * 500
|
|
},
|
|
};
|
|
|
|
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 oldProcess = mockApiData.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
|
|
|
|
// Get the mappings for this process
|
|
const mappings = processData.mappings || [];
|
|
|
|
// Format source columns for display
|
|
const sourceColumns = mappings.map(m => m.source);
|
|
|
|
// Format target columns for display
|
|
const targetColumns = mappings.map(m => m.target);
|
|
|
|
// 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: sourceColumns.join(', ') || 'No mappings',
|
|
processName: processData.name,
|
|
isActive: isActive,
|
|
edgeType: 'source',
|
|
mappings: sourceColumns
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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: targetColumns.join(', ') || 'No mappings',
|
|
processName: processData.name,
|
|
isActive: isActive,
|
|
edgeType: 'target',
|
|
mappings: targetColumns
|
|
}
|
|
});
|
|
});
|
|
|
|
setEdges(eds => [...eds, ...newEdges]);
|
|
|
|
// Update the mock data
|
|
const processIndex = mockApiData.processes.findIndex(p => p.slug === processData.slug);
|
|
if (processIndex !== -1) {
|
|
mockApiData.processes[processIndex] = processData;
|
|
} else {
|
|
mockApiData.processes.push(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 for the new process
|
|
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
|
|
|
|
// Get the mappings for this process
|
|
const mappings = processData.mappings || [];
|
|
|
|
// Format source columns for display
|
|
const sourceColumns = mappings.map(m => m.source);
|
|
|
|
// Format target columns for display
|
|
const targetColumns = mappings.map(m => m.target);
|
|
|
|
// 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: isActive ? 1 : 0.7
|
|
},
|
|
markerEnd: {
|
|
type: 'arrowclosed',
|
|
width: 20,
|
|
height: 20,
|
|
color: sourceColor,
|
|
},
|
|
data: {
|
|
label: sourceColumns.join(', ') || 'No mappings',
|
|
processName: processData.name,
|
|
isActive: isActive,
|
|
edgeType: 'source',
|
|
mappings: sourceColumns
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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: isActive ? 1 : 0.7
|
|
},
|
|
markerEnd: {
|
|
type: 'arrowclosed',
|
|
width: 20,
|
|
height: 20,
|
|
color: destColor,
|
|
},
|
|
data: {
|
|
label: targetColumns.join(', ') || 'No mappings',
|
|
processName: processData.name,
|
|
isActive: isActive,
|
|
edgeType: 'target',
|
|
mappings: targetColumns
|
|
}
|
|
});
|
|
});
|
|
|
|
setEdges(eds => [...eds, ...newEdges]);
|
|
|
|
// Add to mock data
|
|
mockApiData.processes.push(processData);
|
|
}
|
|
};
|
|
|
|
// Add custom CSS to the document
|
|
useEffect(() => {
|
|
const styleElement = document.createElement('style');
|
|
styleElement.textContent = generateCustomStyles();
|
|
document.head.appendChild(styleElement);
|
|
|
|
return () => {
|
|
document.head.removeChild(styleElement);
|
|
};
|
|
}, []);
|
|
|
|
// Set initial viewport
|
|
useEffect(() => {
|
|
if (reactFlowInstance) {
|
|
reactFlowInstance.setViewport(mockApiData.viewportSettings);
|
|
}
|
|
}, [reactFlowInstance]);
|
|
|
|
// 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;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div style={{ width: '100%', height: '100vh' }}>
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
onNodeClick={onNodeClick}
|
|
nodeTypes={nodeTypes}
|
|
edgeTypes={edgeTypes}
|
|
fitView
|
|
attributionPosition="bottom-left"
|
|
>
|
|
<Controls />
|
|
<MiniMap
|
|
nodeStrokeWidth={3}
|
|
zoomable
|
|
pannable
|
|
/>
|
|
<Background color="#f0f0f0" gap={16} />
|
|
|
|
<Panel position="top-right" style={{
|
|
display: 'flex',
|
|
gap: '10px',
|
|
background: 'white',
|
|
padding: '10px',
|
|
borderRadius: '5px',
|
|
boxShadow: '0 0 10px rgba(0,0,0,0.1)'
|
|
}}>
|
|
<button
|
|
onClick={addProcessNode}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '5px',
|
|
background: '#fa8c16',
|
|
color: 'white',
|
|
border: 'none',
|
|
padding: '8px 12px',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
fontWeight: 'bold',
|
|
boxShadow: '0 2px 5px rgba(0,0,0,0.1)'
|
|
}}
|
|
>
|
|
<CustomProcessIcon width="18" height="18" /> Add Process
|
|
</button>
|
|
<button
|
|
onClick={addTableNode}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '5px',
|
|
background: '#1890ff',
|
|
color: 'white',
|
|
border: 'none',
|
|
padding: '8px 12px',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
fontWeight: 'bold',
|
|
boxShadow: '0 2px 5px rgba(0,0,0,0.1)'
|
|
}}
|
|
>
|
|
<FaTable /> Add Table
|
|
</button>
|
|
</Panel>
|
|
</ReactFlow>
|
|
|
|
{/* Process Form */}
|
|
<ProcessForm
|
|
isOpen={showProcessForm}
|
|
onClose={() => setShowProcessForm(false)}
|
|
onSave={handleSaveProcess}
|
|
tables={mockApiData.tables}
|
|
existingProcess={selectedProcessForEdit}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Wrap with ReactFlowProvider
|
|
const DataflowCanvasWithProvider = () => (
|
|
<ReactFlowProvider>
|
|
<DataflowCanvasUpdated />
|
|
</ReactFlowProvider>
|
|
);
|
|
|
|
export default DataflowCanvasWithProvider; |