286 lines
8.4 KiB
React
286 lines
8.4 KiB
React
import { Background, Controls, MiniMap, ReactFlow, useEdgesState, useNodesState, Position, Handle, ReactFlowProvider } from '@xyflow/react';
|
|
import React, { useCallback, memo, useMemo, useRef, useState, useEffect } from 'react';
|
|
import { Play } from 'lucide-react';
|
|
|
|
// Optimized node components with proper prop types and memoization
|
|
const StartNode = memo(({ data, isConnectable }) => {
|
|
// Memoize handles configuration
|
|
const handles = useMemo(() => ([
|
|
{ id: 'start-right', type: 'source', position: Position.Right }
|
|
]), []);
|
|
|
|
return (
|
|
<div className="bg-green-50 rounded-lg border-2 border-green-500 shadow-lg p-4 w-[100px]">
|
|
{handles.map(handle => (
|
|
<Handle
|
|
key={handle.id}
|
|
type={handle.type}
|
|
position={handle.position}
|
|
isConnectable={isConnectable}
|
|
className="w-3 h-3 bg-green-500"
|
|
/>
|
|
))}
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Play className="h-4 w-4 text-green-600" />
|
|
<span className="font-semibold text-sm text-green-700">Start</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
const ApiNode = memo(({ data, isConnectable }) => {
|
|
// Memoize handles and colors
|
|
const handles = useMemo(() => ([
|
|
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
|
|
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
|
|
]), [data.id]);
|
|
|
|
const methodColor = useMemo(() => {
|
|
const colors = {
|
|
GET: 'bg-green-500',
|
|
POST: 'bg-blue-500',
|
|
PUT: 'bg-yellow-500',
|
|
DELETE: 'bg-red-500',
|
|
default: 'bg-gray-500'
|
|
};
|
|
return colors[data.method] || colors.default;
|
|
}, [data.method]);
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg border shadow-lg p-4 w-[200px]">
|
|
{handles.map(handle => (
|
|
<Handle
|
|
key={handle.id}
|
|
type={handle.type}
|
|
position={handle.position}
|
|
isConnectable={isConnectable}
|
|
className="w-3 h-3 bg-gray-500"
|
|
/>
|
|
))}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${methodColor}`} />
|
|
<span className="font-bold text-sm">{data.method}</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-gray-500">{data.name}</div>
|
|
<div className="text-sm truncate text-gray-600 mb-2">
|
|
{data.path}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
const ConditionNode = memo(({ data, isConnectable }) => {
|
|
// Memoize handle styles
|
|
const handleStyles = useMemo(() => ({
|
|
true: {
|
|
top: '30%',
|
|
backgroundColor: '#22c55e',
|
|
border: '2px solid #16a34a'
|
|
},
|
|
false: {
|
|
top: '70%',
|
|
backgroundColor: '#ef4444',
|
|
border: '2px solid #dc2626'
|
|
}
|
|
}), []);
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg border shadow-lg p-4 w-[200px]">
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="in"
|
|
isConnectable={isConnectable}
|
|
className="w-3 h-3"
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="true"
|
|
style={handleStyles.true}
|
|
isConnectable={isConnectable}
|
|
className="w-3 h-3 !opacity-100"
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="false"
|
|
style={handleStyles.false}
|
|
isConnectable={isConnectable}
|
|
className="w-3 h-3 !opacity-100"
|
|
/>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-purple-500" />
|
|
<span className="font-bold text-sm">Condition</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-sm text-gray-600">
|
|
{data.condition || 'Add condition'}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
const DelayNode = memo(({ data, isConnectable }) => {
|
|
const handles = useMemo(() => ([
|
|
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
|
|
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
|
|
]), [data.id]);
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg border shadow-lg p-4 w-[200px]">
|
|
{handles.map(handle => (
|
|
<Handle
|
|
key={handle.id}
|
|
type={handle.type}
|
|
position={handle.position}
|
|
isConnectable={isConnectable}
|
|
className="w-3 h-3 bg-gray-500"
|
|
/>
|
|
))}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-orange-500" />
|
|
<span className="font-bold text-sm">Delay</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-sm text-gray-600">
|
|
Delay: {data.duration || '1000'}ms
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// Add display names for debugging
|
|
StartNode.displayName = 'StartNode';
|
|
ApiNode.displayName = 'ApiNode';
|
|
ConditionNode.displayName = 'ConditionNode';
|
|
DelayNode.displayName = 'DelayNode';
|
|
|
|
// Memoize initial nodes and edges
|
|
const initialNodes = [
|
|
{
|
|
id: 'start',
|
|
type: 'startNode',
|
|
position: { x: 100, y: 50 },
|
|
data: { id: 'start' }
|
|
}
|
|
];
|
|
|
|
const initialEdges = [];
|
|
|
|
// Memoize node types
|
|
const nodeTypes = {
|
|
startNode: StartNode,
|
|
apiNode: ApiNode,
|
|
conditionNode: ConditionNode,
|
|
delayNode: DelayNode
|
|
};
|
|
|
|
const DiagramArea = () => {
|
|
const reactFlowWrapper = useRef(null);
|
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
|
|
|
// Click handlers for node selection
|
|
const onNodeClick = useCallback((event, node) => {
|
|
event.stopPropagation();
|
|
setSelectedNodeId(node.id);
|
|
}, []);
|
|
|
|
const onPaneClick = useCallback(() => {
|
|
setSelectedNodeId(null);
|
|
}, []);
|
|
|
|
// Apply styles to nodes
|
|
useEffect(() => {
|
|
setNodes((nds) =>
|
|
nds.map((node) => ({
|
|
...node,
|
|
className: node.id === selectedNodeId ? 'border-2 border-primary shadow-md bg-primary/5' : null
|
|
}))
|
|
);
|
|
}, [selectedNodeId, setNodes]);
|
|
|
|
// Memoize callback functions
|
|
const onConnect = useCallback((params) => {
|
|
setEdges(prev => [...prev, { ...params, id: `e${prev.length + 1}` }]);
|
|
}, []);
|
|
|
|
const onInit = useCallback((instance) => {
|
|
reactFlowWrapper.current = instance;
|
|
}, []);
|
|
|
|
const onDragOver = useCallback((event) => {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
}, []);
|
|
|
|
const onDrop = useCallback((event) => {
|
|
event.preventDefault();
|
|
|
|
if (!reactFlowWrapper.current) return;
|
|
|
|
try {
|
|
const apiData = JSON.parse(event.dataTransfer.getData('application/json'));
|
|
const position = reactFlowWrapper.current.screenToFlowPosition({
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
});
|
|
|
|
const newNode = {
|
|
id: `api-${Date.now()}`,
|
|
type: 'apiNode',
|
|
position,
|
|
data: {
|
|
id: apiData.id,
|
|
name: apiData.name,
|
|
method: apiData.method,
|
|
path: apiData.path,
|
|
collectionId: apiData.collectionId
|
|
}
|
|
};
|
|
|
|
setNodes(prev => prev.concat(newNode));
|
|
} catch (error) {
|
|
console.error('Error adding new node:', error);
|
|
}
|
|
}, [setNodes]);
|
|
|
|
// Default edge options memoized
|
|
const defaultEdgeOptions = useMemo(() => ({ type: 'smoothstep' }), []);
|
|
|
|
return (
|
|
<div className="h-[50vh] border-b">
|
|
<ReactFlowProvider>
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
onInit={onInit}
|
|
defaultEdgeOptions={defaultEdgeOptions}
|
|
onDrop={onDrop}
|
|
onDragOver={onDragOver}
|
|
onNodeClick={onNodeClick}
|
|
onPaneClick={onPaneClick}
|
|
nodeTypes={nodeTypes}
|
|
defaultZoom={1.0} // 초기 배율 설정 (1이 기본값)
|
|
proOptions={{ hideAttribution: true }}
|
|
>
|
|
<Background />
|
|
<Controls />
|
|
<MiniMap />
|
|
</ReactFlow>
|
|
</ReactFlowProvider>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default memo(DiagramArea);
|