Qubit_EPM/src/components/InfiniteCanvas.jsx

3157 lines
106 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import ReactFlow, {
MiniMap,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Panel,
useReactFlow,
ReactFlowProvider,
Handle,
Position,
BaseEdge,
EdgeLabelRenderer,
getSmoothStepPath,
getBezierPath
} from 'reactflow';
import 'reactflow/dist/style.css';
import axios from 'axios';
import planPlusLogo from '../assets/img/planPlusLogo.png';
// Import icons from react-icons
import { FaDatabase, FaTable, FaFlask, FaArrowRight, FaPlus, FaTimes, FaChevronUp, FaChevronDown } from 'react-icons/fa';
import { BiSolidData } from 'react-icons/bi';
import { AiFillFolder } from 'react-icons/ai';
import { BsFileEarmarkSpreadsheet } from 'react-icons/bs';
// Import mock data from DataflowCanvas
import mockApiData from './mockData';
// Modal component for creating entities
const Modal = ({ isOpen, title, onClose, children }) => {
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
width: '700px',
maxWidth: '90%',
maxHeight: '90vh',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.2)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 20px',
borderBottom: '1px solid #eee',
backgroundColor: '#f8f8f8'
}}>
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{title}</h3>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '20px',
color: '#666'
}}
>
<FaTimes />
</button>
</div>
<div style={{
padding: '20px',
overflowY: 'auto',
flex: 1
}}>
{children}
</div>
</div>
</div>
);
};
// Database Creation Form with nested schema and table creation
const DatabaseForm = ({ onSave, onCancel }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
url: '',
key: '',
type: 'PostgreSQL',
schemas: [] // Array to hold schemas
});
const [showSchemaForm, setShowSchemaForm] = useState(false);
const [currentSchemaIndex, setCurrentSchemaIndex] = useState(null);
const [showTableForm, setShowTableForm] = useState(false);
const [currentSchemaForTable, setCurrentSchemaForTable] = useState(null);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
onSave(formData);
};
const fetchId = () => {
// In a real app, this would make an API call
alert('Fetching ID from URL: ' + formData.url);
};
// Add a new schema to the database
const addSchema = (schemaData) => {
if (currentSchemaIndex !== null) {
// Edit existing schema
const updatedSchemas = [...formData.schemas];
updatedSchemas[currentSchemaIndex] = {
...updatedSchemas[currentSchemaIndex],
...schemaData,
};
setFormData(prev => ({ ...prev, schemas: updatedSchemas }));
} else {
// Add new schema
const newSchema = {
id: `schema-${Date.now()}`,
name: schemaData.name,
description: schemaData.description,
tables: [] // Initialize with empty tables array
};
setFormData(prev => ({
...prev,
schemas: [...prev.schemas, newSchema]
}));
}
setShowSchemaForm(false);
setCurrentSchemaIndex(null);
};
// Add a new table to a schema
const addTable = (tableData) => {
const schemaIndex = formData.schemas.findIndex(s => s.id === currentSchemaForTable);
if (schemaIndex !== -1) {
const newTable = {
id: `table-${Date.now()}`,
name: tableData.name,
description: tableData.description,
type: tableData.type
};
const updatedSchemas = [...formData.schemas];
updatedSchemas[schemaIndex] = {
...updatedSchemas[schemaIndex],
tables: [...updatedSchemas[schemaIndex].tables, newTable]
};
setFormData(prev => ({ ...prev, schemas: updatedSchemas }));
}
setShowTableForm(false);
setCurrentSchemaForTable(null);
};
// Remove a schema
const removeSchema = (index) => {
const updatedSchemas = [...formData.schemas];
updatedSchemas.splice(index, 1);
setFormData(prev => ({ ...prev, schemas: updatedSchemas }));
};
// Remove a table
const removeTable = (schemaIndex, tableIndex) => {
const updatedSchemas = [...formData.schemas];
updatedSchemas[schemaIndex].tables.splice(tableIndex, 1);
setFormData(prev => ({ ...prev, schemas: updatedSchemas }));
};
return (
<div>
{showSchemaForm ? (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px'
}}>
<h4 style={{ margin: 0 }}>
{currentSchemaIndex !== null ? 'Edit Schema' : 'Add Schema'}
</h4>
<button
onClick={() => {
setShowSchemaForm(false);
setCurrentSchemaIndex(null);
}}
style={{
background: 'none',
border: 'none',
fontSize: '18px',
cursor: 'pointer',
color: '#666'
}}
>
×
</button>
</div>
<SchemaForm
onSave={addSchema}
onCancel={() => {
setShowSchemaForm(false);
setCurrentSchemaIndex(null);
}}
initialData={currentSchemaIndex !== null ? formData.schemas[currentSchemaIndex] : null}
/>
</div>
) : showTableForm ? (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px'
}}>
<h4 style={{ margin: 0 }}>Add Table</h4>
<button
onClick={() => {
setShowTableForm(false);
setCurrentSchemaForTable(null);
}}
style={{
background: 'none',
border: 'none',
fontSize: '18px',
cursor: 'pointer',
color: '#666'
}}
>
×
</button>
</div>
<TableForm
onSave={addTable}
onCancel={() => {
setShowTableForm(false);
setCurrentSchemaForTable(null);
}}
/>
</div>
) : (
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
Name
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Name"
style={{
width: '100%',
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px'
}}
required
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Description"
style={{
width: '100%',
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px',
minHeight: '60px',
resize: 'vertical'
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
URL
</label>
<div style={{ display: 'flex', gap: '10px' }}>
<input
type="url"
name="url"
value={formData.url}
onChange={handleChange}
placeholder="http://"
style={{
flex: 1,
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px'
}}
/>
<button
type="button"
onClick={fetchId}
style={{
padding: '10px 15px',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Fetch ID
</button>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
Key
</label>
<input
type="text"
name="key"
value={formData.key}
onChange={handleChange}
placeholder="http://"
style={{
width: '100%',
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px'
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
Database Type
</label>
<select
name="type"
value={formData.type}
onChange={handleChange}
style={{
width: '100%',
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px',
backgroundColor: 'white'
}}
>
<option value="PostgreSQL">PostgreSQL</option>
<option value="MySQL">MySQL</option>
<option value="Oracle">Oracle</option>
<option value="SQL Server">SQL Server</option>
<option value="MongoDB">MongoDB</option>
</select>
</div>
{/* Schemas Section removed as per requirements */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '24px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '10px 20px',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Cancel
</button>
<button
type="submit"
style={{
padding: '10px 20px',
backgroundColor: '#00a99d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Save Database
</button>
</div>
</form>
)}
</div>
);
};
// Schema Creation Form
const SchemaForm = ({ onSave, onCancel, parentDatabase, initialData }) => {
const [formData, setFormData] = useState({
name: initialData ? initialData.name : '',
description: initialData ? initialData.description : ''
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
onSave(parentDatabase ? { ...formData, parentDatabase } : formData);
};
return (
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
Schema Name
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Schema Name"
style={{
width: '100%',
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px'
}}
required
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Schema Description"
style={{
width: '100%',
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px',
minHeight: '60px',
resize: 'vertical'
}}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '10px 20px',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Cancel
</button>
<button
type="submit"
style={{
padding: '10px 20px',
backgroundColor: '#00a99d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
{initialData ? 'Update Schema' : 'Add Schema'}
</button>
</div>
</form>
);
};
// Entity Selector Component
const EntitySelector = ({
activeEntityType,
setActiveEntityType,
databases,
schemas,
selectedDatabaseForSchema,
setSelectedDatabaseForSchema,
selectedSchemaForTable,
setSelectedSchemaForTable,
onSaveDatabase,
onSaveSchema,
onSaveTable,
onCancel
}) => {
// Determine which databases are available for schema creation
const availableDatabases = databases || [];
// Determine which schemas are available for table creation
const availableSchemas = schemas ? schemas.filter(schema =>
selectedDatabaseForSchema ? schema.dbId === selectedDatabaseForSchema : true
) : [];
return (
<div>
{/* Entity Type Selector - Only Database */}
<div style={{
display: 'flex',
justifyContent: 'center',
marginBottom: '20px',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid #eee'
}}>
<button
onClick={() => {
setActiveEntityType('database');
setSelectedDatabaseForSchema(null);
setSelectedSchemaForTable(null);
}}
style={{
flex: 1,
padding: '12px',
border: 'none',
background: '#00a99d',
color: 'white',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '5px',
transition: 'all 0.2s'
}}
>
<svg width="20" height="20" viewBox="0 0 41 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40.5" height="38.7" rx="3.6" fill="url(#db_paint0_linear)"/>
<path d="M19.8845 24.789C17.0714 24.789 14.2465 24.0672 12.912 22.5097C12.8725 22.683 12.8462 22.8607 12.8462 23.0493V25.8844C12.8462 28.3962 16.4937 29.5392 19.8845 29.5392C23.2753 29.5392 26.9228 28.3962 26.9228 25.8844V23.0493C26.9228 22.8607 26.8964 22.6837 26.8569 22.5104C25.5217 24.068 22.6976 24.7904 19.8837 24.7904L19.8845 24.789ZM19.8845 19.3083C17.0714 19.3083 14.2465 18.5865 12.9127 17.0289C12.8706 17.2053 12.8486 17.3858 12.8469 17.5671V20.4022C12.8469 22.9133 16.4937 24.0563 19.8845 24.0563C23.2753 24.0563 26.9228 22.9133 26.9228 20.4015V17.5657C26.9228 17.3778 26.8964 17.2001 26.8569 17.0268C25.5217 18.5843 22.6976 19.3068 19.8837 19.3068L19.8845 19.3083ZM19.8845 8.42944C16.4937 8.42944 12.8462 9.57385 12.8462 12.0857V14.9208C12.8462 17.4326 16.4937 18.5755 19.8845 18.5755C23.2753 18.5755 26.9228 17.4333 26.9228 14.9215V12.0864C26.9228 9.57458 23.2753 8.43017 19.8845 8.43017V8.42944ZM19.8845 14.2794C16.8059 14.2794 14.3087 13.2974 14.3087 12.0857C14.3087 10.8747 16.8052 9.89194 19.8845 9.89194C22.9638 9.89194 25.4603 10.8747 25.4603 12.0857C25.4603 13.2981 22.9638 14.2794 19.8845 14.2794Z" fill="white"/>
<defs>
<linearGradient id="db_paint0_linear" x1="40.5" y1="19.35" x2="0" y2="19.35" gradientUnits="userSpaceOnUse">
<stop stopColor="#006064"/>
<stop offset="0.711538" stopColor="#00A1A8"/>
</linearGradient>
</defs>
</svg>
{/* <span>Database</span> */}
</button>
</div>
{/* No parent selectors needed since we only have database */}
{/* Only Database Form */}
<DatabaseForm
onSave={onSaveDatabase}
onCancel={onCancel}
/>
{/* Guidance message when no parents are available */}
{activeEntityType === 'schema' && availableDatabases.length === 0 && (
<div style={{
padding: '30px',
textAlign: 'center',
border: '1px dashed #ddd',
borderRadius: '4px',
color: '#666',
backgroundColor: '#fafafa'
}}>
You need to create a database first before creating a schema.
</div>
)}
{activeEntityType === 'table' && availableSchemas.length === 0 && (
<div style={{
padding: '30px',
textAlign: 'center',
border: '1px dashed #ddd',
borderRadius: '4px',
color: '#666',
backgroundColor: '#fafafa'
}}>
You need to create a schema first before creating a table.
</div>
)}
</div>
);
};
// Table Creation Form
const TableForm = ({ onSave, onCancel, parentSchema, initialData }) => {
const [formData, setFormData] = useState({
name: initialData ? initialData.name : '',
description: initialData ? initialData.description : '',
type: initialData ? initialData.type : 'dimension',
columns: initialData && initialData.columns ? initialData.columns : []
});
const [showColumnInput, setShowColumnInput] = useState(false);
const [columnName, setColumnName] = useState('');
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
onSave(parentSchema ? { ...formData, parentSchema } : formData);
};
const addColumn = () => {
if (columnName.trim()) {
setFormData(prev => ({
...prev,
columns: [...prev.columns, columnName.trim()]
}));
setColumnName('');
setShowColumnInput(false);
}
};
const removeColumn = (index) => {
const updatedColumns = [...formData.columns];
updatedColumns.splice(index, 1);
setFormData(prev => ({ ...prev, columns: updatedColumns }));
};
return (
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
Table Name
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Table Name"
style={{
width: '100%',
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px'
}}
required
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
Table Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Table Description"
style={{
width: '100%',
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px',
minHeight: '60px',
resize: 'vertical'
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500', color: '#333' }}>
Table Type
</label>
<select
name="type"
value={formData.type}
onChange={handleChange}
style={{
width: '100%',
padding: '10px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px',
backgroundColor: 'white'
}}
>
<option value="dimension">Dimension</option>
<option value="fact">Fact</option>
<option value="stage">Stage</option>
</select>
</div>
{/* Columns Section */}
<div style={{ marginBottom: '24px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '10px'
}}>
<label style={{ fontWeight: '500', color: '#333' }}>
Columns
</label>
<button
type="button"
onClick={() => setShowColumnInput(true)}
style={{
padding: '5px 10px',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '5px'
}}
>
<FaPlus size={10} /> Add Column
</button>
</div>
{showColumnInput && (
<div style={{
display: 'flex',
gap: '10px',
marginBottom: '10px'
}}>
<input
type="text"
value={columnName}
onChange={(e) => setColumnName(e.target.value)}
placeholder="Column name"
style={{
flex: 1,
padding: '8px',
borderRadius: '4px',
border: '1px solid #ddd',
fontSize: '14px'
}}
/>
<button
type="button"
onClick={addColumn}
style={{
padding: '8px 15px',
backgroundColor: '#52c41a',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Add
</button>
<button
type="button"
onClick={() => {
setShowColumnInput(false);
setColumnName('');
}}
style={{
padding: '8px 15px',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Cancel
</button>
</div>
)}
{formData.columns.length > 0 ? (
<div style={{
border: '1px solid #eee',
borderRadius: '4px',
maxHeight: '150px',
overflowY: 'auto'
}}>
{formData.columns.map((column, index) => (
<div
key={index}
style={{
padding: '8px 12px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: index < formData.columns.length - 1 ? '1px solid #f0f0f0' : 'none',
backgroundColor: index % 2 === 0 ? '#f9f9f9' : 'white'
}}
>
<span>{column}</span>
<button
type="button"
onClick={() => removeColumn(index)}
style={{
background: 'none',
border: 'none',
color: '#ff4d4f',
cursor: 'pointer',
fontSize: '14px'
}}
>
×
</button>
</div>
))}
</div>
) : (
<div style={{
padding: '15px',
textAlign: 'center',
border: '1px dashed #ddd',
borderRadius: '4px',
color: '#999',
backgroundColor: '#fafafa',
fontSize: '13px'
}}>
No columns added yet. Click "Add Column" to create one.
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '10px 20px',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Cancel
</button>
<button
type="submit"
style={{
padding: '10px 20px',
backgroundColor: '#00a99d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
{initialData ? 'Update Table' : 'Add Table'}
</button>
</div>
</form>
);
};
// 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,
});
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
pointerEvents: 'all',
backgroundColor: style.stroke || '#1a1a1a',
padding: '2px 5px',
borderRadius: '4px',
color: 'white',
fontWeight: 500,
boxShadow: '0 0 5px rgba(0,0,0,0.3)',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
className="nodrag nopan"
>
<FaArrowRight size={10} />
{data?.label || 'connects to'}
</div>
</EdgeLabelRenderer>
</>
);
};
// Create a unique ID for edges that should be animated
const SERVICE_EDGE_CLASS = 'service-db-connection';
// Add the CSS for the animated connections directly to the stylesheet
// This avoids potential issues with React's rendering cycle
const style = document.createElement('style');
style.textContent = `
@keyframes dashdraw {
from {
stroke-dashoffset: 10;
}
to {
stroke-dashoffset: 0;
}
}
.${SERVICE_EDGE_CLASS} .react-flow__edge-path {
animation: dashdraw 0.5s linear infinite;
}
`;
// Add the style element only once
if (!document.getElementById('react-flow-animation')) {
style.id = 'react-flow-animation';
document.head.appendChild(style);
}
// Hierarchical Edge for connecting service to databases
const HierarchicalEdge = ({ id, source, target, sourceX, sourceY, targetX, targetY, style = {} }) => {
// Calculate the path points for a stepped edge with orthogonal lines
const [path] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition: Position.Bottom, // Always start from bottom
targetX,
targetY,
targetPosition: Position.Top, // Always connect to top
borderRadius: 20, // Smoother corners for longer connections
stepSize: 30, // Increased step size for longer connections
});
// Enhanced style for hierarchical connections with animated dotted line
const hierarchyStyle = {
strokeWidth: 2,
stroke: '#00a99d',
strokeDasharray: '5, 5', // Create dotted/dashed line effect
...style,
};
return (
<g className={SERVICE_EDGE_CLASS}>
<BaseEdge
id={id}
path={path}
style={hierarchyStyle}
markerEnd={{
type: 'arrowclosed',
width: 16,
height: 16,
color: '#00a99d',
strokeWidth: 2,
}}
/>
</g>
);
};
// Custom node types
const ServiceNode = ({ data = {} }) => {
// Safety check for undefined data
if (!data) {
console.error('ServiceNode received undefined data');
data = {}; // Provide a default empty object
}
return (
<div style={{
padding: '20px',
borderRadius: '12px',
background: 'linear-gradient(145deg, #222, #2a2a2a)',
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(0, 169, 157, 0.3) inset',
border: '2px solid #00a99d',
color: '#fff',
width: '240px',
minHeight: '110px',
display: 'flex',
flexDirection: 'column',
position: 'relative',
fontFamily: 'Inter, sans-serif',
justifyContent: 'space-between'
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '16px'
}}>
{/* Logo and text container */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '8px'
}}>
<img
src={planPlusLogo}
alt="Logo"
width="85"
style={{
marginRight: '2px',
verticalAlign: 'middle'
}}
/>
<span style={{
fontWeight: '600',
fontSize: '20px',
color: '#fff',
marginLeft: '2px',
marginTop: '2px'
}}>
Plan+
</span>
</div>
{/* Connected databases count */}
<div style={{
fontSize: '12px',
color: '#aaa',
marginTop: '2px',
textAlign: 'center'
}}>
Connected Databases: {data.databases || 0}
</div>
</div>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: '8px'
}}>
<button
style={{
background: 'linear-gradient(145deg, rgba(0, 169, 157, 0.2), rgba(0, 169, 157, 0.1))',
border: '1px solid rgba(0, 169, 157, 0.4)',
borderRadius: '6px',
padding: '8px 16px',
color: '#00a99d',
fontSize: '12px',
fontWeight: '500',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: '0 2px 5px rgba(0, 0, 0, 0.2)',
transition: 'all 0.2s ease',
width: '100%',
justifyContent: 'center'
}}
onClick={() => data.onToggle && data.onToggle(data.id)}
>
{data.expanded ? (
<>
<span>Collapse Connections</span>
<FaChevronUp size={10} />
</>
) : (
<>
<span>Expand Connections</span>
<FaChevronDown size={10} />
</>
)}
</button>
</div>
{/* Handles for connections - explicit id for targeting */}
<Handle
id="bottom"
type="source"
position={Position.Bottom}
style={{
background: '#00a99d',
width: '8px',
height: '8px',
border: '2px solid #00a99d',
boxShadow: '0 0 5px rgba(0, 169, 157, 0.5)',
bottom: '-5px' // Move it slightly outside the node for better connection
}}
/>
</div>
);
};
const DatabaseNode = ({ data = {} }) => {
// Safety check for undefined data
if (!data) {
console.error('DatabaseNode received undefined data');
data = {}; // Provide a default empty object
}
// Enhanced styles for handles used in hierarchy connections
// Debug: Log the data being received by the component
// console.log('DatabaseNode data:', data);
// Assign different colors to different databases
const colorMap = {
'my_dwh': '#00a99d', // First database - teal
'my_dwh2': '#1890ff', // Second database - blue
'default': '#9c27b0' // Default - purple
};
// Get the database slug from the id (format: db-{slug})
// Add a safety check for undefined data.id
// Use data.id if available, otherwise fall back to the node's id
const nodeId = (data && data.id) ? data.id : 'default';
const dbSlug = (nodeId && typeof nodeId === 'string' && nodeId.startsWith && nodeId.startsWith('db-'))
? nodeId.substring(3)
: 'default';
// Use the color from the map or default to a fallback color
const borderColor = colorMap[dbSlug] || '#ff9800';
const handleColor = borderColor;
const bgColor = '#1a1a1a';
// Create a safe gradient ID by removing any special characters
const safeDbSlug = (dbSlug && typeof dbSlug === 'string')
? dbSlug.replace(/[^a-zA-Z0-9]/g, '_')
: 'default';
const gradientId = `db_paint0_linear_${safeDbSlug}`;
// Database SVG icon
const DatabaseIcon = () => (
<svg width="30" height="28" viewBox="0 0 41 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40.5" height="38.7" rx="3.6" fill={`url(#${gradientId})`}/>
<path d="M19.8845 24.789C17.0714 24.789 14.2465 24.0672 12.912 22.5097C12.8725 22.683 12.8462 22.8607 12.8462 23.0493V25.8844C12.8462 28.3962 16.4937 29.5392 19.8845 29.5392C23.2753 29.5392 26.9228 28.3962 26.9228 25.8844V23.0493C26.9228 22.8607 26.8964 22.6837 26.8569 22.5104C25.5217 24.068 22.6976 24.7904 19.8837 24.7904L19.8845 24.789ZM19.8845 19.3083C17.0714 19.3083 14.2465 18.5865 12.9127 17.0289C12.8706 17.2053 12.8486 17.3858 12.8469 17.5671V20.4022C12.8469 22.9133 16.4937 24.0563 19.8845 24.0563C23.2753 24.0563 26.9228 22.9133 26.9228 20.4015V17.5657C26.9228 17.3778 26.8964 17.2001 26.8569 17.0268C25.5217 18.5843 22.6976 19.3068 19.8837 19.3068L19.8845 19.3083ZM19.8845 8.42944C16.4937 8.42944 12.8462 9.57385 12.8462 12.0857V14.9208C12.8462 17.4326 16.4937 18.5755 19.8845 18.5755C23.2753 18.5755 26.9228 17.4333 26.9228 14.9215V12.0864C26.9228 9.57458 23.2753 8.43017 19.8845 8.43017V8.42944ZM19.8845 14.2794C16.8059 14.2794 14.3087 13.2974 14.3087 12.0857C14.3087 10.8747 16.8052 9.89194 19.8845 9.89194C22.9638 9.89194 25.4603 10.8747 25.4603 12.0857C25.4603 13.2981 22.9638 14.2794 19.8845 14.2794Z" fill="white"/>
<defs>
<linearGradient id={gradientId} x1="40.5" y1="19.35" x2="0" y2="19.35" gradientUnits="userSpaceOnUse">
<stop stopColor={borderColor}/>
<stop offset="0.711538" stopColor={handleColor}/>
</linearGradient>
</defs>
</svg>
);
return (
<div className="database-node" style={{
padding: '10px',
borderRadius: '5px',
background: bgColor,
border: `1px solid ${borderColor}`,
width: '180px',
position: 'relative',
boxShadow: '0 0 10px rgba(0, 0, 0, 0.3)',
color: '#ffffff'
}}>
{/* Connection handles */}
<Handle
type="source"
position={Position.Right}
id="right"
style={{ background: handleColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<Handle
type="target"
position={Position.Left}
id="left"
style={{ background: handleColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: handleColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<Handle
type="target"
position={Position.Top}
id="top"
style={{ background: handleColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<div style={{
fontWeight: 'bold',
marginBottom: '5px',
display: 'flex',
alignItems: 'center'
}}>
<span style={{ marginRight: '8px' }}>
<DatabaseIcon />
</span>
{/* Display the database name from the API response */}
<span title={data.name || data.label || 'Unknown Database'} style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '120px' }}>
{data.name || data.label || 'Unknown Database'}
</span>
</div>
{/* <div style={{ fontSize: '0.8em', color: '#aaa', marginBottom: '5px' }}>
{`${data.schemas} schemas • ${data.tables} tables`}
</div>
<div style={{ fontSize: '0.7em', color: '#888', marginBottom: '10px' }}>
Connection: {dbSlug}
</div> */}
{/* View Details Button */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
onClick={(event) => {
// Stop propagation to prevent the node click handler from being triggered
event.stopPropagation();
// Call the viewDetails function passed in data with safety checks
if (data && data.onViewDetails && data.id) {
data.onViewDetails(data.id, data.name || data.label || 'Unknown Database');
} else {
console.warn('Cannot view details: missing required data properties');
}
}}
style={{
padding: '4px 8px',
backgroundColor: borderColor,
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '5px',
transition: 'all 0.2s'
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12C2.73 16.39 7 19.5 12 19.5C17 19.5 21.27 16.39 23 12C21.27 7.61 17 4.5 12 4.5ZM12 17C9.24 17 7 14.76 7 12C7 9.24 9.24 7 12 7C14.76 7 17 9.24 17 12C17 14.76 14.76 17 12 17ZM12 9C10.34 9 9 10.34 9 12C9 13.66 10.34 15 12 15C13.66 15 15 13.66 15 12C15 10.34 13.66 9 12 9Z" fill="white"/>
</svg>
View Data Mapping
</button>
</div>
</div>
);
};
const SchemaNode = ({ data }) => {
// Use teal color for schemas under Dbtez
const isDbtezSchema = data.dbId === 'db4';
const isSalesSchema = data.label === 'Sales';
// Use dark theme colors
const bgColor = '#1a1a1a';
const borderColor = isSalesSchema ? '#fa8c16' : (isDbtezSchema ? '#00a99d' : '#52c41a');
const handleColor = isSalesSchema ? '#fa8c16' : (isDbtezSchema ? '#00a99d' : '#52c41a');
// Schema SVG icon
const SchemaIcon = () => (
<svg width="30" height="28" viewBox="0 0 42 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.6875" y="0.136963" width="40.95" height="38.4259" rx="3.6" fill="url(#schema_paint0_linear)"/>
<path fillRule="evenodd" clipRule="evenodd" d="M35.28 25.3874H32.8947C32.7256 23.869 32.0612 22.4376 30.9499 21.2794C29.4071 19.6721 27.226 18.7868 24.8092 18.7868H21.7639V11.5874H27.2722C27.572 11.5874 27.8149 11.3441 27.8149 11.0447V6.95504C27.8149 6.65523 27.572 6.41235 27.2722 6.41235H15.2479C14.9478 6.41235 14.7049 6.65523 14.7049 6.95504V11.0447C14.7049 11.3441 14.9478 11.5874 15.2479 11.5874H20.7469V18.7868H17.6905C13.3466 18.7868 10.1185 21.5175 9.63615 25.4032C7.87769 25.5619 6.49976 27.0378 6.49976 28.8374C6.49976 30.7424 8.04467 32.2874 9.94976 32.2874C11.8552 32.2874 13.3998 30.7424 13.3998 28.8374C13.3998 27.1713 12.2188 25.7817 10.6487 25.4584C11.0914 22.0881 13.8538 19.8039 17.6905 19.8039H20.7469V25.3874H17.2251C17.0347 25.3874 16.9226 25.6088 17.0309 25.771L21.0657 32.1814C21.1595 32.3225 21.3603 32.3225 21.4545 32.1814L25.4892 25.771C25.5976 25.6088 25.4851 25.3874 25.295 25.3874H21.7639V19.8039H24.8092C26.9469 19.8039 28.8671 20.5781 30.2157 21.9836C31.1428 22.9492 31.7079 24.1322 31.8728 25.3874H29.4695C29.1687 25.3874 28.9248 25.6313 28.9248 25.9321V31.7426C28.9248 32.0434 29.1687 32.2874 29.4695 32.2874H35.28C35.5808 32.2874 35.8248 32.0434 35.8248 31.7426V25.9321C35.8248 25.6313 35.5808 25.3874 35.28 25.3874Z" fill="white"/>
<defs>
<linearGradient id="schema_paint0_linear" x1="0.6875" y1="19.3499" x2="41.6375" y2="19.3499" gradientUnits="userSpaceOnUse">
<stop stopColor="#FF9D2C"/>
<stop offset="1" stopColor="#CD750F"/>
</linearGradient>
</defs>
</svg>
);
return (
<div className="schema-node" style={{
padding: '10px',
borderRadius: '5px',
background: bgColor,
border: `1px solid ${borderColor}`,
width: '160px',
position: 'relative',
boxShadow: isDbtezSchema ? '0 0 10px rgba(0, 169, 157, 0.3)' : 'none',
color: '#ffffff'
}}>
{/* Connection handles */}
<Handle
type="source"
position={Position.Right}
id="right"
style={{ background: handleColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<Handle
type="target"
position={Position.Left}
id="left"
style={{ background: handleColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: handleColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<Handle
type="target"
position={Position.Top}
id="top"
style={{ background: handleColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<div style={{
fontWeight: 'bold',
marginBottom: '5px',
display: 'flex',
alignItems: 'center'
}}>
<span style={{ marginRight: '8px' }}>
<SchemaIcon />
</span>
{data.label}
</div>
<div style={{ fontSize: '0.8em', color: '#aaa' }}>
{isDbtezSchema ? 'Schema' : `${data.tables} tables`}
</div>
</div>
);
};
const TableNode = ({ data }) => {
const tableType = data.type || (data.isFact ? 'fact' : 'dimension');
// Determine colors based on table type
let borderColor, background, labelBg, labelColor;
switch(tableType) {
case 'fact':
borderColor = '#fa8c16'; // Orange for fact tables
background = '#1a1a1a';
labelBg = '#fa8c16';
labelColor = 'white';
break;
case 'stage':
borderColor = '#1890ff'; // Blue for stage tables
background = '#1a1a1a';
labelBg = '#1890ff';
labelColor = 'white';
break;
case 'dimension':
default:
borderColor = '#52c41a'; // Green for dimension tables
background = '#1a1a1a';
labelBg = '#52c41a';
labelColor = 'white';
}
// Table SVG icon
const TableIcon = () => (
<svg width="30" height="28" viewBox="0 0 42 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.825195" width="40.5" height="38.7" rx="3.6" fill="url(#table_paint0_linear)"/>
<path d="M17.1504 20.7083H24.9896V25.2531H17.1504V20.7083ZM17.1504 31.0499H24.9896V26.5051H17.1504V31.0499ZM17.1504 19.4563H24.9896V14.9053H17.1504V19.4563ZM26.0349 19.4563H33.8741V14.9053H26.0349V19.4563ZM8.26592 19.4563H16.1052V14.9053H8.26592V19.4563ZM8.26592 25.2531H16.1052V20.7083H8.26592V25.2531ZM26.061 25.2531H33.9002V20.7083H26.061V25.2531ZM32.3324 7.6499H9.81809C9.40227 7.6499 9.00348 7.84776 8.70946 8.19996C8.41543 8.55215 8.25024 9.02983 8.25024 9.52791V13.6533H16.5494C16.5754 13.6497 16.6017 13.6497 16.6278 13.6533H16.7062H25.4443H25.5227C25.5488 13.6497 25.5751 13.6497 25.6011 13.6533H33.9002V9.52791C33.9002 9.02983 33.7351 8.55215 33.441 8.19996C33.147 7.84776 32.7482 7.6499 32.3324 7.6499ZM8.25024 29.1719C8.25024 29.67 8.41543 30.1477 8.70946 30.4998C8.85504 30.6742 9.02788 30.8126 9.2181 30.9069C9.40832 31.0013 9.6122 31.0499 9.81809 31.0499H16.0895V26.5051H8.25024V29.1719ZM26.0453 31.0499H32.3167C32.7325 31.0499 33.1313 30.852 33.4254 30.4998C33.7194 30.1477 33.8846 29.67 33.8846 29.1719V26.5051H26.0453V31.0499Z" fill="white"/>
<defs>
<linearGradient id="table_paint0_linear" x1="41.3252" y1="19.35" x2="0.825195" y2="19.35" gradientUnits="userSpaceOnUse">
<stop stopColor="#659667"/>
<stop offset="1" stopColor="#81C784"/>
</linearGradient>
</defs>
</svg>
);
return (
<div className="table-node" style={{
padding: '10px',
borderRadius: '5px',
background: background,
border: `1px solid ${borderColor}`,
width: '150px',
position: 'relative',
boxShadow: `0 0 10px ${borderColor}33`,
color: '#ffffff'
}}>
{/* Connection handles */}
<Handle
type="source"
position={Position.Right}
id="right"
style={{ background: borderColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<Handle
type="target"
position={Position.Left}
id="left"
style={{ background: borderColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: borderColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<Handle
type="target"
position={Position.Top}
id="top"
style={{ background: borderColor, width: '10px', height: '10px' }}
isConnectable={true}
/>
<div style={{ fontWeight: 'bold', marginBottom: '5px', display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', flex: 1 }}>
<span style={{ marginRight: '8px' }}>
<TableIcon />
</span>
<span style={{ maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{data.label}
</span>
<span style={{
fontSize: '10px',
background: labelBg,
color: labelColor,
padding: '1px 4px',
borderRadius: '3px',
marginLeft: '5px',
verticalAlign: 'middle'
}}>
{tableType.toUpperCase()}
</span>
</div>
</div>
<div style={{ fontSize: '0.8em', color: '#aaa' }}>
{data.columns && data.columns.length > 0 ?
`${data.columns.length} columns` :
tableType === 'fact' ? 'Fact Table' :
tableType === 'stage' ? 'Stage Table' :
'Dimension Table'}
</div>
</div>
);
};
// Generate data for InfiniteCanvas using mockApiData from DataflowCanvas
const generateMockData = () => {
// Get unique database slugs from schemas
const uniqueDatabases = [...new Set(mockApiData.schemas.map(schema => schema.database))];
// Create databases from unique database slugs
const databases = uniqueDatabases.map((dbSlug, index) => {
// Find all schemas for this database
const dbSchemas = mockApiData.schemas.filter(schema => schema.database === dbSlug);
// Find all tables for this database
const dbTables = mockApiData.tables.filter(table => table.database === dbSlug);
// Get the database name from the first schema (if available)
const dbName = dbSchemas.length > 0 && dbSchemas[0].databaseName
? dbSchemas[0].databaseName
: `Database ${dbSlug}`;
return {
id: `db-${dbSlug}`,
name: dbName, // This will be displayed in the UI
slug: dbSlug,
schemas: dbSchemas.length,
tables: dbTables.length
};
});
// If no databases were found, add a default one
if (databases.length === 0) {
databases.push({
id: 'db-default',
name: 'Default Database',
slug: 'default',
schemas: mockApiData.schemas.length,
tables: mockApiData.tables.length
});
}
// Create schemas from mockApiData
const schemas = mockApiData.schemas.map((schema, index) => {
// Find the database for this schema
const dbSlug = schema.database || 'default';
const db = databases.find(db => db.slug === dbSlug) || databases[0];
return {
id: `schema-${schema.slug}`,
dbId: db.id,
name: schema.name,
slug: schema.slug,
tables: mockApiData.tables.filter(table => table.schema === schema.slug).length
};
});
// Create tables from mockApiData
const tables = mockApiData.tables.map((table, index) => {
// Find the schema this table belongs to
const schemaSlug = table.schema;
const schemaObj = schemas.find(s => s.slug === schemaSlug);
if (!schemaObj) {
console.warn(`Schema not found for table ${table.name} (${table.slug})`);
return null;
}
return {
id: `table-${table.slug}`,
schemaId: schemaObj.id,
name: table.name,
slug: table.slug,
isFact: table.type === 'fact',
type: table.type,
columns: table.columns,
database: table.database
};
}).filter(Boolean); // Remove any null entries
return { databases, schemas, tables };
};
const InfiniteCanvas = () => {
const reactFlowWrapper = useRef(null);
const [reactFlowInstance, setReactFlowInstance] = useState(null);
const { fitView, setViewport, getViewport } = useReactFlow();
// State to track which elements are expanded
const [expandedDatabases, setExpandedDatabases] = useState({});
const [expandedSchemas, setExpandedSchemas] = useState({});
const [selectedTable, setSelectedTable] = useState(null);
// State for infinite canvas
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
// State for connection mode
const [isConnectionMode, setIsConnectionMode] = useState(false);
const [connectionSource, setConnectionSource] = useState(null);
const [connectionType, setConnectionType] = useState('default'); // default, reference, dependency
// State for entity creation modals
const [showAddEntityModal, setShowAddEntityModal] = useState(false);
const [activeEntityType, setActiveEntityType] = useState('database'); // 'database', 'schema', or 'table'
const [selectedDatabaseForSchema, setSelectedDatabaseForSchema] = useState(null);
const [selectedSchemaForTable, setSelectedSchemaForTable] = useState(null);
// Initialize with mock data
const mockData = generateMockData();
// State for storing created entities
const [databases, setDatabases] = useState(() => {
// Try to load databases from localStorage
try {
const savedDatabases = localStorage.getItem('databases');
if (savedDatabases) {
return JSON.parse(savedDatabases);
}
} catch (error) {
console.error('Error loading databases from localStorage:', error);
}
// If no saved databases, use the databases from our API
return mockData.databases.map(db => ({
...db,
description: db.description || `Database ${db.name}`,
type: db.type || 'PostgreSQL'
}));
});
// Track if we've already fetched the database data
const [hasFetchedDatabases, setHasFetchedDatabases] = useState(false);
// Fetch real database data from API - only once
useEffect(() => {
// Skip if we've already fetched the data
if (hasFetchedDatabases) {
console.log('Skipping database fetch - already loaded');
return;
}
const fetchDatabasesFromAPI = async () => {
try {
// API configuration
const API_BASE_URL = 'https://sandbox.kezel.io/api';
const token = "abdhsg"; // Replace with your actual token
const orgSlug = "sN05Pjv11qvH"; // Replace with your actual org slug
console.log('Fetching databases from API...');
const response = await axios.post(
`${API_BASE_URL}/qbt_database_list_get`,
{
token: token,
org: orgSlug,
},
{
headers: {
'Content-Type': 'application/json'
}
}
);
console.log('API Response:', response.data);
if (response.data && response.data.items && Array.isArray(response.data.items)) {
// Map API response to our database format
const apiDatabases = response.data.items.map((db, index) => ({
id: `db-${db.con || index}`,
name: db.name, // Use the name from API
slug: db.con,
description: db.description || `Database ${db.name}`,
type: db.database_type || 'PostgreSQL',
schemas: 0, // We'll update these later if needed
tables: 0
}));
console.log('Mapped databases from API:', apiDatabases);
// Update state with API data
setDatabases(apiDatabases);
// Mark that we've fetched the data
setHasFetchedDatabases(true);
}
} catch (error) {
console.error('Error fetching databases from API:', error);
}
};
// Call the function to fetch databases
fetchDatabasesFromAPI();
}, [hasFetchedDatabases]); // Only re-run if hasFetchedDatabases changes
const [schemas, setSchemas] = useState(() => {
// Try to load schemas from localStorage
try {
const savedSchemas = localStorage.getItem('schemas');
if (savedSchemas) {
return JSON.parse(savedSchemas);
}
} catch (error) {
console.error('Error loading schemas from localStorage:', error);
}
return [];
});
const [tables, setTables] = useState(() => {
// Try to load tables from localStorage
try {
const savedTables = localStorage.getItem('tables');
if (savedTables) {
return JSON.parse(savedTables);
}
} catch (error) {
console.error('Error loading tables from localStorage:', error);
}
return [];
});
// Save databases, schemas, and tables to localStorage whenever they change
useEffect(() => {
localStorage.setItem('databases', JSON.stringify(databases));
}, [databases]);
useEffect(() => {
localStorage.setItem('schemas', JSON.stringify(schemas));
}, [schemas]);
useEffect(() => {
localStorage.setItem('tables', JSON.stringify(tables));
}, [tables]);
// Handler for creating a new database with nested schemas and tables
const handleCreateDatabase = (formData) => {
// Create the database
const dbId = `db-${Date.now()}`;
const newDatabase = {
id: dbId,
name: formData.name,
description: formData.description,
url: formData.url,
key: formData.key,
type: formData.type,
schemas: formData.schemas.length,
tables: formData.schemas.reduce((count, schema) => count + schema.tables.length, 0)
};
setDatabases(prev => [...prev, newDatabase]);
// Add the new database node to the canvas
const dbNode = {
id: dbId,
type: 'database',
data: {
label: newDatabase.name,
name: newDatabase.name, // Include the name explicitly
schemas: newDatabase.schemas,
tables: newDatabase.tables,
expanded: false,
onToggle: toggleDatabaseExpansion,
onViewDetails: handleViewDataFlow // Add the function to handle redirection
},
position: {
x: Math.random() * 300,
y: Math.random() * 300
},
};
const newNodes = [dbNode];
const newEdges = [];
// Process schemas
const createdSchemas = formData.schemas.map((schemaData, schemaIndex) => {
const schemaId = `schema-${Date.now()}-${schemaIndex}`;
const newSchema = {
id: schemaId,
dbId: dbId,
name: schemaData.name,
description: schemaData.description,
slug: schemaData.name.toLowerCase().replace(/\s+/g, '_'),
tables: schemaData.tables.length
};
// Create schema node (positioned relative to database)
const schemaNode = {
id: schemaId,
type: 'schema',
data: {
label: schemaData.name,
tables: schemaData.tables.length,
dbId: dbId,
expanded: false,
onToggle: toggleSchemaExpansion
},
position: {
x: dbNode.position.x + 300,
y: dbNode.position.y + (schemaIndex * 150)
},
};
newNodes.push(schemaNode);
// Create edge from database to schema
newEdges.push({
id: `e-${dbId}-${schemaId}`,
source: dbId,
target: schemaId,
type: 'custom',
animated: true,
style: { stroke: '#00a99d', strokeWidth: 2 },
markerEnd: {
type: 'arrowclosed',
width: 20,
height: 20,
color: '#00a99d',
},
data: {
label: 'contains'
}
});
// Process tables for this schema
const schemaTables = schemaData.tables.map((tableData, tableIndex) => {
const tableId = `table-${Date.now()}-${schemaIndex}-${tableIndex}`;
const newTable = {
id: tableId,
schemaId: schemaId,
name: tableData.name,
description: tableData.description,
slug: tableData.name.toLowerCase().replace(/\s+/g, '_'),
type: tableData.type,
isFact: tableData.type === 'fact',
columns: tableData.columns || []
};
// Create table node (positioned relative to schema)
const tableNode = {
id: tableId,
type: 'table',
data: {
label: tableData.name,
type: tableData.type,
isFact: tableData.type === 'fact',
schemaId: schemaId,
columns: tableData.columns || []
},
position: {
x: schemaNode.position.x + 300,
y: schemaNode.position.y + (tableIndex * 100)
},
};
newNodes.push(tableNode);
// Create edge from schema to table
newEdges.push({
id: `e-${schemaId}-${tableId}`,
source: schemaId,
target: tableId,
type: 'custom',
animated: true,
style: {
stroke: tableData.type === 'fact' ? '#fa8c16' :
tableData.type === 'stage' ? '#1890ff' : '#52c41a',
strokeWidth: 2
},
markerEnd: {
type: 'arrowclosed',
width: 20,
height: 20,
color: tableData.type === 'fact' ? '#fa8c16' :
tableData.type === 'stage' ? '#1890ff' : '#52c41a',
},
data: {
label: 'contains'
}
});
return newTable;
});
// Add tables to state
setTables(prev => [...prev, ...schemaTables]);
return newSchema;
});
// Add schemas to state
setSchemas(prev => [...prev, ...createdSchemas]);
// Add all nodes and edges to the canvas
setNodes(nodes => [...nodes, ...newNodes]);
setEdges(edges => [...edges, ...newEdges]);
// Auto-expand the database if it has schemas
if (formData.schemas.length > 0) {
setTimeout(() => {
toggleDatabaseExpansion(dbId);
// Auto-expand schemas if they have tables
formData.schemas.forEach((schema, index) => {
if (schema.tables.length > 0) {
const schemaId = `schema-${Date.now()}-${index}`;
toggleSchemaExpansion(schemaId);
}
});
}, 500);
}
setShowAddEntityModal(false);
// Fit view to show all the new nodes
setTimeout(() => {
fitView();
}, 1000);
};
// Handler for creating a new schema
const handleCreateSchema = (formData) => {
const parentDb = formData.parentDatabase;
const newSchema = {
id: `schema-${Date.now()}`,
dbId: parentDb.id,
name: formData.name,
description: formData.description,
slug: formData.name.toLowerCase().replace(/\s+/g, '_'),
tables: 0
};
setSchemas(prev => [...prev, newSchema]);
// Update the parent database's schema count
setDatabases(prev => prev.map(db =>
db.id === parentDb.id
? { ...db, schemas: db.schemas + 1 }
: db
));
// If the parent database is expanded, add the schema node to the canvas
if (expandedDatabases[parentDb.id]) {
const dbNode = nodes.find(n => n.id === parentDb.id);
const newNode = {
id: newSchema.id,
type: 'schema',
data: {
label: newSchema.name,
tables: newSchema.tables,
dbId: parentDb.id,
expanded: false,
onToggle: toggleSchemaExpansion
},
position: {
x: dbNode.position.x + 150,
y: dbNode.position.y + 150 + (schemas.filter(s => s.dbId === parentDb.id).length * 100)
},
};
setNodes(nodes => [...nodes, newNode]);
// Add an edge from the database to the schema
const newEdge = {
id: `e-${parentDb.id}-${newSchema.id}`,
source: parentDb.id,
target: newSchema.id,
type: 'custom',
animated: true,
style: { stroke: '#00a99d', strokeWidth: 2 },
markerEnd: {
type: 'arrowclosed',
width: 20,
height: 20,
color: '#00a99d',
},
data: {
label: 'contains'
}
};
setEdges(edges => [...edges, newEdge]);
}
setShowAddEntityModal(false);
setSelectedDatabaseForSchema(null);
// Fit view to show the new node
setTimeout(() => {
fitView();
}, 10);
};
// Handler for creating a new table
const handleCreateTable = (formData) => {
const parentSchema = formData.parentSchema;
const newTable = {
id: `table-${Date.now()}`,
schemaId: parentSchema.id,
name: formData.name,
description: formData.description,
slug: formData.name.toLowerCase().replace(/\s+/g, '_'),
type: formData.type,
isFact: formData.type === 'fact',
columns: []
};
setTables(prev => [...prev, newTable]);
// Update the parent schema's table count
setSchemas(prev => prev.map(schema =>
schema.id === parentSchema.id
? { ...schema, tables: schema.tables + 1 }
: schema
));
// Update the parent database's table count
const parentDb = databases.find(db => db.id === parentSchema.dbId);
if (parentDb) {
setDatabases(prev => prev.map(db =>
db.id === parentDb.id
? { ...db, tables: db.tables + 1 }
: db
));
}
// If the parent schema is expanded, add the table node to the canvas
if (expandedSchemas[parentSchema.id]) {
const schemaNode = nodes.find(n => n.id === parentSchema.id);
const newNode = {
id: newTable.id,
type: 'table',
data: {
label: newTable.name,
type: newTable.type,
isFact: newTable.isFact,
schemaId: parentSchema.id,
columns: newTable.columns
},
position: {
x: schemaNode.position.x + 150,
y: schemaNode.position.y + 150 + (tables.filter(t => t.schemaId === parentSchema.id).length * 100)
},
};
setNodes(nodes => [...nodes, newNode]);
// Add an edge from the schema to the table
const newEdge = {
id: `e-${parentSchema.id}-${newTable.id}`,
source: parentSchema.id,
target: newTable.id,
type: 'custom',
animated: true,
style: {
stroke: newTable.isFact ? '#fa8c16' : '#52c41a',
strokeWidth: 2
},
markerEnd: {
type: 'arrowclosed',
width: 20,
height: 20,
color: newTable.isFact ? '#fa8c16' : '#52c41a',
},
data: {
label: 'contains'
}
};
setEdges(edges => [...edges, newEdge]);
}
setShowAddEntityModal(false);
setSelectedSchemaForTable(null);
// Fit view to show the new node
setTimeout(() => {
fitView();
}, 10);
};
// Define node types (memoized to prevent unnecessary re-renders)
const nodeTypes = useMemo(() => ({
service: ServiceNode,
database: DatabaseNode,
schema: SchemaNode,
table: TableNode,
}), []);
// Define edge types
const edgeTypes = useMemo(() => ({
custom: CustomEdge,
hierarchical: HierarchicalEdge,
}), []);
// Function to handle redirection to DataFlow view
const handleViewDataFlow = (dbId, dbName) => {
// Safety check for undefined parameters
if (!dbId || !dbName) {
console.error('Invalid database ID or name provided to handleViewDataFlow');
return;
}
// Find the database object to get the slug
const database = databases.find(db => db.id === dbId);
if (!database) {
console.error(`Database with ID ${dbId} not found`);
return;
}
// Get the database slug, with a fallback
const dbSlug = database.slug || (dbId.startsWith('db-') ? dbId.substring(3) : 'default');
console.log(`Viewing data flow for database: ${dbName} (${dbSlug})`);
// Selectively clear cached data for the current database only
try {
// Only clear the cache for the current database
if (dbSlug === 'my_dwh2') {
console.log('Setting empty schemas for service 2 database');
// For service 2, we want to ensure it always shows as empty
localStorage.setItem(`schemas_${dbSlug}`, JSON.stringify([]));
localStorage.setItem(`tables_${dbSlug}`, JSON.stringify([]));
} else {
// For other databases like MyDataWarehouseDB, we want to preserve their data
// but ensure we're not using stale data
console.log(`Preserving schemas for database: ${dbName}`);
// We don't need to clear anything for MyDataWarehouseDB
// This ensures its schemas will be loaded from the API or mock data
}
} catch (error) {
console.error('Error managing cached data in localStorage:', error);
}
// Store the selected database info in localStorage for the DataFlow component to use
localStorage.setItem('selectedDatabase', JSON.stringify({
id: dbId,
name: dbName,
slug: dbSlug, // Include the slug for API calls
isEmpty: dbSlug === 'my_dwh2', // Flag to indicate if this database has no schemas
timestamp: Date.now() // Add timestamp to ensure uniqueness
}));
// Set the current database slug in mockData.js
if (typeof window.setCurrentDbSlug === 'function') {
// First clear any existing data
window.setCurrentDbSlug(null);
// Then set the new database slug
setTimeout(() => {
window.setCurrentDbSlug(dbSlug);
}, 50);
} else {
console.warn('window.setCurrentDbSlug function is not available');
}
// Check if this database should have empty schemas
const isEmpty = dbSlug === 'my_dwh2';
// Trigger an event that App.jsx can listen to
const event = new CustomEvent('viewDataFlow', {
detail: {
databaseId: dbId,
databaseName: dbName,
databaseSlug: dbSlug,
isEmpty: isEmpty, // Flag to indicate if this database has no schemas
timestamp: Date.now() // Add timestamp to ensure uniqueness
}
});
window.dispatchEvent(event);
// No alert needed for MVP - the view will change automatically
};
// Create a service node for "Plan Plus"
const serviceNode = {
id: 'service-plan-plus',
type: 'service',
data: {
id: 'service-plan-plus',
// label: 'Qubit Service: Plan+',
databases: databases.length,
expanded: true,
onToggle: (id) => {
// Toggle expansion of the service node
setNodes(nodes => nodes.map(node => {
if (node.id === id) {
return { ...node, data: { ...node.data, expanded: !node.data.expanded } };
}
return node;
}));
}
},
position: { x: 400, y: 70 } // Positioned even higher on the canvas for maximum spacing
};
// Initialize with database nodes as children of the service
const databaseNodes = databases
.filter(db => db && db.id) // Filter out any invalid database objects
.map((db, index) => {
// Create a safe ID for the database
const safeId = db.id;
return {
id: safeId,
type: 'database',
data: {
id: safeId, // Explicitly include id in data to ensure it's available
label: db.name || `Database ${index + 1}`,
name: db.name, // Include the name from the API response
schemas: db.schemas || 0,
tables: db.tables || 0,
expanded: false,
onToggle: (id) => toggleDatabaseExpansion(id),
onViewDetails: handleViewDataFlow // Add the function to handle redirection
},
// Position databases in a grid layout with significantly increased vertical spacing
position: {
x: 250 + (index % 2) * 400, // 2 columns
y: 400 + Math.floor(index / 2) * 250 // further increased vertical spacing (400px) from service node
},
};
});
// Combine the service node with database nodes
const initialNodes = [
serviceNode,
...databaseNodes
];
// Create edges from service node to each database with custom hierarchical connections
const initialEdges = databases
.filter(db => db && db.id)
.map((db) => ({
id: `edge-service-to-${db.id}`,
source: 'service-plan-plus',
target: db.id,
type: 'hierarchical', // Using our custom hierarchical edge
animated: true,
style: {
stroke: '#00a99d',
strokeWidth: 2,
strokeDasharray: '5, 5', // Create dotted/dashed line effect
},
// No need for handles as they're built into the edge type
}));
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
// Update nodes when databases change (e.g., after API fetch)
useEffect(() => {
console.log('Databases updated, updating nodes:', databases);
// Create a parent service node for "Plan Plus"
const serviceNode = {
id: 'service-plan-plus',
type: 'service',
data: {
id: 'service-plan-plus',
// label: 'Qubit Service: Plan+',
databases: databases.length,
expanded: true,
onToggle: (id) => {
// Toggle expansion of the service node
setNodes(nodes => nodes.map(node => {
if (node.id === id) {
return { ...node, data: { ...node.data, expanded: !node.data.expanded } };
}
return node;
}));
}
},
position: { x: 400, y: 70 } // Positioned even higher on the canvas for maximum spacing
};
// Map database nodes as children of the service node
const databaseNodes = databases
.filter(db => db && db.id)
.map((db, index) => {
const existingNode = nodes.find(node => node.id === db.id);
// If node already exists, update its data
if (existingNode) {
return {
...existingNode,
data: {
...existingNode.data,
label: db.name || `Database ${index + 1}`,
name: db.name, // Make sure name is set from the API response
schemas: db.schemas || 0,
tables: db.tables || 0,
},
// Update position with significantly increased vertical spacing
position: {
x: 250 + (index % 2) * 400, // 2 columns
y: 400 + Math.floor(index / 2) * 250 // further increased vertical spacing (400px) from service node
}
};
}
// Otherwise create a new node
return {
id: db.id,
type: 'database',
data: {
id: db.id,
label: db.name || `Database ${index + 1}`,
name: db.name, // Include the name from the API response
schemas: db.schemas || 0,
tables: db.tables || 0,
expanded: false,
onToggle: (id) => toggleDatabaseExpansion(id),
onViewDetails: handleViewDataFlow
},
// Position databases in a grid layout with significantly increased vertical spacing
position: {
x: 250 + (index % 2) * 400,
y: 400 + Math.floor(index / 2) * 250 // further increased vertical spacing (400px) from service node
},
};
});
// Create edges from service node to each database with custom hierarchical connections
const serviceEdges = databases
.filter(db => db && db.id)
.map((db) => ({
id: `edge-service-to-${db.id}`,
source: 'service-plan-plus',
target: db.id,
type: 'hierarchical', // Using our custom hierarchical edge
animated: true,
style: {
stroke: '#00a99d',
strokeWidth: 2,
strokeDasharray: '5, 5', // Create dotted/dashed line effect
},
// No need for handles as they're built into the edge type
}));
// Combine service node with database nodes
const updatedNodes = [
serviceNode,
...databaseNodes
];
// Only update if we have nodes to show
if (updatedNodes.length > 0) {
setNodes(updatedNodes);
setEdges(serviceEdges);
}
}, [databases]);
// Track viewport changes using the onMove callback instead of event listeners
const onMove = useCallback((event, viewport) => {
setScale(viewport.zoom);
setPosition({ x: viewport.x, y: viewport.y });
}, []);
const onConnect = useCallback(
(params) => {
// Create a custom edge with the selected connection type
const edgeColor = connectionType === 'reference' ? '#00a99d' :
connectionType === 'dependency' ? '#ff4d4f' : '#722ed1';
const edgeLabel = connectionType === 'reference' ? 'references' :
connectionType === 'dependency' ? 'depends on' : 'connects to';
const newEdge = {
...params,
id: `e-custom-${params.source}-${params.target}`,
type: 'custom',
animated: true,
style: {
stroke: edgeColor,
strokeWidth: 2
},
markerEnd: {
type: 'arrowclosed',
width: 20,
height: 20,
color: edgeColor,
},
data: {
label: edgeLabel
}
};
// 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})`);
},
[setEdges, connectionType, isConnectionMode]
);
const onInit = (instance) => {
setReactFlowInstance(instance);
// Auto-expand the first database and its schemas on load
setTimeout(() => {
// Find the first database in our list
if (databases.length > 0) {
const firstDb = databases[0];
if (firstDb && firstDb.id) {
console.log(`Auto-expanding first database: ${firstDb.id}`);
// Expand the first database
toggleDatabaseExpansion(firstDb.id);
// After expanding the database, also expand all its schemas
setTimeout(() => {
const schemas = mockData.schemas.filter(schema => schema && schema.dbId === firstDb.id);
schemas.forEach(schema => {
if (schema && schema.id) {
toggleSchemaExpansion(schema.id);
}
});
// Fit view after all expansions
setTimeout(() => {
try {
fitView({ padding: 0.5, maxZoom: 0.7 });
} catch (error) {
console.error('Error calling fitView:', error);
}
}, 100);
}, 100);
}
}
}, 100);
};
// Handle node click for expanding/collapsing
const onNodeClick = (event, node) => {
// Only handle expansion/collapse if not in connection mode
if (!isConnectionMode) {
if (node.type === 'database') {
toggleDatabaseExpansion(node.id);
} else if (node.type === 'schema') {
toggleSchemaExpansion(node.id);
} else if (node.type === 'table') {
showTableDetails(node.id);
}
}
};
// Handle connection creation
const onConnectStart = (event, params) => {
console.log('Connection started:', params);
setConnectionSource(params);
};
// Handle connection completion
const onConnectEnd = (event) => {
console.log('Connection ended');
};
// Toggle database expansion to show/hide schemas
const toggleDatabaseExpansion = (dbId) => {
// Safety check for undefined or invalid dbId
if (!dbId) {
console.error('Invalid database ID provided to toggleDatabaseExpansion');
return;
}
const isExpanded = expandedDatabases[dbId];
// Update the database node to show the correct toggle state
setNodes(nodes => nodes.map(node => {
if (node.id === dbId) {
return {
...node,
data: {
...node.data,
expanded: !isExpanded,
onToggle: toggleDatabaseExpansion
}
};
}
return node;
}));
if (isExpanded) {
// Collapse: remove all schemas and tables for this database
setNodes(nodes => nodes.filter(node => {
if (node.type === 'database') return true;
if (node.type === 'schema' && node.data) {
return node.data.dbId !== dbId;
}
if (node.type === 'table' && node.data && node.data.schemaId) {
const schema = mockData.schemas.find(s => s.id === node.data.schemaId);
return !schema || schema.dbId !== dbId;
}
return true;
}));
// Safely filter edges
setEdges(edges => edges.filter(edge => {
if (!edge.source || !edge.target) return true;
const sourceStr = String(edge.source);
const targetStr = String(edge.target);
return !sourceStr.startsWith(dbId) && !targetStr.startsWith(dbId);
}));
// Update expanded state
setExpandedDatabases({
...expandedDatabases,
[dbId]: false
});
// Also collapse any expanded schemas
const updatedExpandedSchemas = { ...expandedSchemas };
mockData.schemas.forEach(schema => {
if (schema && schema.dbId === dbId) {
updatedExpandedSchemas[schema.id] = false;
}
});
setExpandedSchemas(updatedExpandedSchemas);
} else {
// Expand: add schema nodes for this database
const dbNode = nodes.find(n => n.id === dbId);
// Safety check if database node exists
if (!dbNode) {
console.error(`Database node with ID ${dbId} not found`);
return;
}
const dbSchemas = mockData.schemas.filter(schema => schema && schema.dbId === dbId);
// Check if we have schemas for this database
if (dbSchemas.length === 0) {
console.warn(`No schemas found for database ${dbId}`);
}
const schemaNodes = dbSchemas.map((schema, index) => ({
id: schema.id,
type: 'schema',
data: {
id: schema.id, // Include id in data
label: schema.name || `Schema ${index + 1}`,
tables: schema.tables || 0,
dbId: dbId,
expanded: false,
onToggle: toggleSchemaExpansion
},
position: {
x: dbNode.position.x - 150 + (index * 150),
y: dbNode.position.y + 150
},
}));
const schemaEdges = dbSchemas.map(schema => ({
id: `e-${dbId}-${schema.id}`,
source: dbId,
target: schema.id,
type: 'custom',
animated: true,
style: { stroke: '#00a99d', strokeWidth: 2 },
markerEnd: {
type: 'arrowclosed',
width: 20,
height: 20,
color: '#00a99d',
},
data: {
label: 'contains'
}
}));
setNodes(nodes => [...nodes, ...schemaNodes]);
setEdges(edges => [...edges, ...schemaEdges]);
// Update expanded state
setExpandedDatabases({
...expandedDatabases,
[dbId]: true
});
}
// Safely call fitView with a delay
setTimeout(() => {
try {
fitView();
} catch (error) {
console.error('Error calling fitView:', error);
}
}, 10);
};
// Toggle schema expansion to show/hide tables
const toggleSchemaExpansion = (schemaId) => {
// Safety check for undefined or invalid schemaId
if (!schemaId) {
console.error('Invalid schema ID provided to toggleSchemaExpansion');
return;
}
const isExpanded = expandedSchemas[schemaId];
// Update the schema node to show the correct toggle state
setNodes(nodes => nodes.map(node => {
if (node.id === schemaId) {
return {
...node,
data: {
...node.data,
expanded: !isExpanded,
onToggle: toggleSchemaExpansion
}
};
}
return node;
}));
if (isExpanded) {
// Collapse: remove all tables for this schema
setNodes(nodes => nodes.filter(node => {
if (node.type !== 'table') return true;
return !node.data || node.data.schemaId !== schemaId;
}));
// Safely filter edges
setEdges(edges => edges.filter(edge => {
if (!edge.source || !edge.target) return true;
const sourceStr = String(edge.source);
const targetStr = String(edge.target);
return !sourceStr.startsWith(schemaId) && !targetStr.startsWith(schemaId);
}));
// Update expanded state
setExpandedSchemas({
...expandedSchemas,
[schemaId]: false
});
} else {
// Expand: add table nodes for this schema
const schemaNode = nodes.find(n => n.id === schemaId);
// Safety check if schema node exists
if (!schemaNode) {
console.error(`Schema node with ID ${schemaId} not found`);
return;
}
const schemaTables = mockData.tables.filter(table => table && table.schemaId === schemaId);
// Check if we have tables for this schema
if (schemaTables.length === 0) {
console.warn(`No tables found for schema ${schemaId}`);
}
const tableNodes = schemaTables.map((table, index) => ({
id: table.id,
type: 'table',
data: {
id: table.id, // Include id in data
label: table.name || `Table ${index + 1}`,
isFact: table.isFact || false,
type: table.type || (table.isFact ? 'fact' : 'dimension'),
schemaId: schemaId,
columns: table.columns || []
},
position: {
x: schemaNode.position.x - 200 + (index * 150),
y: schemaNode.position.y + 150
},
}));
const tableEdges = schemaTables.map(table => ({
id: `e-${schemaId}-${table.id}`,
source: schemaId,
target: table.id,
type: 'custom',
animated: true,
style: {
stroke: table.isFact ? '#fa8c16' : '#52c41a',
strokeWidth: 2
},
markerEnd: {
type: 'arrowclosed',
width: 20,
height: 20,
color: table.isFact ? '#fa8c16' : '#52c41a',
},
data: {
label: table.isFact ? 'fact table' : 'dimension'
}
}));
setNodes(nodes => [...nodes, ...tableNodes]);
setEdges(edges => [...edges, ...tableEdges]);
// Update expanded state
setExpandedSchemas({
...expandedSchemas,
[schemaId]: true
});
}
setTimeout(() => {
fitView();
}, 10);
};
// Show table details when a table is clicked
const showTableDetails = (tableId) => {
// Look for the table in both the state tables and mock data
const table = tables.find(t => t.id === tableId) || mockData.tables.find(t => t.id === tableId);
if (!table) return;
setSelectedTable(table);
// Format the table type
const tableType = table.type || (table.isFact ? 'fact' : 'dimension');
// Format the columns list
const columnsText = table.columns && table.columns.length > 0
? `Columns:\n${table.columns.join('\n')}`
: 'No columns defined';
// In a real application, you would show a modal with detailed information
alert(`Table Details: ${table.name}\nType: ${tableType.toUpperCase()}\n\n${columnsText}\n\nIn a real application, this would show a modal with columns, relationships, and metrics.`);
};
// Delete a custom connection
const deleteConnection = (edgeId) => {
setEdges(edges => edges.filter(edge => edge.id !== edgeId));
};
// Get node label by ID
const getNodeLabel = (nodeId) => {
const node = nodes.find(n => n.id === nodeId);
return node ? node.data.label : nodeId;
};
// Reset the canvas to show only databases
const resetView = () => {
setNodes(initialNodes);
setEdges(initialEdges);
setExpandedDatabases({});
setExpandedSchemas({});
setSelectedTable(null);
setIsConnectionMode(false);
setConnectionSource(null);
setTimeout(() => {
fitView();
}, 10);
};
return (
<div style={{ width: '100%', height: '80vh', background: '#121212' }} ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onInit={onInit}
onNodeClick={onNodeClick}
onMove={onMove}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
minZoom={0.05} // Allow zooming out much further
maxZoom={8} // Allow zooming in closer
defaultViewport={{ x: 0, y: 0, zoom: 0.7 }}
fitView
fitViewOptions={{ padding: 0.5, maxZoom: 0.7 }}
style={{ background: '#121212' }}
className={isConnectionMode ? 'connection-mode' : ''}
connectionMode="loose"
connectionLineStyle={{
stroke: connectionType === 'reference' ? '#00a99d' :
connectionType === 'dependency' ? '#ff4d4f' : '#52c41a',
strokeWidth: 3,
strokeDasharray: '5,5'
}}
connectionLineType="bezier"
proOptions={{ hideAttribution: true }}
defaultMarkerColor="#00a99d"
>
<Controls
showInteractive={true}
position="bottom-right"
style={{
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
boxShadow: '0 0 10px rgba(0,0,0,0.5)'
}}
/>
<MiniMap
nodeStrokeWidth={3}
zoomable
pannable
style={{
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px'
}}
maskColor="rgba(18, 18, 18, 0.7)"
/>
<Background
variant="dots"
gap={12}
size={1}
color="#333"
/>
{/* Add Button */}
<Panel position="top-right">
<button
onClick={() => setShowAddEntityModal(true)}
style={{
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer'
}}
title="Add DataSource"
>
<svg width="99" height="36" viewBox="0 0 99 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="98" height="35" rx="7.5" fill="#3EA29A"/>
<rect x="0.5" y="0.5" width="98" height="35" rx="7.5" stroke="#27B5AA"/>
<path fillRule="evenodd" clipRule="evenodd" d="M17.3448 10.0172C20.4387 9.67462 23.561 9.67462 26.6548 10.0172C28.3678 10.2092 29.7498 11.5582 29.9508 13.2772C30.3175 16.4152 30.3175 19.5852 29.9508 22.7232C29.7498 24.4422 28.3678 25.7912 26.6548 25.9832C23.561 26.3257 20.4387 26.3257 17.3448 25.9832C15.6318 25.7912 14.2498 24.4422 14.0488 22.7232C13.6823 19.5855 13.6823 16.4158 14.0488 13.2782C14.1505 12.443 14.5312 11.6667 15.1293 11.075C15.7273 10.4833 16.5077 10.1109 17.3438 10.0182M21.9998 13.0072C22.1987 13.0072 22.3895 13.0862 22.5302 13.2268C22.6708 13.3675 22.7498 13.5583 22.7498 13.7572V17.2502H26.2428C26.4417 17.2502 26.6325 17.3292 26.7732 17.4698C26.9138 17.6105 26.9928 17.8013 26.9928 18.0002C26.9928 18.1991 26.9138 18.3898 26.7732 18.5305C26.6325 18.6712 26.4417 18.7502 26.2428 18.7502H22.7498V22.2432C22.7498 22.4421 22.6708 22.6328 22.5302 22.7735C22.3895 22.9142 22.1987 22.9932 21.9998 22.9932C21.8009 22.9932 21.6102 22.9142 21.4695 22.7735C21.3289 22.6328 21.2498 22.4421 21.2498 22.2432V18.7502H17.7568C17.5579 18.7502 17.3672 18.6712 17.2265 18.5305C17.0859 18.3898 17.0068 18.1991 17.0068 18.0002C17.0068 17.8013 17.0859 17.6105 17.2265 17.4698C17.3672 17.3292 17.5579 17.2502 17.7568 17.2502H21.2498V13.7572C21.2498 13.5583 21.3289 13.3675 21.4695 13.2268C21.6102 13.0862 21.8009 13.0072 21.9998 13.0072Z" fill="white"/>
<path d="M38.9787 23H37.348L41.0121 12.8182H42.7869L46.451 23H44.8203L41.9418 14.6676H41.8622L38.9787 23ZM39.2521 19.0128H44.5419V20.3054H39.2521V19.0128ZM50.5376 23.1491C49.9212 23.1491 49.371 22.9917 48.8871 22.6768C48.4065 22.3587 48.0286 21.9062 47.7536 21.3196C47.4818 20.7296 47.3459 20.022 47.3459 19.1967C47.3459 18.3714 47.4834 17.6655 47.7585 17.0788C48.0369 16.4922 48.4181 16.0431 48.902 15.7315C49.3859 15.42 49.9344 15.2642 50.5476 15.2642C51.0215 15.2642 51.4027 15.3438 51.6911 15.5028C51.9827 15.6586 52.2081 15.8409 52.3672 16.0497C52.5296 16.2585 52.6555 16.4425 52.745 16.6016H52.8345V12.8182H54.321V23H52.8693V21.8118H52.745C52.6555 21.9742 52.5263 22.1598 52.3572 22.3686C52.1915 22.5774 51.9628 22.7597 51.6712 22.9155C51.3795 23.0713 51.0017 23.1491 50.5376 23.1491ZM50.8658 21.8814C51.2933 21.8814 51.6546 21.7687 51.9496 21.5433C52.2479 21.3146 52.4732 20.9981 52.6257 20.5938C52.7815 20.1894 52.8594 19.7187 52.8594 19.1818C52.8594 18.6515 52.7831 18.1875 52.6307 17.7898C52.4782 17.392 52.2545 17.0821 51.9595 16.8601C51.6645 16.638 51.3 16.527 50.8658 16.527C50.4183 16.527 50.0455 16.643 49.7472 16.875C49.4489 17.107 49.2235 17.4235 49.071 17.8246C48.9219 18.2256 48.8473 18.678 48.8473 19.1818C48.8473 19.6922 48.9235 20.1513 49.076 20.5589C49.2285 20.9666 49.4538 21.2898 49.7521 21.5284C50.0537 21.7637 50.425 21.8814 50.8658 21.8814ZM59.3013 23.1491C58.6848 23.1491 58.1346 22.9917 57.6507 22.6768C57.1702 22.3587 56.7923 21.9062 56.5172 21.3196C56.2454 20.7296 56.1096 20.022 56.1096 19.1967C56.1096 18.3714 56.2471 17.6655 56.5222 17.0788C56.8006 16.4922 57.1818 16.0431 57.6657 15.7315C58.1496 15.42 58.6981 15.2642 59.3113 15.2642C59.7852 15.2642 60.1664 15.3438 60.4547 15.5028C60.7464 15.6586 60.9718 15.8409 61.1309 16.0497C61.2933 16.2585 61.4192 16.4425 61.5087 16.6016H61.5982V12.8182H63.0847V23H61.633V21.8118H61.5087C61.4192 21.9742 61.29 22.1598 61.1209 22.3686C60.9552 22.5774 60.7265 22.7597 60.4348 22.9155C60.1432 23.0713 59.7653 23.1491 59.3013 23.1491ZM59.6294 21.8814C60.057 21.8814 60.4183 21.7687 60.7132 21.5433C61.0115 21.3146 61.2369 20.9981 61.3894 20.5938C61.5452 20.1894 61.623 19.7187 61.623 19.1818C61.623 18.6515 61.5468 18.1875 61.3944 17.7898C61.2419 17.392 61.0182 17.0821 60.7232 16.8601C60.4282 16.638 60.0636 16.527 59.6294 16.527C59.182 16.527 58.8091 16.643 58.5108 16.875C58.2125 17.107 57.9872 17.4235 57.8347 17.8246C57.6855 18.2256 57.611 18.678 57.611 19.1818C57.611 19.6922 57.6872 20.1513 57.8397 20.5589C57.9921 20.9666 58.2175 21.2898 58.5158 21.5284C58.8174 21.7637 59.1886 21.8814 59.6294 21.8814Z" fill="white"/>
<path d="M69.3439 17.9091C69.3439 16.6629 69.508 15.5161 69.8361 14.4688C70.1642 13.4214 70.6432 12.4553 71.2729 11.5703H72.6351C72.3899 11.8984 72.1612 12.3011 71.949 12.7784C71.7369 13.2557 71.5513 13.7794 71.3922 14.3494C71.2331 14.9162 71.1088 15.5045 71.0194 16.1143C70.9299 16.7209 70.8851 17.3191 70.8851 17.9091C70.8851 18.6979 70.963 19.4967 71.1188 20.3054C71.2746 21.1141 71.485 21.8648 71.7502 22.5575C72.0153 23.2502 72.3103 23.8153 72.6351 24.2528H71.2729C70.6432 23.3679 70.1642 22.4018 69.8361 21.3544C69.508 20.3071 69.3439 19.1586 69.3439 17.9091ZM75.1818 23H73.5511L77.2152 12.8182H78.9901L82.6541 23H81.0234L78.1449 14.6676H78.0653L75.1818 23ZM75.4553 19.0128H80.745V20.3054H75.4553V19.0128ZM86.8638 17.9091C86.8638 19.1586 86.6998 20.3071 86.3716 21.3544C86.0435 22.4018 85.5646 23.3679 84.9348 24.2528H83.5726C83.8179 23.9247 84.0466 23.522 84.2587 23.0447C84.4708 22.5675 84.6564 22.0455 84.8155 21.4787C84.9746 20.9086 85.0989 20.3187 85.1884 19.7088C85.2779 19.099 85.3226 18.4991 85.3226 17.9091C85.3226 17.1236 85.2447 16.3265 85.089 15.5178C84.9332 14.709 84.7227 13.9583 84.4576 13.2656C84.1924 12.5729 83.8974 12.0078 83.5726 11.5703H84.9348C85.5646 12.4553 86.0435 13.4214 86.3716 14.4688C86.6998 15.5161 86.8638 16.6629 86.8638 17.9091Z" fill="white" fillOpacity="0.5"/>
</svg>
</button>
</Panel>
{/* Minimal control panel */}
<Panel position="top-left">
<div style={{
background: 'rgba(26, 26, 26, 0.8)',
padding: '8px',
borderRadius: '5px',
boxShadow: '0 0 10px rgba(0,0,0,0.3)',
border: '1px solid #333',
color: '#ffffff',
backdropFilter: 'blur(5px)',
maxWidth: '200px'
}}>
<div style={{ display: 'flex', gap: '5px', marginBottom: '5px' }}>
<button
onClick={resetView}
style={{
padding: '5px 8px',
background: 'linear-gradient(45deg, #00a99d, #1890ff)',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '12px',
flex: 1,
boxShadow: '0 0 5px rgba(0, 169, 157, 0.3)'
}}
className="glow-effect"
>
Reset View
</button>
<button
onClick={() => {
if (reactFlowInstance) {
const viewport = reactFlowInstance.getViewport();
reactFlowInstance.setViewport({
x: viewport.x,
y: viewport.y,
zoom: Math.max(0.05, viewport.zoom * 0.5)
});
}
}}
style={{
padding: '5px 8px',
background: 'linear-gradient(45deg, #52c41a, #00a99d)',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '12px',
boxShadow: '0 0 5px rgba(82, 196, 26, 0.3)'
}}
className="glow-effect"
>
Zoom Out
</button>
</div>
<div style={{
fontSize: '11px',
color: '#aaa',
marginTop: '5px'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>Zoom: {Math.round(scale * 70)}%</span>
<span>Click nodes to expand</span>
</div>
</div>
</div>
</Panel>
{/* Dbtez Control Panel */}
<Panel position="bottom-right">
<div style={{
background: 'rgba(26, 26, 26, 0.8)',
padding: '8px',
borderRadius: '5px',
boxShadow: '0 0 10px rgba(0,0,0,0.3)',
border: '1px solid #00a99d',
color: '#ffffff',
backdropFilter: 'blur(5px)',
display: 'flex',
flexDirection: 'column',
gap: '5px',
marginBottom: '60px',
marginRight: '10px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '5px'
}}>
<span style={{
display: 'flex',
alignItems: 'center',
fontSize: '12px',
color: '#00a99d',
fontWeight: 'bold'
}}>
<span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '18px',
height: '18px',
background: 'linear-gradient(45deg, #00a99d, #52c41a)',
borderRadius: '4px',
color: 'white',
marginRight: '5px',
fontSize: '10px'
}}>
<FaDatabase />
</span>
Dbtez Data Warehouse
</span>
</div>
<div style={{ display: 'flex', gap: '5px' }}>
<button
onClick={() => toggleDatabaseExpansion('db4')}
style={{
padding: '4px 0',
background: 'linear-gradient(45deg, #00a99d, #52c41a)',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold',
width: '30px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 5px rgba(0, 169, 157, 0.3)'
}}
className="glow-effect"
title="Expand/Collapse Database"
>
{expandedDatabases['db4'] ? '' : '+'}
</button>
<button
onClick={() => {
if (!expandedDatabases['db4']) {
toggleDatabaseExpansion('db4');
setTimeout(() => {
toggleSchemaExpansion('schema10');
}, 100);
} else if (!expandedSchemas['schema10']) {
toggleSchemaExpansion('schema10');
} else {
// If both are expanded, collapse schema
toggleSchemaExpansion('schema10');
}
}}
style={{
padding: '4px 0',
background: 'linear-gradient(45deg, #fa8c16, #faad14)',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold',
width: '30px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 5px rgba(250, 140, 22, 0.3)'
}}
className="glow-effect"
title="Expand/Collapse Sales Schema"
>
{expandedSchemas['schema10'] ? '' : '+'}
</button>
</div>
</div>
</Panel>
{/* Connection Mode Indicator */}
{isConnectionMode && (
<Panel position="top-center">
<div style={{
background: '#fffbe6',
padding: '8px 15px',
borderRadius: '20px',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span style={{
width: '10px',
height: '10px',
borderRadius: '50%',
background: connectionType === 'reference' ? '#1890ff' :
connectionType === 'dependency' ? '#ff4d4f' : '#722ed1',
display: 'inline-block',
animation: 'pulse 1.5s infinite'
}}></span>
<span>
<strong>Connection Mode Active</strong> -
Drag from a connection point (dot) to another node
</span>
</div>
</Panel>
)}
{/* Connections Panel */}
<Panel position="bottom-left">
{edges.some(edge => edge.id.startsWith('e-custom')) && (
<div style={{
background: 'white',
padding: '10px',
borderRadius: '5px',
boxShadow: '0 0 10px rgba(0,0,0,0.1)',
maxWidth: '300px',
maxHeight: '200px',
overflowY: 'auto'
}}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px' }}>Custom Connections</h4>
<ul style={{
listStyle: 'none',
padding: 0,
margin: 0,
fontSize: '12px'
}}>
{edges
.filter(edge => edge.id.startsWith('e-custom'))
.map(edge => (
<li key={edge.id} style={{
padding: '5px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<span style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
background: edge.style?.stroke || '#722ed1',
marginRight: '5px'
}}></span>
<strong>{getNodeLabel(edge.source)}</strong>
<span style={{ margin: '0 5px' }}></span>
<strong>{getNodeLabel(edge.target)}</strong>
<div style={{
fontSize: '10px',
color: '#666',
marginTop: '2px',
marginLeft: '13px'
}}>
{edge.label || 'Connected to'}
</div>
</div>
<button
onClick={() => deleteConnection(edge.id)}
style={{
background: 'transparent',
border: 'none',
color: '#ff4d4f',
cursor: 'pointer',
fontSize: '12px',
padding: '2px 5px'
}}
>
</button>
</li>
))}
</ul>
</div>
)}
</Panel>
</ReactFlow>
{/* Entity Creation Modal */}
<Modal
isOpen={showAddEntityModal}
title="Add DataSource"
onClose={() => {
setShowAddEntityModal(false);
setActiveEntityType('database');
setSelectedDatabaseForSchema(null);
setSelectedSchemaForTable(null);
}}
>
<EntitySelector
activeEntityType={activeEntityType}
setActiveEntityType={setActiveEntityType}
databases={databases}
schemas={schemas}
selectedDatabaseForSchema={selectedDatabaseForSchema}
setSelectedDatabaseForSchema={setSelectedDatabaseForSchema}
selectedSchemaForTable={selectedSchemaForTable}
setSelectedSchemaForTable={setSelectedSchemaForTable}
onSaveDatabase={handleCreateDatabase}
onSaveSchema={handleCreateSchema}
onSaveTable={handleCreateTable}
onCancel={() => {
setShowAddEntityModal(false);
setActiveEntityType('database');
setSelectedDatabaseForSchema(null);
setSelectedSchemaForTable(null);
}}
/>
</Modal>
</div>
);
};
export default function InfiniteCanvasWrapper() {
return (
<div style={{ width: '100%', height: '100%', background: '#121212' }}>
<ReactFlowProvider>
<InfiniteCanvas />
</ReactFlowProvider>
</div>
);
}