Skip to content

Commit abf46da

Browse files
committed
fixed subflow ops
1 parent 3b69707 commit abf46da

File tree

15 files changed

+782
-104
lines changed

15 files changed

+782
-104
lines changed

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

Lines changed: 2 additions & 21 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,
@@ -124,30 +121,14 @@ const WorkflowEdgeComponent = ({
124121

125122
return (
126123
<>
127-
<BaseEdge
128-
path={edgePath}
129-
data-testid='workflow-edge'
130-
style={edgeStyle}
131-
interactionWidth={30}
132-
data-edge-id={id}
133-
data-parent-loop-id={parentLoopId}
134-
data-is-selected={isSelected ? 'true' : 'false'}
135-
data-is-inside-loop={isInsideLoop ? 'true' : 'false'}
136-
/>
137-
{/* Animate dash offset for edge movement effect */}
138-
<animate
139-
attributeName='stroke-dashoffset'
140-
from={edgeDiffStatus === 'deleted' ? '15' : '10'}
141-
to='0'
142-
dur={edgeDiffStatus === 'deleted' ? '2s' : '1s'}
143-
repeatCount='indefinite'
144-
/>
124+
<BaseEdge path={edgePath} style={edgeStyle} interactionWidth={30} />
145125

146126
{isSelected && (
147127
<EdgeLabelRenderer>
148128
<div
149129
className='nodrag nopan group flex h-[22px] w-[22px] cursor-pointer items-center justify-center transition-colors'
150130
style={{
131+
position: 'absolute',
151132
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
152133
pointerEvents: 'all',
153134
zIndex: 100,

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,
@@ -2437,6 +2438,43 @@ const WorkflowContent = React.memo(() => {
24372438
previousPositions: multiNodeDragStartRef.current,
24382439
})
24392440

2441+
// Process parent updates for all selected nodes if dropping into a subflow
2442+
if (potentialParentId && potentialParentId !== dragStartParentId) {
2443+
// Filter out nodes that cannot be moved into subflows
2444+
const validNodes = selectedNodes.filter((n) => {
2445+
const block = blocks[n.id]
2446+
if (!block) return false
2447+
// Starter blocks cannot be in containers
2448+
if (n.data?.type === 'starter') return false
2449+
// Trigger blocks cannot be in containers
2450+
if (TriggerUtils.isTriggerBlock(block)) return false
2451+
// Subflow nodes (loop/parallel) cannot be nested
2452+
if (n.type === 'subflowNode') return false
2453+
return true
2454+
})
2455+
2456+
if (validNodes.length > 0) {
2457+
// Build updates for all valid nodes
2458+
const updates = validNodes.map((n) => {
2459+
const edgesToRemove = edgesForDisplay.filter(
2460+
(e) => e.source === n.id || e.target === n.id
2461+
)
2462+
return {
2463+
blockId: n.id,
2464+
newParentId: potentialParentId,
2465+
affectedEdges: edgesToRemove,
2466+
}
2467+
})
2468+
2469+
collaborativeBatchUpdateParent(updates)
2470+
2471+
logger.info('Batch moved nodes into subflow', {
2472+
targetParentId: potentialParentId,
2473+
nodeCount: validNodes.length,
2474+
})
2475+
}
2476+
}
2477+
24402478
// Clear drag start state
24412479
setDragStartPosition(null)
24422480
setPotentialParentId(null)
@@ -2580,6 +2618,7 @@ const WorkflowContent = React.memo(() => {
25802618
addNotification,
25812619
activeWorkflowId,
25822620
collaborativeBatchUpdatePositions,
2621+
collaborativeBatchUpdateParent,
25832622
]
25842623
)
25852624

@@ -2594,19 +2633,213 @@ const WorkflowContent = React.memo(() => {
25942633
requestAnimationFrame(() => setIsSelectionDragActive(false))
25952634
}, [])
25962635

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

26022789
const allNodes = getNodes()
26032790
const positionUpdates = computeClampedPositionUpdates(nodes, blocks, allNodes)
26042791
collaborativeBatchUpdatePositions(positionUpdates, {
26052792
previousPositions: multiNodeDragStartRef.current,
26062793
})
2794+
2795+
// Process parent updates if dropping into a subflow
2796+
if (potentialParentId && potentialParentId !== dragStartParentId) {
2797+
// Filter out nodes that cannot be moved into subflows
2798+
const validNodes = nodes.filter((n: Node) => {
2799+
const block = blocks[n.id]
2800+
if (!block) return false
2801+
if (n.data?.type === 'starter') return false
2802+
if (TriggerUtils.isTriggerBlock(block)) return false
2803+
if (n.type === 'subflowNode') return false
2804+
return true
2805+
})
2806+
2807+
if (validNodes.length > 0) {
2808+
const updates = validNodes.map((n: Node) => {
2809+
const edgesToRemove = edgesForDisplay.filter(
2810+
(e) => e.source === n.id || e.target === n.id
2811+
)
2812+
return {
2813+
blockId: n.id,
2814+
newParentId: potentialParentId,
2815+
affectedEdges: edgesToRemove,
2816+
}
2817+
})
2818+
2819+
collaborativeBatchUpdateParent(updates)
2820+
2821+
logger.info('Batch moved selection into subflow', {
2822+
targetParentId: potentialParentId,
2823+
nodeCount: validNodes.length,
2824+
})
2825+
}
2826+
}
2827+
2828+
// Clear drag state
2829+
setDragStartPosition(null)
2830+
setPotentialParentId(null)
26072831
multiNodeDragStartRef.current.clear()
26082832
},
2609-
[blocks, getNodes, collaborativeBatchUpdatePositions]
2833+
[
2834+
blocks,
2835+
getNodes,
2836+
collaborativeBatchUpdatePositions,
2837+
collaborativeBatchUpdateParent,
2838+
potentialParentId,
2839+
dragStartParentId,
2840+
edgesForDisplay,
2841+
clearDragHighlights,
2842+
]
26102843
)
26112844

26122845
const onPaneClick = useCallback(() => {
@@ -2830,6 +3063,8 @@ const WorkflowContent = React.memo(() => {
28303063
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
28313064
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
28323065
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
3066+
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
3067+
onSelectionDrag={effectivePermissions.canEdit ? onSelectionDrag : undefined}
28333068
onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined}
28343069
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
28353070
snapToGrid={snapToGrid}

0 commit comments

Comments
 (0)