|
|
@@ -1,5 +1,6 @@
|
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
+import dagre from 'dagre'
|
|
|
import { workflowsApi, nodesApi, Workflow, NodeDefinition, NodeOutput } from '../api/workflows'
|
|
|
import ReactFlow, {
|
|
|
Background,
|
|
|
@@ -1185,12 +1186,23 @@ function WorkflowEditorInner() {
|
|
|
setShowDeleteConfirm(null)
|
|
|
}, [setNodes, setEdges])
|
|
|
|
|
|
- // Auto-layout nodes in a vertical top-to-bottom arrangement
|
|
|
- // Special handling for loop nodes: body nodes are positioned below the loop
|
|
|
+ // Auto-layout nodes using dagre for clean hierarchical layout
|
|
|
+ // Special handling for loop nodes: body nodes positioned below their parent loop
|
|
|
const autoLayout = useCallback(() => {
|
|
|
if (nodes.length === 0) return
|
|
|
|
|
|
- // Build adjacency maps with edge info
|
|
|
+ const GRID_SIZE = 20
|
|
|
+ const NODE_WIDTH = 200
|
|
|
+ const NODE_HEIGHT = 80
|
|
|
+ const HORIZONTAL_SPACING = 80
|
|
|
+ const VERTICAL_SPACING = 100
|
|
|
+ const LOOP_BODY_OFFSET_Y = 60
|
|
|
+ const LOOP_BODY_INDENT_X = 40
|
|
|
+
|
|
|
+ // Helper to snap to grid
|
|
|
+ const snapToGrid = (value: number) => Math.round(value / GRID_SIZE) * GRID_SIZE
|
|
|
+
|
|
|
+ // Build edge maps
|
|
|
const outgoingEdges = new Map<string, Array<{ target: string; handle: string }>>()
|
|
|
const incomingEdges = new Map<string, Array<{ source: string; handle: string }>>()
|
|
|
nodes.forEach(n => {
|
|
|
@@ -1204,8 +1216,8 @@ function WorkflowEditorInner() {
|
|
|
|
|
|
// Identify loop nodes and their body nodes
|
|
|
const loopNodes = new Set<string>()
|
|
|
- const loopBodyNodes = new Map<string, Set<string>>() // loopId -> set of body node IDs
|
|
|
- const nodeToLoop = new Map<string, string>() // nodeId -> loopId (which loop this node belongs to)
|
|
|
+ const loopBodyNodes = new Map<string, Set<string>>()
|
|
|
+ const nodeToLoop = new Map<string, string>()
|
|
|
|
|
|
nodes.forEach(n => {
|
|
|
if (n.data.type === 'loop') {
|
|
|
@@ -1214,7 +1226,7 @@ function WorkflowEditorInner() {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- // Find all body nodes for each loop (nodes connected via 'loop' output)
|
|
|
+ // Find body nodes for each loop (connected via 'loop' output handle)
|
|
|
const findLoopBodyNodes = (loopId: string, nodeId: string, visited: Set<string>) => {
|
|
|
if (visited.has(nodeId)) return
|
|
|
visited.add(nodeId)
|
|
|
@@ -1225,18 +1237,14 @@ function WorkflowEditorInner() {
|
|
|
nodeToLoop.set(nodeId, loopId)
|
|
|
}
|
|
|
|
|
|
- // Follow connections from this node, but stop at 'done' outputs from the loop
|
|
|
const outEdges = outgoingEdges.get(nodeId) || []
|
|
|
for (const edge of outEdges) {
|
|
|
- // Don't follow 'done' connections from the loop itself
|
|
|
if (nodeId === loopId && edge.handle === 'done') continue
|
|
|
- // Don't enter other loops
|
|
|
if (loopNodes.has(edge.target) && edge.target !== loopId) continue
|
|
|
findLoopBodyNodes(loopId, edge.target, visited)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Populate loop body nodes
|
|
|
loopNodes.forEach(loopId => {
|
|
|
const outEdges = outgoingEdges.get(loopId) || []
|
|
|
const loopEdges = outEdges.filter(e => e.handle === 'loop')
|
|
|
@@ -1246,138 +1254,193 @@ function WorkflowEditorInner() {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- // Find root nodes (triggers or nodes with no incoming edges that aren't loop body nodes)
|
|
|
- const roots = nodes.filter(n =>
|
|
|
- (n.data.isTrigger || (incomingEdges.get(n.id)?.length === 0)) && !nodeToLoop.has(n.id)
|
|
|
- )
|
|
|
+ // Create dagre graph for main flow (excluding loop body nodes)
|
|
|
+ const g = new dagre.graphlib.Graph()
|
|
|
+ g.setGraph({
|
|
|
+ rankdir: 'TB',
|
|
|
+ nodesep: HORIZONTAL_SPACING,
|
|
|
+ ranksep: VERTICAL_SPACING,
|
|
|
+ marginx: 50,
|
|
|
+ marginy: 50,
|
|
|
+ })
|
|
|
+ g.setDefaultEdgeLabel(() => ({}))
|
|
|
|
|
|
- // BFS to assign levels, skipping loop body nodes (they get positioned separately)
|
|
|
- const levels = new Map<string, number>()
|
|
|
- const queue: { id: string; level: number }[] = roots.map(n => ({ id: n.id, level: 0 }))
|
|
|
- const visited = new Set<string>()
|
|
|
+ // Add main flow nodes (not loop body nodes)
|
|
|
+ nodes.forEach(n => {
|
|
|
+ if (!nodeToLoop.has(n.id)) {
|
|
|
+ g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
|
|
+ }
|
|
|
+ })
|
|
|
|
|
|
- while (queue.length > 0) {
|
|
|
- const { id, level } = queue.shift()!
|
|
|
- if (visited.has(id)) {
|
|
|
- if (level > (levels.get(id) || 0)) {
|
|
|
- levels.set(id, level)
|
|
|
- }
|
|
|
- continue
|
|
|
+ // Add main flow edges (skip edges to/from loop body nodes, and skip loop->body edges)
|
|
|
+ edges.forEach(e => {
|
|
|
+ const sourceInBody = nodeToLoop.has(e.source)
|
|
|
+ const targetInBody = nodeToLoop.has(e.target)
|
|
|
+ const isLoopBodyEdge = loopNodes.has(e.source) && e.sourceHandle === 'loop'
|
|
|
+
|
|
|
+ if (!sourceInBody && !targetInBody && !isLoopBodyEdge) {
|
|
|
+ g.setEdge(e.source, e.target)
|
|
|
}
|
|
|
- visited.add(id)
|
|
|
- levels.set(id, Math.max(levels.get(id) || 0, level))
|
|
|
-
|
|
|
- const outEdges = outgoingEdges.get(id) || []
|
|
|
-
|
|
|
- // For loop nodes, calculate how many levels the body takes
|
|
|
- let loopBodyLevels = 0
|
|
|
- if (loopNodes.has(id)) {
|
|
|
- const bodyNodes = loopBodyNodes.get(id)!
|
|
|
- if (bodyNodes.size > 0) {
|
|
|
- // Layout body nodes internally and find max depth
|
|
|
- loopBodyLevels = Math.max(1, Math.ceil(bodyNodes.size / 2))
|
|
|
- }
|
|
|
+ })
|
|
|
+
|
|
|
+ // Run dagre layout
|
|
|
+ dagre.layout(g)
|
|
|
+
|
|
|
+ // Calculate loop body heights to offset 'done' targets
|
|
|
+ const loopBodyHeights = new Map<string, number>()
|
|
|
+ loopNodes.forEach(loopId => {
|
|
|
+ const bodyNodes = loopBodyNodes.get(loopId)!
|
|
|
+ if (bodyNodes.size === 0) {
|
|
|
+ loopBodyHeights.set(loopId, 0)
|
|
|
+ return
|
|
|
}
|
|
|
|
|
|
- for (const edge of outEdges) {
|
|
|
- // Skip nodes that are loop body nodes (unless they're this loop's done targets)
|
|
|
- if (nodeToLoop.has(edge.target) && nodeToLoop.get(edge.target) !== id) continue
|
|
|
+ // Create a sub-graph for the loop body
|
|
|
+ const bodyGraph = new dagre.graphlib.Graph()
|
|
|
+ bodyGraph.setGraph({
|
|
|
+ rankdir: 'TB',
|
|
|
+ nodesep: HORIZONTAL_SPACING * 0.8,
|
|
|
+ ranksep: VERTICAL_SPACING * 0.8,
|
|
|
+ })
|
|
|
+ bodyGraph.setDefaultEdgeLabel(() => ({}))
|
|
|
|
|
|
- // For 'done' outputs from loops, add extra levels for the body
|
|
|
- const nextLevel = loopNodes.has(id) && edge.handle === 'done'
|
|
|
- ? level + 1 + loopBodyLevels
|
|
|
- : level + 1
|
|
|
+ bodyNodes.forEach(nodeId => {
|
|
|
+ bodyGraph.setNode(nodeId, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
|
|
+ })
|
|
|
|
|
|
- queue.push({ id: edge.target, level: nextLevel })
|
|
|
- }
|
|
|
- }
|
|
|
+ // Add internal edges within the loop body
|
|
|
+ edges.forEach(e => {
|
|
|
+ if (bodyNodes.has(e.source) && bodyNodes.has(e.target)) {
|
|
|
+ bodyGraph.setEdge(e.source, e.target)
|
|
|
+ }
|
|
|
+ // Also add edge from loop node to first body node
|
|
|
+ if (e.source === loopId && e.sourceHandle === 'loop' && bodyNodes.has(e.target)) {
|
|
|
+ bodyGraph.setNode(loopId + '_entry', { width: 0, height: 0 })
|
|
|
+ bodyGraph.setEdge(loopId + '_entry', e.target)
|
|
|
+ }
|
|
|
+ })
|
|
|
|
|
|
- // Handle unvisited non-body nodes
|
|
|
- nodes.forEach(n => {
|
|
|
- if (!visited.has(n.id) && !nodeToLoop.has(n.id)) {
|
|
|
- levels.set(n.id, 0)
|
|
|
- }
|
|
|
- })
|
|
|
+ dagre.layout(bodyGraph)
|
|
|
|
|
|
- // Group nodes by level (excluding loop body nodes)
|
|
|
- const nodesByLevel = new Map<number, Node[]>()
|
|
|
- nodes.forEach(n => {
|
|
|
- if (nodeToLoop.has(n.id)) return // Skip body nodes
|
|
|
- const level = levels.get(n.id) || 0
|
|
|
- if (!nodesByLevel.has(level)) {
|
|
|
- nodesByLevel.set(level, [])
|
|
|
- }
|
|
|
- nodesByLevel.get(level)!.push(n)
|
|
|
+ // Find the bounding box of the body
|
|
|
+ let maxY = 0
|
|
|
+ bodyNodes.forEach(nodeId => {
|
|
|
+ const nodePos = bodyGraph.node(nodeId)
|
|
|
+ if (nodePos) {
|
|
|
+ maxY = Math.max(maxY, nodePos.y + NODE_HEIGHT / 2)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ loopBodyHeights.set(loopId, maxY + LOOP_BODY_OFFSET_Y)
|
|
|
})
|
|
|
|
|
|
- const GRID_SIZE = 20
|
|
|
- const VERTICAL_SPACING = 140
|
|
|
- const HORIZONTAL_SPACING = 260 // Divisible by 20
|
|
|
- const LOOP_BODY_INDENT = 60 // Indent for loop body nodes
|
|
|
+ // Get positions from dagre and apply adjustments
|
|
|
+ const newPositions = new Map<string, { x: number; y: number }>()
|
|
|
|
|
|
- // Helper to snap to grid
|
|
|
- const snapToGrid = (value: number) => Math.round(value / GRID_SIZE) * GRID_SIZE
|
|
|
+ // Calculate vertical offsets for nodes that follow loop 'done' outputs
|
|
|
+ // We need to push them down to account for loop body height
|
|
|
+ const nodeYOffset = new Map<string, number>()
|
|
|
|
|
|
- const newPositions = new Map<string, { x: number; y: number }>()
|
|
|
- const maxLevel = Math.max(0, ...levels.values())
|
|
|
-
|
|
|
- // Position main flow nodes
|
|
|
- for (let level = 0; level <= maxLevel; level++) {
|
|
|
- const nodesAtLevel = nodesByLevel.get(level) || []
|
|
|
- const levelWidth = nodesAtLevel.length * HORIZONTAL_SPACING
|
|
|
- const startX = snapToGrid((400 - levelWidth / 2) + HORIZONTAL_SPACING / 2)
|
|
|
-
|
|
|
- nodesAtLevel.forEach((node, idx) => {
|
|
|
- newPositions.set(node.id, {
|
|
|
- x: snapToGrid(startX + idx * HORIZONTAL_SPACING),
|
|
|
- y: snapToGrid(100 + level * VERTICAL_SPACING),
|
|
|
- })
|
|
|
+ // BFS to propagate offsets from loops
|
|
|
+ const visited = new Set<string>()
|
|
|
+ const queue: string[] = []
|
|
|
+
|
|
|
+ // Start from all loop nodes
|
|
|
+ loopNodes.forEach(loopId => {
|
|
|
+ const bodyHeight = loopBodyHeights.get(loopId) || 0
|
|
|
+ const outEdges = outgoingEdges.get(loopId) || []
|
|
|
+ outEdges.forEach(edge => {
|
|
|
+ if (edge.handle === 'done' && !nodeToLoop.has(edge.target)) {
|
|
|
+ nodeYOffset.set(edge.target, (nodeYOffset.get(edge.target) || 0) + bodyHeight)
|
|
|
+ queue.push(edge.target)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ // Propagate offsets downstream
|
|
|
+ while (queue.length > 0) {
|
|
|
+ const nodeId = queue.shift()!
|
|
|
+ if (visited.has(nodeId)) continue
|
|
|
+ visited.add(nodeId)
|
|
|
+
|
|
|
+ const offset = nodeYOffset.get(nodeId) || 0
|
|
|
+ const outEdges = outgoingEdges.get(nodeId) || []
|
|
|
+ outEdges.forEach(edge => {
|
|
|
+ if (!nodeToLoop.has(edge.target)) {
|
|
|
+ const existingOffset = nodeYOffset.get(edge.target) || 0
|
|
|
+ nodeYOffset.set(edge.target, Math.max(existingOffset, offset))
|
|
|
+ queue.push(edge.target)
|
|
|
+ }
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- // Position loop body nodes below their loop
|
|
|
+ // Apply positions for main flow nodes
|
|
|
+ nodes.forEach(n => {
|
|
|
+ if (!nodeToLoop.has(n.id)) {
|
|
|
+ const dagreNode = g.node(n.id)
|
|
|
+ if (dagreNode) {
|
|
|
+ const yOffset = nodeYOffset.get(n.id) || 0
|
|
|
+ newPositions.set(n.id, {
|
|
|
+ x: snapToGrid(dagreNode.x - NODE_WIDTH / 2),
|
|
|
+ y: snapToGrid(dagreNode.y - NODE_HEIGHT / 2 + yOffset),
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // Position loop body nodes below their parent loop
|
|
|
loopNodes.forEach(loopId => {
|
|
|
const loopPos = newPositions.get(loopId)
|
|
|
if (!loopPos) return
|
|
|
|
|
|
- const bodyNodes = Array.from(loopBodyNodes.get(loopId) || [])
|
|
|
- if (bodyNodes.length === 0) return
|
|
|
+ const bodyNodeIds = Array.from(loopBodyNodes.get(loopId) || [])
|
|
|
+ if (bodyNodeIds.length === 0) return
|
|
|
|
|
|
- // Sort body nodes by their internal dependencies
|
|
|
- const bodySorted: string[] = []
|
|
|
- const bodyVisited = new Set<string>()
|
|
|
+ // Create and layout sub-graph for body
|
|
|
+ const bodyGraph = new dagre.graphlib.Graph()
|
|
|
+ bodyGraph.setGraph({
|
|
|
+ rankdir: 'TB',
|
|
|
+ nodesep: HORIZONTAL_SPACING * 0.8,
|
|
|
+ ranksep: VERTICAL_SPACING * 0.8,
|
|
|
+ })
|
|
|
+ bodyGraph.setDefaultEdgeLabel(() => ({}))
|
|
|
|
|
|
- const sortBody = (nodeId: string) => {
|
|
|
- if (bodyVisited.has(nodeId) || !loopBodyNodes.get(loopId)!.has(nodeId)) return
|
|
|
- bodyVisited.add(nodeId)
|
|
|
+ bodyNodeIds.forEach(nodeId => {
|
|
|
+ bodyGraph.setNode(nodeId, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
|
|
+ })
|
|
|
|
|
|
- // Visit dependencies first
|
|
|
- const inEdges = incomingEdges.get(nodeId) || []
|
|
|
- for (const edge of inEdges) {
|
|
|
- if (loopBodyNodes.get(loopId)!.has(edge.source)) {
|
|
|
- sortBody(edge.source)
|
|
|
- }
|
|
|
+ edges.forEach(e => {
|
|
|
+ const sourceInBody = loopBodyNodes.get(loopId)!.has(e.source)
|
|
|
+ const targetInBody = loopBodyNodes.get(loopId)!.has(e.target)
|
|
|
+ if (sourceInBody && targetInBody) {
|
|
|
+ bodyGraph.setEdge(e.source, e.target)
|
|
|
}
|
|
|
- bodySorted.push(nodeId)
|
|
|
- }
|
|
|
-
|
|
|
- bodyNodes.forEach(nodeId => sortBody(nodeId))
|
|
|
+ })
|
|
|
|
|
|
- // Position body nodes in rows below the loop
|
|
|
- const BODY_HORIZONTAL_SPACING = 220
|
|
|
- const BODY_VERTICAL_SPACING = 120
|
|
|
- const nodesPerRow = 2
|
|
|
+ dagre.layout(bodyGraph)
|
|
|
|
|
|
- bodySorted.forEach((nodeId, idx) => {
|
|
|
- const row = Math.floor(idx / nodesPerRow)
|
|
|
- const col = idx % nodesPerRow
|
|
|
- const rowWidth = Math.min(nodesPerRow, bodySorted.length - row * nodesPerRow) * BODY_HORIZONTAL_SPACING
|
|
|
- const rowStartX = loopPos.x - rowWidth / 2 + BODY_HORIZONTAL_SPACING / 2 + LOOP_BODY_INDENT
|
|
|
+ // Find bounds to center the body below the loop
|
|
|
+ let minX = Infinity, maxX = -Infinity
|
|
|
+ bodyNodeIds.forEach(nodeId => {
|
|
|
+ const pos = bodyGraph.node(nodeId)
|
|
|
+ if (pos) {
|
|
|
+ minX = Math.min(minX, pos.x - NODE_WIDTH / 2)
|
|
|
+ maxX = Math.max(maxX, pos.x + NODE_WIDTH / 2)
|
|
|
+ }
|
|
|
+ })
|
|
|
|
|
|
- newPositions.set(nodeId, {
|
|
|
- x: snapToGrid(rowStartX + col * BODY_HORIZONTAL_SPACING),
|
|
|
- y: snapToGrid(loopPos.y + VERTICAL_SPACING + row * BODY_VERTICAL_SPACING),
|
|
|
- })
|
|
|
+ const bodyWidth = maxX - minX
|
|
|
+ const bodyCenterX = loopPos.x + NODE_WIDTH / 2 + LOOP_BODY_INDENT_X
|
|
|
+ const bodyStartX = bodyCenterX - bodyWidth / 2
|
|
|
+ const bodyStartY = loopPos.y + NODE_HEIGHT + LOOP_BODY_OFFSET_Y
|
|
|
+
|
|
|
+ bodyNodeIds.forEach(nodeId => {
|
|
|
+ const pos = bodyGraph.node(nodeId)
|
|
|
+ if (pos) {
|
|
|
+ newPositions.set(nodeId, {
|
|
|
+ x: snapToGrid(bodyStartX + (pos.x - minX)),
|
|
|
+ y: snapToGrid(bodyStartY + pos.y - NODE_HEIGHT / 2),
|
|
|
+ })
|
|
|
+ }
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -1864,7 +1927,8 @@ function WorkflowEditorInner() {
|
|
|
nodeTypes={nodeTypes}
|
|
|
defaultEdgeOptions={{
|
|
|
type: 'smoothstep',
|
|
|
- style: { strokeWidth: 2 },
|
|
|
+ style: { strokeWidth: 2, stroke: '#64748b' },
|
|
|
+ animated: false,
|
|
|
}}
|
|
|
connectionLineType={ConnectionLineType.SmoothStep}
|
|
|
snapToGrid={!isViewingExecution}
|