mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+62
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user