336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
import { useEffect, useState, useCallback } from 'react';
|
||
import ReactFlow, {
|
||
addEdge,
|
||
Background,
|
||
Controls,
|
||
useNodesState,
|
||
useEdgesState,
|
||
} from 'reactflow';
|
||
import 'reactflow/dist/style.css';
|
||
import {
|
||
fetchDiagramByName,
|
||
saveDiagramByName,
|
||
listDiagrams,
|
||
deleteDiagramByName,
|
||
} from '../services/api';
|
||
import CustomNode from './CustomNode';
|
||
import IconSelector from './IconSelector';
|
||
import { toast } from 'react-toastify';
|
||
import RouterNode from './RouterNode';
|
||
|
||
const nodeTypes = {
|
||
custom: CustomNode,
|
||
router: RouterNode,
|
||
};
|
||
|
||
function Diagram() {
|
||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||
const [selectedNode, setSelectedNode] = useState(null);
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [newLabel, setNewLabel] = useState('');
|
||
const [newType, setNewType] = useState('custom');
|
||
const [selectedIcon, setSelectedIcon] = useState('');
|
||
const [selectedEdge, setSelectedEdge] = useState(null);
|
||
const [diagramName, setDiagramName] = useState(null);
|
||
const [diagramList, setDiagramList] = useState([]);
|
||
const isIframe = window.self !== window.top;
|
||
|
||
useEffect(() => {
|
||
let isMounted = true;
|
||
|
||
const load = async () => {
|
||
try {
|
||
const { diagrams } = await listDiagrams();
|
||
if (isMounted) {
|
||
setDiagramList(diagrams);
|
||
|
||
if (diagrams.length > 0) {
|
||
setDiagramName((prev) => prev || diagrams[0]);
|
||
} else {
|
||
setDiagramName(null);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
setDiagramList([]);
|
||
setDiagramName(null);
|
||
}
|
||
};
|
||
|
||
load();
|
||
return () => {
|
||
isMounted = false;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let isMounted = true;
|
||
|
||
const loadDiagram = async () => {
|
||
if (!diagramName) {
|
||
setNodes([]);
|
||
setEdges([]);
|
||
return;
|
||
}
|
||
try {
|
||
const data = await fetchDiagramByName(diagramName);
|
||
if (isMounted) {
|
||
setNodes(data.nodes || []);
|
||
const sanitizedEdges = (data.edges || []).map(({ id, source, target, animated }) => ({
|
||
id,
|
||
source,
|
||
target,
|
||
animated: !!animated,
|
||
}));
|
||
setEdges(sanitizedEdges);
|
||
setSelectedNode(null);
|
||
setSelectedEdge(null);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
toast.error(`❌ Failed to load diagram "${diagramName}"`);
|
||
setNodes([]);
|
||
setEdges([]);
|
||
}
|
||
};
|
||
|
||
loadDiagram();
|
||
return () => {
|
||
isMounted = false;
|
||
};
|
||
}, [diagramName, setNodes, setEdges]);
|
||
|
||
const handleSave = async () => {
|
||
if (!diagramName) return;
|
||
const cleanedEdges = edges.map(({ id, source, target, animated }) => ({
|
||
id,
|
||
source,
|
||
target,
|
||
animated: !!animated,
|
||
}));
|
||
await saveDiagramByName(diagramName, { nodes, edges: cleanedEdges });
|
||
toast.success(`✅ Diagram "${diagramName}" saved!`);
|
||
};
|
||
|
||
const handleAddNode = () => {
|
||
setShowForm(true);
|
||
};
|
||
|
||
const handleSubmitNode = () => {
|
||
const id = (nodes.length + 1).toString();
|
||
const label = newLabel || `Node ${id}`;
|
||
const icon = selectedIcon || 'https://s3.dvirlabs.com/lab-icons/default.svg';
|
||
|
||
const newNode = {
|
||
id,
|
||
type: newType,
|
||
data: newType === 'custom'
|
||
? { label, icon }
|
||
: { label }, // router and other types don't have icons
|
||
position: { x: Math.random() * 400, y: Math.random() * 300 },
|
||
};
|
||
|
||
setNodes((nds) => [...nds, newNode]);
|
||
toast.success(`🟢 Node "${label}" added`);
|
||
setShowForm(false);
|
||
setNewLabel('');
|
||
setSelectedIcon('');
|
||
setNewType('custom');
|
||
};
|
||
|
||
const handleDeleteNode = () => {
|
||
if (!selectedNode) return;
|
||
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
|
||
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id));
|
||
toast.error(`🗑️ Node "${selectedNode.data.label}" deleted`);
|
||
setSelectedNode(null);
|
||
};
|
||
|
||
const onEdgeClick = (_, edge) => {
|
||
setSelectedEdge(edge);
|
||
setSelectedNode(null);
|
||
};
|
||
|
||
const handleDeleteEdge = () => {
|
||
if (!selectedEdge) return;
|
||
setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id));
|
||
toast.error(`🗑️ Edge "${selectedEdge.id}" deleted`);
|
||
setSelectedEdge(null);
|
||
};
|
||
|
||
const onConnect = useCallback(
|
||
(params) => setEdges((eds) => addEdge({ ...params, animated: true }, eds)),
|
||
[setEdges]
|
||
);
|
||
|
||
const handleNodeDoubleClick = (event, node) => {
|
||
const newLabel = prompt('Enter new name:', node.data.label);
|
||
if (newLabel !== null) {
|
||
setNodes((nds) =>
|
||
nds.map((n) =>
|
||
n.id === node.id ? { ...n, data: { ...n.data, label: newLabel } } : n
|
||
)
|
||
);
|
||
toast.info(`✏️ Node renamed to "${newLabel}"`);
|
||
}
|
||
};
|
||
|
||
const onNodeClick = (_, node) => {
|
||
setSelectedNode(node);
|
||
};
|
||
|
||
const handleDeleteDiagram = async () => {
|
||
if (!diagramName || diagramName === 'default') {
|
||
toast.warn("❌ Cannot delete 'default' diagram or nothing selected.");
|
||
return;
|
||
}
|
||
|
||
const confirmDelete = confirm(`Are you sure you want to delete diagram "${diagramName}"?`);
|
||
if (!confirmDelete) return;
|
||
|
||
try {
|
||
await deleteDiagramByName(diagramName);
|
||
const updatedList = diagramList.filter(name => name !== diagramName);
|
||
setDiagramList(updatedList);
|
||
const newSelected = updatedList[0] || null;
|
||
setDiagramName(newSelected);
|
||
setNodes([]);
|
||
setEdges([]);
|
||
toast.success(`🗑️ Diagram "${diagramName}" deleted`);
|
||
} catch (err) {
|
||
toast.error(`❌ Failed to delete diagram "${diagramName}"`);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={{ width: '100vw', height: '100vh' }}>
|
||
<div style={{ position: 'absolute', zIndex: 10, right: 10, top: 10, display: 'flex', alignItems: 'center', gap: '10px', height: '48px' }}>
|
||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||
<select
|
||
value={diagramName || ''}
|
||
onChange={(e) => setDiagramName(e.target.value)}
|
||
style={{
|
||
padding: '10px 16px',
|
||
height: '48px',
|
||
fontSize: '16px',
|
||
fontWeight: 'bold',
|
||
borderRadius: '12px',
|
||
border: 'none',
|
||
background: '#111',
|
||
color: 'white',
|
||
cursor: 'pointer',
|
||
appearance: 'none',
|
||
width: 180,
|
||
paddingRight: 32,
|
||
}}
|
||
>
|
||
{diagramList.map((name) => (
|
||
<option key={name} value={name} style={{ color: 'white', font: 'bold 16px Arial' }}>
|
||
📌 {name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
right: 12,
|
||
top: '50%',
|
||
transform: 'translateY(-50%)',
|
||
pointerEvents: 'none',
|
||
fontSize: 16,
|
||
color: 'white',
|
||
}}
|
||
>
|
||
⏷
|
||
</div>
|
||
</div>
|
||
|
||
{!isIframe && (
|
||
<>
|
||
<button className="btn" style={{ background: '#d28519' }} onClick={() => {
|
||
const newName = prompt("Enter new diagram name:");
|
||
if (newName) {
|
||
if (!diagramList.includes(newName)) {
|
||
const updatedList = [...diagramList, newName];
|
||
setDiagramList(updatedList);
|
||
}
|
||
setDiagramName(newName);
|
||
setNodes([]);
|
||
setEdges([]);
|
||
setSelectedNode(null);
|
||
setSelectedEdge(null);
|
||
}
|
||
}}>🆕 New Diagram</button>
|
||
|
||
<button className="btn" onClick={handleSave} style={{ background: 'green' }}>💾 Save</button>
|
||
<button className="btn" onClick={handleAddNode} style={{ background: 'blue' }}>➕ Add Node</button>
|
||
|
||
<button className="btn" onClick={handleDeleteNode} style={{ background: selectedNode ? '#b81a1a' : '#ccc', color: selectedNode ? 'white' : '#666', cursor: selectedNode ? 'pointer' : 'not-allowed' }} disabled={!selectedNode}>🗑️ Delete Node</button>
|
||
|
||
<button className="btn" onClick={handleDeleteEdge} style={{ background: selectedEdge ? '#b81a1a' : '#ccc', color: selectedEdge ? 'white' : '#666', cursor: selectedEdge ? 'pointer' : 'not-allowed' }} disabled={!selectedEdge}>🗑️ Delete Edge</button>
|
||
|
||
<button className="btn" onClick={handleDeleteDiagram} style={{ background: 'red' }}>🗑️ Delete Diagram</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{showForm && (
|
||
<div style={{ position: 'absolute', zIndex: 20, right: 10, top: 80, background: '#e6f0fa', padding: '16px', borderRadius: '12px', boxShadow: '0 4px 8px rgba(0,0,0,0.15)', width: 280 }}>
|
||
<h4 style={{ color: '#125ea8', marginBottom: 12, fontSize: 18, display: 'flex', alignItems: 'center', gap: 8 }}>🧩 Add New Node</h4>
|
||
|
||
<label style={{ fontWeight: 'bold', marginBottom: 4, display: 'block', color: '#333' }}>Label:</label>
|
||
<input
|
||
type="text"
|
||
placeholder="Enter label"
|
||
value={newLabel}
|
||
onChange={(e) => setNewLabel(e.target.value)}
|
||
style={{ padding: '6px', width: '100%', borderRadius: 6, border: '1px solid #ccc', marginBottom: 12 }}
|
||
/>
|
||
|
||
<label style={{ fontWeight: 'bold', marginBottom: 4, display: 'block', color: '#333' }}>Type:</label>
|
||
<select
|
||
value={newType}
|
||
onChange={(e) => setNewType(e.target.value)}
|
||
style={{ padding: '6px', width: '100%', borderRadius: 6, border: '1px solid #ccc', marginBottom: 12 }}
|
||
>
|
||
<option value="custom">🧱 Custom</option>
|
||
<option value="router">📡 Router</option>
|
||
</select>
|
||
|
||
{newType === 'custom' && (
|
||
<IconSelector onSelect={setSelectedIcon} />
|
||
)}
|
||
|
||
<div style={{ marginTop: 12 }}>
|
||
<button className="btn" onClick={handleSubmitNode}>✅ Add</button>
|
||
<button className="btn" style={{ marginLeft: 8, background: '#ccc', color: '#333' }} onClick={() => setShowForm(false)}>❌ Cancel</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChange}
|
||
onConnect={onConnect}
|
||
onNodeClick={onNodeClick}
|
||
onNodeDoubleClick={handleNodeDoubleClick}
|
||
onEdgeClick={onEdgeClick}
|
||
onPaneClick={() => {
|
||
setSelectedNode(null);
|
||
setSelectedEdge(null);
|
||
}}
|
||
nodeTypes={nodeTypes}
|
||
fitView
|
||
>
|
||
<Background />
|
||
<Controls />
|
||
</ReactFlow>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Diagram;
|