Skip to content

Commit 20d66b9

Browse files
committed
fixed subflow ops
1 parent 1029ba0 commit 20d66b9

File tree

15 files changed

+782
-107
lines changed

15 files changed

+782
-107
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,7 @@ const WorkflowEdgeComponent = ({
4040
})
4141

4242
const isSelected = data?.isSelected ?? false
43-
const isInsideLoop = data?.isInsideLoop ?? false
44-
const parentLoopId = data?.parentLoopId
4543

46-
// Combined store subscription to reduce subscription overhead
4744
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore(
4845
useShallow((state) => ({
4946
diffAnalysis: state.diffAnalysis,
@@ -57,7 +54,6 @@ const WorkflowEdgeComponent = ({
5754
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
5855
const edgeRunStatus = lastRunEdges.get(id)
5956

60-
// Memoize diff status calculation to avoid recomputing on every render
6157
const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
6258
if (data?.isDeleted) return 'deleted'
6359
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
@@ -84,7 +80,6 @@ const WorkflowEdgeComponent = ({
8480
targetHandle,
8581
])
8682

87-
// Memoize edge style to prevent object recreation
8883
const edgeStyle = useMemo(() => {
8984
let color = 'var(--workflow-edge)'
9085
if (edgeDiffStatus === 'deleted') color = 'var(--text-error)'
@@ -104,30 +99,14 @@ const WorkflowEdgeComponent = ({
10499

105100
return (
106101
<>
107-
<BaseEdge
108-
path={edgePath}
109-
data-testid='workflow-edge'
110-
style={edgeStyle}
111-
interactionWidth={30}
112-
data-edge-id={id}
113-
data-parent-loop-id={parentLoopId}
114-
data-is-selected={isSelected ? 'true' : 'false'}
115-
data-is-inside-loop={isInsideLoop ? 'true' : 'false'}
116-
/>
117-
{/* Animate dash offset for edge movement effect */}
118-
<animate
119-
attributeName='stroke-dashoffset'
120-
from={edgeDiffStatus === 'deleted' ? '15' : '10'}
121-
to='0'
122-
dur={edgeDiffStatus === 'deleted' ? '2s' : '1s'}
123-
repeatCount='indefinite'
124-
/>
102+
<BaseEdge path={edgePath} style={edgeStyle} interactionWidth={30} />
125103

126104
{isSelected && (
127105
<EdgeLabelRenderer>
128106
<div
129107
className='nodrag nopan group flex h-[22px] w-[22px] cursor-pointer items-center justify-center transition-colors'
130108
style={{
109+
position: 'absolute',
131110
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
132111
pointerEvents: 'all',
133112
zIndex: 100,
@@ -137,7 +116,6 @@ const WorkflowEdgeComponent = ({
137116
e.stopPropagation()
138117

139118
if (data?.onDelete) {
140-
// Pass this specific edge's ID to the delete function
141119
data.onDelete(id)
142120
}
143121
}}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -315,11 +315,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
315315

316316
childNodes.forEach((node) => {
317317
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
318-
// Use block position from store if available (more up-to-date)
319-
const block = blocks[node.id]
320-
const position = block?.position || node.position
321-
maxRight = Math.max(maxRight, position.x + nodeWidth)
322-
maxBottom = Math.max(maxBottom, position.y + nodeHeight)
318+
// Use ReactFlow's node.position which is already in the correct coordinate system
319+
// (relative to parent for child nodes). The store's block.position may be stale
320+
// or still in absolute coordinates during parent updates.
321+
maxRight = Math.max(maxRight, node.position.x + nodeWidth)
322+
maxBottom = Math.max(maxBottom, node.position.y + nodeHeight)
323323
})
324324

325325
const width = Math.max(

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ const WorkflowContent = React.memo(() => {
447447
collaborativeBatchRemoveEdges,
448448
collaborativeBatchUpdatePositions,
449449
collaborativeUpdateParentId: updateParentId,
450+
collaborativeBatchUpdateParent,
450451
collaborativeBatchAddBlocks,
451452
collaborativeBatchRemoveBlocks,
452453
collaborativeBatchToggleBlockEnabled,
@@ -2425,6 +2426,43 @@ const WorkflowContent = React.memo(() => {
24252426
previousPositions: multiNodeDragStartRef.current,
24262427
})
24272428

2429+
// Process parent updates for all selected nodes if dropping into a subflow
2430+
if (potentialParentId && potentialParentId !== dragStartParentId) {
2431+
// Filter out nodes that cannot be moved into subflows
2432+
const validNodes = selectedNodes.filter((n) => {
2433+
const block = blocks[n.id]
2434+
if (!block) return false
2435+
// Starter blocks cannot be in containers
2436+
if (n.data?.type === 'starter') return false
2437+
// Trigger blocks cannot be in containers
2438+
if (TriggerUtils.isTriggerBlock(block)) return false
2439+
// Subflow nodes (loop/parallel) cannot be nested
2440+
if (n.type === 'subflowNode') return false
2441+
return true
2442+
})
2443+
2444+
if (validNodes.length > 0) {
2445+
// Build updates for all valid nodes
2446+
const updates = validNodes.map((n) => {
2447+
const edgesToRemove = edgesForDisplay.filter(
2448+
(e) => e.source === n.id || e.target === n.id
2449+
)
2450+
return {
2451+
blockId: n.id,
2452+
newParentId: potentialParentId,
2453+
affectedEdges: edgesToRemove,
2454+
}
2455+
})
2456+
2457+
collaborativeBatchUpdateParent(updates)
2458+
2459+
logger.info('Batch moved nodes into subflow', {
2460+
targetParentId: potentialParentId,
2461+
nodeCount: validNodes.length,
2462+
})
2463+
}
2464+
}
2465+
24282466
// Clear drag start state
24292467
setDragStartPosition(null)
24302468
setPotentialParentId(null)
@@ -2568,6 +2606,7 @@ const WorkflowContent = React.memo(() => {
25682606
addNotification,
25692607
activeWorkflowId,
25702608
collaborativeBatchUpdatePositions,
2609+
collaborativeBatchUpdateParent,
25712610
]
25722611
)
25732612

@@ -2582,19 +2621,213 @@ const WorkflowContent = React.memo(() => {
25822621
requestAnimationFrame(() => setIsSelectionDragActive(false))
25832622
}, [])
25842623

2624+
/** Captures initial positions when selection drag starts (for marquee-selected nodes). */
2625+
const onSelectionDragStart = useCallback(
2626+
(_event: React.MouseEvent, nodes: Node[]) => {
2627+
// Capture the parent ID of the first node as reference (they should all be in the same context)
2628+
if (nodes.length > 0) {
2629+
const firstNodeParentId = blocks[nodes[0].id]?.data?.parentId || null
2630+
setDragStartParentId(firstNodeParentId)
2631+
}
2632+
2633+
// Capture all selected nodes' positions for undo/redo
2634+
multiNodeDragStartRef.current.clear()
2635+
nodes.forEach((n) => {
2636+
const block = blocks[n.id]
2637+
if (block) {
2638+
multiNodeDragStartRef.current.set(n.id, {
2639+
x: n.position.x,
2640+
y: n.position.y,
2641+
parentId: block.data?.parentId,
2642+
})
2643+
}
2644+
})
2645+
},
2646+
[blocks]
2647+
)
2648+
2649+
/** Handles selection drag to detect potential parent containers for batch drops. */
2650+
const onSelectionDrag = useCallback(
2651+
(_event: React.MouseEvent, nodes: Node[]) => {
2652+
if (nodes.length === 0) return
2653+
2654+
// Filter out nodes that can't be placed in containers
2655+
const eligibleNodes = nodes.filter((n) => {
2656+
if (n.data?.type === 'starter') return false
2657+
if (n.type === 'subflowNode') return false
2658+
const block = blocks[n.id]
2659+
if (block && TriggerUtils.isTriggerBlock(block)) return false
2660+
return true
2661+
})
2662+
2663+
// If no eligible nodes, clear any potential parent
2664+
if (eligibleNodes.length === 0) {
2665+
if (potentialParentId) {
2666+
clearDragHighlights()
2667+
setPotentialParentId(null)
2668+
}
2669+
return
2670+
}
2671+
2672+
// Calculate bounding box of all dragged nodes using absolute positions
2673+
let minX = Number.POSITIVE_INFINITY
2674+
let minY = Number.POSITIVE_INFINITY
2675+
let maxX = Number.NEGATIVE_INFINITY
2676+
let maxY = Number.NEGATIVE_INFINITY
2677+
2678+
eligibleNodes.forEach((node) => {
2679+
const absolutePos = getNodeAbsolutePosition(node.id)
2680+
const block = blocks[node.id]
2681+
const width = BLOCK_DIMENSIONS.FIXED_WIDTH
2682+
const height = Math.max(
2683+
node.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
2684+
BLOCK_DIMENSIONS.MIN_HEIGHT
2685+
)
2686+
2687+
minX = Math.min(minX, absolutePos.x)
2688+
minY = Math.min(minY, absolutePos.y)
2689+
maxX = Math.max(maxX, absolutePos.x + width)
2690+
maxY = Math.max(maxY, absolutePos.y + height)
2691+
})
2692+
2693+
// Use bounding box for intersection detection
2694+
const selectionRect = { left: minX, right: maxX, top: minY, bottom: maxY }
2695+
2696+
// Find containers that intersect with the selection bounding box
2697+
const allNodes = getNodes()
2698+
const intersectingContainers = allNodes
2699+
.filter((containerNode) => {
2700+
if (containerNode.type !== 'subflowNode') return false
2701+
// Skip if any dragged node is this container
2702+
if (nodes.some((n) => n.id === containerNode.id)) return false
2703+
2704+
const containerAbsolutePos = getNodeAbsolutePosition(containerNode.id)
2705+
const containerRect = {
2706+
left: containerAbsolutePos.x,
2707+
right:
2708+
containerAbsolutePos.x +
2709+
(containerNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
2710+
top: containerAbsolutePos.y,
2711+
bottom:
2712+
containerAbsolutePos.y +
2713+
(containerNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
2714+
}
2715+
2716+
// Check intersection
2717+
return (
2718+
selectionRect.left < containerRect.right &&
2719+
selectionRect.right > containerRect.left &&
2720+
selectionRect.top < containerRect.bottom &&
2721+
selectionRect.bottom > containerRect.top
2722+
)
2723+
})
2724+
.map((n) => ({
2725+
container: n,
2726+
depth: getNodeDepth(n.id),
2727+
size:
2728+
(n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) *
2729+
(n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
2730+
}))
2731+
2732+
if (intersectingContainers.length > 0) {
2733+
// Sort by depth first (deepest first), then by size
2734+
const sortedContainers = intersectingContainers.sort((a, b) => {
2735+
if (a.depth !== b.depth) return b.depth - a.depth
2736+
return a.size - b.size
2737+
})
2738+
2739+
const bestMatch = sortedContainers[0]
2740+
2741+
if (bestMatch.container.id !== potentialParentId) {
2742+
clearDragHighlights()
2743+
setPotentialParentId(bestMatch.container.id)
2744+
2745+
// Add highlight
2746+
const containerElement = document.querySelector(`[data-id="${bestMatch.container.id}"]`)
2747+
if (containerElement) {
2748+
if ((bestMatch.container.data as SubflowNodeData)?.kind === 'loop') {
2749+
containerElement.classList.add('loop-node-drag-over')
2750+
} else if ((bestMatch.container.data as SubflowNodeData)?.kind === 'parallel') {
2751+
containerElement.classList.add('parallel-node-drag-over')
2752+
}
2753+
document.body.style.cursor = 'copy'
2754+
}
2755+
}
2756+
} else if (potentialParentId) {
2757+
clearDragHighlights()
2758+
setPotentialParentId(null)
2759+
}
2760+
},
2761+
[
2762+
blocks,
2763+
getNodes,
2764+
potentialParentId,
2765+
getNodeAbsolutePosition,
2766+
getNodeDepth,
2767+
clearDragHighlights,
2768+
]
2769+
)
2770+
25852771
const onSelectionDragStop = useCallback(
25862772
(_event: React.MouseEvent, nodes: any[]) => {
25872773
requestAnimationFrame(() => setIsSelectionDragActive(false))
2774+
clearDragHighlights()
25882775
if (nodes.length === 0) return
25892776

25902777
const allNodes = getNodes()
25912778
const positionUpdates = computeClampedPositionUpdates(nodes, blocks, allNodes)
25922779
collaborativeBatchUpdatePositions(positionUpdates, {
25932780
previousPositions: multiNodeDragStartRef.current,
25942781
})
2782+
2783+
// Process parent updates if dropping into a subflow
2784+
if (potentialParentId && potentialParentId !== dragStartParentId) {
2785+
// Filter out nodes that cannot be moved into subflows
2786+
const validNodes = nodes.filter((n: Node) => {
2787+
const block = blocks[n.id]
2788+
if (!block) return false
2789+
if (n.data?.type === 'starter') return false
2790+
if (TriggerUtils.isTriggerBlock(block)) return false
2791+
if (n.type === 'subflowNode') return false
2792+
return true
2793+
})
2794+
2795+
if (validNodes.length > 0) {
2796+
const updates = validNodes.map((n: Node) => {
2797+
const edgesToRemove = edgesForDisplay.filter(
2798+
(e) => e.source === n.id || e.target === n.id
2799+
)
2800+
return {
2801+
blockId: n.id,
2802+
newParentId: potentialParentId,
2803+
affectedEdges: edgesToRemove,
2804+
}
2805+
})
2806+
2807+
collaborativeBatchUpdateParent(updates)
2808+
2809+
logger.info('Batch moved selection into subflow', {
2810+
targetParentId: potentialParentId,
2811+
nodeCount: validNodes.length,
2812+
})
2813+
}
2814+
}
2815+
2816+
// Clear drag state
2817+
setDragStartPosition(null)
2818+
setPotentialParentId(null)
25952819
multiNodeDragStartRef.current.clear()
25962820
},
2597-
[blocks, getNodes, collaborativeBatchUpdatePositions]
2821+
[
2822+
blocks,
2823+
getNodes,
2824+
collaborativeBatchUpdatePositions,
2825+
collaborativeBatchUpdateParent,
2826+
potentialParentId,
2827+
dragStartParentId,
2828+
edgesForDisplay,
2829+
clearDragHighlights,
2830+
]
25982831
)
25992832

26002833
const onPaneClick = useCallback(() => {
@@ -2818,6 +3051,8 @@ const WorkflowContent = React.memo(() => {
28183051
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
28193052
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
28203053
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
3054+
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
3055+
onSelectionDrag={effectivePermissions.canEdit ? onSelectionDrag : undefined}
28213056
onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined}
28223057
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
28233058
snapToGrid={snapToGrid}

0 commit comments

Comments
 (0)