|
@@ -1,7 +1,7 @@
|
|
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
-import dagre from 'dagre'
|
|
|
|
|
import { workflowsApi, nodesApi, executionsApi, Workflow, NodeDefinition, NodeOutput } from '../api/workflows'
|
|
import { workflowsApi, nodesApi, executionsApi, Workflow, NodeDefinition, NodeOutput } from '../api/workflows'
|
|
|
|
|
+import { computeAutoLayout, LayoutNode, LayoutEdge } from '../utils/autoLayout'
|
|
|
import ReactFlow, {
|
|
import ReactFlow, {
|
|
|
Background,
|
|
Background,
|
|
|
Controls,
|
|
Controls,
|
|
@@ -2183,305 +2183,27 @@ function WorkflowEditorInner() {
|
|
|
setShowDeleteConfirm(null)
|
|
setShowDeleteConfirm(null)
|
|
|
}, [setNodes, setEdges])
|
|
}, [setNodes, setEdges])
|
|
|
|
|
|
|
|
- // Auto-layout nodes using dagre for clean top-to-bottom flow
|
|
|
|
|
|
|
+ // Auto-layout nodes using dagre for clean top-to-bottom flow. The actual
|
|
|
|
|
+ // graph computation lives in utils/autoLayout.ts, laying out each loop
|
|
|
|
|
+ // body as its own nested subgraph rather than reserving an estimated block
|
|
|
|
|
+ // of vertical space for it - see that file for the detail.
|
|
|
const autoLayout = useCallback(() => {
|
|
const autoLayout = useCallback(() => {
|
|
|
if (nodes.length === 0) return
|
|
if (nodes.length === 0) return
|
|
|
|
|
|
|
|
- const GRID_SIZE = 20
|
|
|
|
|
- const NODE_WIDTH = 220
|
|
|
|
|
- const NODE_HEIGHT = 80
|
|
|
|
|
- const HORIZONTAL_SPACING = 80
|
|
|
|
|
- const VERTICAL_SPACING = 100
|
|
|
|
|
-
|
|
|
|
|
- const snapToGrid = (value: number) => Math.round(value / GRID_SIZE) * GRID_SIZE
|
|
|
|
|
-
|
|
|
|
|
- // Build edge maps for loop detection
|
|
|
|
|
- const outgoingEdges = new Map<string, Array<{ target: string; handle: string }>>()
|
|
|
|
|
- nodes.forEach(n => outgoingEdges.set(n.id, []))
|
|
|
|
|
- edges.forEach(e => {
|
|
|
|
|
- outgoingEdges.get(e.source)?.push({ target: e.target, handle: e.sourceHandle || 'main' })
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Identify loop nodes and their body nodes
|
|
|
|
|
- const loopNodes = new Set<string>()
|
|
|
|
|
- const loopBodyNodes = new Map<string, Set<string>>()
|
|
|
|
|
-
|
|
|
|
|
- nodes.forEach(n => {
|
|
|
|
|
- if (n.data.type === 'loop') {
|
|
|
|
|
- loopNodes.add(n.id)
|
|
|
|
|
- loopBodyNodes.set(n.id, new Set())
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Find body nodes for each loop (nodes connected via "loop" output, not "done")
|
|
|
|
|
- const findLoopBodyNodes = (loopId: string, nodeId: string, visited: Set<string>) => {
|
|
|
|
|
- if (visited.has(nodeId)) return
|
|
|
|
|
- visited.add(nodeId)
|
|
|
|
|
-
|
|
|
|
|
- const bodySet = loopBodyNodes.get(loopId)!
|
|
|
|
|
- if (nodeId !== loopId) {
|
|
|
|
|
- bodySet.add(nodeId)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const outEdges = outgoingEdges.get(nodeId) || []
|
|
|
|
|
- for (const edge of outEdges) {
|
|
|
|
|
- // Don't follow "done" edges from the loop itself
|
|
|
|
|
- if (nodeId === loopId && edge.handle === 'done') continue
|
|
|
|
|
- // Don't include other loops as body nodes
|
|
|
|
|
- if (loopNodes.has(edge.target) && edge.target !== loopId) continue
|
|
|
|
|
- findLoopBodyNodes(loopId, edge.target, visited)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- loopNodes.forEach(loopId => {
|
|
|
|
|
- const outEdges = outgoingEdges.get(loopId) || []
|
|
|
|
|
- const loopEdges = outEdges.filter(e => e.handle === 'loop')
|
|
|
|
|
- const visited = new Set<string>()
|
|
|
|
|
- loopEdges.forEach(edge => findLoopBodyNodes(loopId, edge.target, visited))
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Calculate depth of loop body (how many nodes deep)
|
|
|
|
|
- const getLoopBodyDepth = (loopId: string): number => {
|
|
|
|
|
- const bodyNodes = loopBodyNodes.get(loopId)
|
|
|
|
|
- if (!bodyNodes || bodyNodes.size === 0) return 1
|
|
|
|
|
-
|
|
|
|
|
- let maxDepth = 0
|
|
|
|
|
- const visited = new Set<string>()
|
|
|
|
|
-
|
|
|
|
|
- const dfs = (nodeId: string, depth: number) => {
|
|
|
|
|
- if (visited.has(nodeId)) return
|
|
|
|
|
- visited.add(nodeId)
|
|
|
|
|
- maxDepth = Math.max(maxDepth, depth)
|
|
|
|
|
-
|
|
|
|
|
- const outEdges = outgoingEdges.get(nodeId) || []
|
|
|
|
|
- for (const edge of outEdges) {
|
|
|
|
|
- if (bodyNodes.has(edge.target)) {
|
|
|
|
|
- dfs(edge.target, depth + 1)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Start from loop's direct children
|
|
|
|
|
- const loopOutEdges = outgoingEdges.get(loopId) || []
|
|
|
|
|
- loopOutEdges.filter(e => e.handle === 'loop').forEach(e => {
|
|
|
|
|
- dfs(e.target, 1)
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- return Math.max(maxDepth, 1)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Create dagre graph
|
|
|
|
|
- const g = new dagre.graphlib.Graph()
|
|
|
|
|
- g.setGraph({
|
|
|
|
|
- rankdir: 'TB',
|
|
|
|
|
- nodesep: HORIZONTAL_SPACING,
|
|
|
|
|
- ranksep: VERTICAL_SPACING,
|
|
|
|
|
- ranker: 'longest-path',
|
|
|
|
|
- })
|
|
|
|
|
- g.setDefaultEdgeLabel(() => ({}))
|
|
|
|
|
-
|
|
|
|
|
- // Add all real nodes
|
|
|
|
|
- nodes.forEach(n => {
|
|
|
|
|
- g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Add virtual spacer nodes for loop bodies to reserve vertical space
|
|
|
|
|
- const spacerNodes = new Map<string, string>()
|
|
|
|
|
- loopNodes.forEach(loopId => {
|
|
|
|
|
- const spacerId = `spacer_${loopId}`
|
|
|
|
|
- spacerNodes.set(loopId, spacerId)
|
|
|
|
|
- const depth = getLoopBodyDepth(loopId)
|
|
|
|
|
- // Create spacer with height based on loop body depth
|
|
|
|
|
- g.setNode(spacerId, {
|
|
|
|
|
- width: NODE_WIDTH,
|
|
|
|
|
- height: NODE_HEIGHT * depth + VERTICAL_SPACING * (depth - 1)
|
|
|
|
|
- })
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Find nodes that exit from loop body to main flow (connected to nodes outside the loop body)
|
|
|
|
|
- const loopExitTargets = new Map<string, Set<string>>()
|
|
|
|
|
- loopNodes.forEach(loopId => {
|
|
|
|
|
- loopExitTargets.set(loopId, new Set())
|
|
|
|
|
- const bodyNodes = loopBodyNodes.get(loopId)
|
|
|
|
|
- if (!bodyNodes) return
|
|
|
|
|
-
|
|
|
|
|
- bodyNodes.forEach(bodyNodeId => {
|
|
|
|
|
- const outEdges = outgoingEdges.get(bodyNodeId) || []
|
|
|
|
|
- outEdges.forEach(edge => {
|
|
|
|
|
- // If edge target is not a body node and not the loop itself, it's an exit edge
|
|
|
|
|
- if (!bodyNodes.has(edge.target) && edge.target !== loopId) {
|
|
|
|
|
- loopExitTargets.get(loopId)?.add(edge.target)
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- })
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Add edges to dagre, routing loop flows through spacers
|
|
|
|
|
- edges.forEach(e => {
|
|
|
|
|
- const sourceNode = nodes.find(n => n.id === e.source)
|
|
|
|
|
- const sourceHandle = e.sourceHandle || 'main'
|
|
|
|
|
-
|
|
|
|
|
- if (sourceNode?.data.type === 'loop') {
|
|
|
|
|
- const spacerId = spacerNodes.get(e.source)!
|
|
|
|
|
- if (sourceHandle === 'loop') {
|
|
|
|
|
- // Loop body connection: loop -> spacer (don't add to dagre, handled separately)
|
|
|
|
|
- return
|
|
|
|
|
- } else if (sourceHandle === 'done') {
|
|
|
|
|
- // Done connection: goes from spacer to target
|
|
|
|
|
- g.setEdge(spacerId, e.target)
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Check if this edge is from a loop body node to something outside
|
|
|
|
|
- let isExitEdge = false
|
|
|
|
|
- loopNodes.forEach(loopId => {
|
|
|
|
|
- const bodyNodes = loopBodyNodes.get(loopId)
|
|
|
|
|
- if (bodyNodes?.has(e.source) && !bodyNodes.has(e.target) && e.target !== loopId) {
|
|
|
|
|
- isExitEdge = true
|
|
|
|
|
- // Route through spacer to ensure proper ordering
|
|
|
|
|
- const spacerId = spacerNodes.get(loopId)!
|
|
|
|
|
- g.setEdge(spacerId, e.target)
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- if (!isExitEdge) {
|
|
|
|
|
- // Regular edge - only add if not loop body internal
|
|
|
|
|
- let isLoopBodyInternal = false
|
|
|
|
|
- loopNodes.forEach(loopId => {
|
|
|
|
|
- const bodyNodes = loopBodyNodes.get(loopId)
|
|
|
|
|
- if (bodyNodes?.has(e.source) && (bodyNodes.has(e.target) || e.target === loopId)) {
|
|
|
|
|
- isLoopBodyInternal = true
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- if (!isLoopBodyInternal) {
|
|
|
|
|
- g.setEdge(e.source, e.target)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Connect loops to their spacers
|
|
|
|
|
- loopNodes.forEach(loopId => {
|
|
|
|
|
- const spacerId = spacerNodes.get(loopId)!
|
|
|
|
|
- g.setEdge(loopId, spacerId)
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Run dagre layout
|
|
|
|
|
- dagre.layout(g)
|
|
|
|
|
-
|
|
|
|
|
- // Extract positions
|
|
|
|
|
- const newPositions = new Map<string, { x: number; y: number }>()
|
|
|
|
|
-
|
|
|
|
|
- // Position real nodes
|
|
|
|
|
- nodes.forEach(n => {
|
|
|
|
|
- const nodeData = g.node(n.id)
|
|
|
|
|
- if (nodeData) {
|
|
|
|
|
- newPositions.set(n.id, {
|
|
|
|
|
- x: snapToGrid(nodeData.x - NODE_WIDTH / 2),
|
|
|
|
|
- y: snapToGrid(nodeData.y - NODE_HEIGHT / 2),
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Position loop body nodes based on their loop's spacer position
|
|
|
|
|
- loopNodes.forEach(loopId => {
|
|
|
|
|
- const spacerId = spacerNodes.get(loopId)!
|
|
|
|
|
- const spacerData = g.node(spacerId)
|
|
|
|
|
- const loopPos = newPositions.get(loopId)
|
|
|
|
|
- const bodyNodes = loopBodyNodes.get(loopId)
|
|
|
|
|
-
|
|
|
|
|
- if (!spacerData || !loopPos || !bodyNodes || bodyNodes.size === 0) return
|
|
|
|
|
-
|
|
|
|
|
- // Create a mini-graph for loop body nodes
|
|
|
|
|
- const bodyGraph = new dagre.graphlib.Graph()
|
|
|
|
|
- bodyGraph.setGraph({
|
|
|
|
|
- rankdir: 'TB',
|
|
|
|
|
- nodesep: HORIZONTAL_SPACING / 2,
|
|
|
|
|
- ranksep: VERTICAL_SPACING / 2,
|
|
|
|
|
- })
|
|
|
|
|
- bodyGraph.setDefaultEdgeLabel(() => ({}))
|
|
|
|
|
-
|
|
|
|
|
- bodyNodes.forEach(bodyId => {
|
|
|
|
|
- bodyGraph.setNode(bodyId, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Add edges between body nodes
|
|
|
|
|
- edges.forEach(e => {
|
|
|
|
|
- if (bodyNodes.has(e.source) && bodyNodes.has(e.target)) {
|
|
|
|
|
- bodyGraph.setEdge(e.source, e.target)
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // Add edges from loop to first body nodes
|
|
|
|
|
- const loopOutEdges = outgoingEdges.get(loopId) || []
|
|
|
|
|
- loopOutEdges.filter(e => e.handle === 'loop').forEach(e => {
|
|
|
|
|
- if (bodyNodes.has(e.target)) {
|
|
|
|
|
- bodyGraph.setNode('_loop_start', { width: 0, height: 0 })
|
|
|
|
|
- bodyGraph.setEdge('_loop_start', e.target)
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- dagre.layout(bodyGraph)
|
|
|
|
|
-
|
|
|
|
|
- // Position body nodes below and indented from the loop
|
|
|
|
|
- const loopCenterX = loopPos.x + NODE_WIDTH / 2
|
|
|
|
|
- const bodyStartY = loopPos.y + NODE_HEIGHT + VERTICAL_SPACING / 2
|
|
|
|
|
-
|
|
|
|
|
- let minX = Infinity, maxX = -Infinity
|
|
|
|
|
- bodyNodes.forEach(bodyId => {
|
|
|
|
|
- const bodyData = bodyGraph.node(bodyId)
|
|
|
|
|
- if (bodyData) {
|
|
|
|
|
- minX = Math.min(minX, bodyData.x)
|
|
|
|
|
- maxX = Math.max(maxX, bodyData.x)
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- const bodyWidth = maxX - minX
|
|
|
|
|
- const offsetX = loopCenterX - (minX + bodyWidth / 2) + 40 // Indent to the right
|
|
|
|
|
-
|
|
|
|
|
- bodyNodes.forEach(bodyId => {
|
|
|
|
|
- const bodyData = bodyGraph.node(bodyId)
|
|
|
|
|
- if (bodyData) {
|
|
|
|
|
- newPositions.set(bodyId, {
|
|
|
|
|
- x: snapToGrid(bodyData.x - NODE_WIDTH / 2 + offsetX),
|
|
|
|
|
- y: snapToGrid(bodyData.y - NODE_HEIGHT / 2 + bodyStartY),
|
|
|
|
|
- })
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- // A Configurator does not sit in the flow - it hangs off the side of the
|
|
|
|
|
- // nodes it configures. Dagre has no idea about that and drops it in the
|
|
|
|
|
- // column like anything else, so it is moved out to the left of whatever it
|
|
|
|
|
- // feeds, level with the topmost one.
|
|
|
|
|
- const CONFIG_GAP = NODE_WIDTH + 80
|
|
|
|
|
- nodes.filter(n => n.data.type === 'configurator').forEach(cfg => {
|
|
|
|
|
- const targets = edges
|
|
|
|
|
- .filter(e => e.source === cfg.id && (e.targetHandle || 'data') === 'config')
|
|
|
|
|
- .map(e => newPositions.get(e.target))
|
|
|
|
|
- .filter((p): p is { x: number; y: number } => !!p)
|
|
|
|
|
- if (targets.length === 0) return
|
|
|
|
|
- const leftmost = Math.min(...targets.map(p => p.x))
|
|
|
|
|
- const topmost = Math.min(...targets.map(p => p.y))
|
|
|
|
|
- newPositions.set(cfg.id, {
|
|
|
|
|
- x: snapToGrid(leftmost - CONFIG_GAP),
|
|
|
|
|
- y: snapToGrid(topmost),
|
|
|
|
|
- })
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const layoutNodes: LayoutNode[] = nodes.map(n => ({
|
|
|
|
|
+ id: n.id,
|
|
|
|
|
+ type: n.data.type,
|
|
|
|
|
+ width: n.width,
|
|
|
|
|
+ height: n.height,
|
|
|
|
|
+ }))
|
|
|
|
|
+ const layoutEdges: LayoutEdge[] = edges.map(e => ({
|
|
|
|
|
+ source: e.source,
|
|
|
|
|
+ target: e.target,
|
|
|
|
|
+ sourceHandle: e.sourceHandle,
|
|
|
|
|
+ targetHandle: e.targetHandle,
|
|
|
|
|
+ }))
|
|
|
|
|
|
|
|
- // Two Configurators that ended up on the same spot would hide one another.
|
|
|
|
|
- const takenColumns = new Map<string, number>()
|
|
|
|
|
- nodes.filter(n => n.data.type === 'configurator').forEach(cfg => {
|
|
|
|
|
- const pos = newPositions.get(cfg.id)
|
|
|
|
|
- if (!pos) return
|
|
|
|
|
- const key = `${pos.x}:${pos.y}`
|
|
|
|
|
- const seen = takenColumns.get(key) || 0
|
|
|
|
|
- if (seen > 0) {
|
|
|
|
|
- newPositions.set(cfg.id, { x: pos.x, y: snapToGrid(pos.y + seen * (NODE_HEIGHT + 40)) })
|
|
|
|
|
- }
|
|
|
|
|
- takenColumns.set(key, seen + 1)
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const newPositions = computeAutoLayout(layoutNodes, layoutEdges)
|
|
|
|
|
|
|
|
// Check if any positions actually changed
|
|
// Check if any positions actually changed
|
|
|
const positionsChanged = nodes.some(n => {
|
|
const positionsChanged = nodes.some(n => {
|