From e8a8cd38290c6dd56ac1140379c71406fba76f9f Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 11 Jun 2026 10:59:03 -0700 Subject: [PATCH] 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. --- .../utils/workflow-canvas-helpers.test.ts | 62 +++++++++++++++++++ .../utils/workflow-canvas-helpers.ts | 20 ++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 3 +- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts new file mode 100644 index 00000000000..30f9edd2cb0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isPositionalTriggerBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers' + +describe('isPositionalTriggerBlock', () => { + it('returns true for a top-level block with no incoming edges', () => { + const block = { id: 'block-1' } + const edges = [{ target: 'other-block' }] + + expect(isPositionalTriggerBlock(block, edges)).toBe(true) + }) + + it('returns true for a top-level block when there are no edges at all', () => { + expect(isPositionalTriggerBlock({ id: 'block-1' }, [])).toBe(true) + }) + + it('returns false for a top-level block with incoming edges', () => { + const block = { id: 'block-1' } + const edges = [{ target: 'block-1' }] + + expect(isPositionalTriggerBlock(block, edges)).toBe(false) + }) + + it('returns false for a block nested in a subflow even with no incoming edges', () => { + const block = { id: 'nested-block', parentId: 'loop-1' } + + expect(isPositionalTriggerBlock(block, [])).toBe(false) + }) + + it('returns false for a nested block with incoming edges', () => { + const block = { id: 'nested-block', parentId: 'loop-1' } + const edges = [{ target: 'nested-block' }] + + expect(isPositionalTriggerBlock(block, edges)).toBe(false) + }) + + it('returns false when no block is provided', () => { + expect(isPositionalTriggerBlock(undefined, [])).toBe(false) + }) + + /** + * Regression: a block copy-pasted into a loop is bound to the subflow + * (parentId set) but has no edges yet. It must not be classified as a + * positional trigger — that classification hid "Remove from Subflow" + * in the block context menu. + */ + it('does not classify a freshly pasted, unconnected block inside a loop as a trigger', () => { + const pastedBlock = { id: 'pasted-cloudwatch', parentId: 'loop-iterate-workflows' } + const edges = [ + { target: 'parse-ids' }, + { target: 'loop-iterate-workflows' }, + { target: 'run-subworkflow' }, + { target: 'check-result' }, + { target: 'publish-success' }, + { target: 'publish-failure' }, + ] + + expect(isPositionalTriggerBlock(pastedBlock, edges)).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 0bd13f7001c..18b3c4ec186 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -87,6 +87,26 @@ export function validateTriggerPaste( return { isValid: true } } +/** + * Determines whether a block should be treated as a positional trigger — a workflow + * entry point inferred from having no incoming edges. + * + * Blocks nested inside a loop or parallel subflow are never triggers regardless of + * their edges: a block pasted into a subflow starts with no incoming edges but is + * still an ordinary nested block (e.g. it must keep its "Remove from Subflow" action). + * + * @param block - The block to classify (id plus its parent binding, if any) + * @param edges - All workflow edges + * @returns True if the block is a top-level block with no incoming edges + */ +export function isPositionalTriggerBlock( + block: { id: string; parentId?: string } | undefined, + edges: Array> +): boolean { + if (!block || block.parentId) return false + return !edges.some((edge) => edge.target === block.id) +} + /** * Clears drag highlight classes and resets cursor state. * Used when drag operations end or are cancelled. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index ee4b43ae031..9f38b879e0d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -62,6 +62,7 @@ import { isBlockProtected, isEdgeProtected, isInEditableElement, + isPositionalTriggerBlock, resolveSelectionConflicts, validateTriggerPaste, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' @@ -4176,7 +4177,7 @@ const WorkflowContent = React.memo( isExecuting={isExecuting} isPositionalTrigger={ contextMenuBlocks.length === 1 && - edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0 + isPositionalTriggerBlock(contextMenuBlocks[0], edges) } onToggleLocked={handleContextToggleLocked} canAdmin={effectivePermissions.canAdmin && !workflowReadOnly}