Skip to content

Commit e8a8cd3

Browse files
committed
fix(workflow): show Remove from Subflow for unconnected blocks pasted into subflows
A block copy-pasted into a loop/parallel has parentId set but no incoming edges yet, so the context menu's positional-trigger heuristic (no incoming edges = trigger) classified it as a trigger and hid Remove from Subflow. Blocks nested inside a subflow can never be entry points, so they are now excluded from positional-trigger classification.
1 parent 9efb7b4 commit e8a8cd3

3 files changed

Lines changed: 84 additions & 1 deletion

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { isPositionalTriggerBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
6+
7+
describe('isPositionalTriggerBlock', () => {
8+
it('returns true for a top-level block with no incoming edges', () => {
9+
const block = { id: 'block-1' }
10+
const edges = [{ target: 'other-block' }]
11+
12+
expect(isPositionalTriggerBlock(block, edges)).toBe(true)
13+
})
14+
15+
it('returns true for a top-level block when there are no edges at all', () => {
16+
expect(isPositionalTriggerBlock({ id: 'block-1' }, [])).toBe(true)
17+
})
18+
19+
it('returns false for a top-level block with incoming edges', () => {
20+
const block = { id: 'block-1' }
21+
const edges = [{ target: 'block-1' }]
22+
23+
expect(isPositionalTriggerBlock(block, edges)).toBe(false)
24+
})
25+
26+
it('returns false for a block nested in a subflow even with no incoming edges', () => {
27+
const block = { id: 'nested-block', parentId: 'loop-1' }
28+
29+
expect(isPositionalTriggerBlock(block, [])).toBe(false)
30+
})
31+
32+
it('returns false for a nested block with incoming edges', () => {
33+
const block = { id: 'nested-block', parentId: 'loop-1' }
34+
const edges = [{ target: 'nested-block' }]
35+
36+
expect(isPositionalTriggerBlock(block, edges)).toBe(false)
37+
})
38+
39+
it('returns false when no block is provided', () => {
40+
expect(isPositionalTriggerBlock(undefined, [])).toBe(false)
41+
})
42+
43+
/**
44+
* Regression: a block copy-pasted into a loop is bound to the subflow
45+
* (parentId set) but has no edges yet. It must not be classified as a
46+
* positional trigger — that classification hid "Remove from Subflow"
47+
* in the block context menu.
48+
*/
49+
it('does not classify a freshly pasted, unconnected block inside a loop as a trigger', () => {
50+
const pastedBlock = { id: 'pasted-cloudwatch', parentId: 'loop-iterate-workflows' }
51+
const edges = [
52+
{ target: 'parse-ids' },
53+
{ target: 'loop-iterate-workflows' },
54+
{ target: 'run-subworkflow' },
55+
{ target: 'check-result' },
56+
{ target: 'publish-success' },
57+
{ target: 'publish-failure' },
58+
]
59+
60+
expect(isPositionalTriggerBlock(pastedBlock, edges)).toBe(false)
61+
})
62+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,26 @@ export function validateTriggerPaste(
8787
return { isValid: true }
8888
}
8989

90+
/**
91+
* Determines whether a block should be treated as a positional trigger — a workflow
92+
* entry point inferred from having no incoming edges.
93+
*
94+
* Blocks nested inside a loop or parallel subflow are never triggers regardless of
95+
* their edges: a block pasted into a subflow starts with no incoming edges but is
96+
* still an ordinary nested block (e.g. it must keep its "Remove from Subflow" action).
97+
*
98+
* @param block - The block to classify (id plus its parent binding, if any)
99+
* @param edges - All workflow edges
100+
* @returns True if the block is a top-level block with no incoming edges
101+
*/
102+
export function isPositionalTriggerBlock(
103+
block: { id: string; parentId?: string } | undefined,
104+
edges: Array<Pick<Edge, 'target'>>
105+
): boolean {
106+
if (!block || block.parentId) return false
107+
return !edges.some((edge) => edge.target === block.id)
108+
}
109+
90110
/**
91111
* Clears drag highlight classes and resets cursor state.
92112
* Used when drag operations end or are cancelled.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
isBlockProtected,
6363
isEdgeProtected,
6464
isInEditableElement,
65+
isPositionalTriggerBlock,
6566
resolveSelectionConflicts,
6667
validateTriggerPaste,
6768
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
@@ -4176,7 +4177,7 @@ const WorkflowContent = React.memo(
41764177
isExecuting={isExecuting}
41774178
isPositionalTrigger={
41784179
contextMenuBlocks.length === 1 &&
4179-
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
4180+
isPositionalTriggerBlock(contextMenuBlocks[0], edges)
41804181
}
41814182
onToggleLocked={handleContextToggleLocked}
41824183
canAdmin={effectivePermissions.canAdmin && !workflowReadOnly}

0 commit comments

Comments
 (0)