Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
isBlockProtected,
isEdgeProtected,
isInEditableElement,
isPositionalTriggerBlock,
resolveSelectionConflicts,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
Expand Down Expand Up @@ -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}
Expand Down
Loading