fix(workflow): show Remove from Subflow for unconnected blocks pasted into subflows (#4971)

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.
This commit is contained in:
Waleed
2026-06-11 11:17:49 -07:00
committed by GitHub
parent ac2817d1c3
commit d41f0a0ef1
3 changed files with 84 additions and 1 deletions
@@ -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)
})
})
@@ -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<Pick<Edge, 'target'>>
): 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.
@@ -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}