From acaa281c5461757ae703d359be6fd978885c027b Mon Sep 17 00:00:00 2001 From: Daria Date: Mon, 28 Sep 2026 12:57:28 +0000 Subject: [PATCH] feat(editor): Manage the empty-group placeholder lifecycle (no-changelog) (#38704) --- .../composables/useCanvasOperations.test.ts | 219 +++++++++++++++++- .../app/composables/useCanvasOperations.ts | 126 ++++++++-- .../editor-ui/src/app/views/NodeView.test.ts | 130 ++++++++++- .../editor-ui/src/app/views/NodeView.vue | 6 +- .../canvas/components/Canvas.test.ts | 62 ++++- .../workflows/canvas/components/Canvas.vue | 14 +- .../useCanvasNodeGroupSelection.ts | 9 + .../editor/canvas/node-groups.spec.ts | 5 +- 8 files changed, 529 insertions(+), 42 deletions(-) diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts index 81e07d5be67..0c47ab0066c 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts @@ -28,7 +28,9 @@ import type { import type { IWorkflowTemplate, IWorkflowTemplateNode } from '@n8n/rest-api-client/api/templates'; import { AddConnectionCommand, + AddNodeCommand, AddNodeGroupCommand, + BulkCommand, RemoveNodeCommand, RemoveNodeGroupCommand, ReplaceNodeParametersCommand, @@ -69,6 +71,7 @@ import { HTTP_REQUEST_NODE_TYPE, MCP_TRIGGER_NODE_TYPE, MESSAGE_AN_AGENT_NODE_TYPE, + NO_OP_NODE_TYPE, OPEN_AI_CHAT_MODEL_NODE_TYPE, SET_NODE_TYPE, STICKY_NODE_TYPE, @@ -137,6 +140,7 @@ vi.mock('@n8n/rest-api-client/api/workflowHistory', () => ({ import { useCanvasOperations } from '@/app/composables/useCanvasOperations'; import * as workflowHelpersModule from '@/app/composables/useWorkflowHelpers'; +import * as nodeGroupOperationGuards from '@/features/workflows/canvas/composables/useCanvasNodeGroupOperationGuards'; import { DEFAULT_NODE_SIZE, GRID_SIZE, HORIZONTAL_NODE_STEP } from '@/app/utils/nodeViewUtils'; import { AGENT_NODE_SIZE } from '@/features/agents/utils/agentNode'; @@ -1840,11 +1844,11 @@ describe('useCanvasOperations', () => { expect(groupCommand?.after.nodeIds).toEqual(['a', 'c']); }); - it('records a group removal when deleting the last grouped node', () => { + it('records a group removal when deleting the last grouped sticky note', () => { const historyStore = mockedStore(useHistoryStore); vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({}); - const node = createTestNode({ id: 'b', name: 'B' }); + const node = createTestNode({ id: 'b', name: 'B', type: STICKY_NODE_TYPE }); vi.spyOn(workflowDocumentStoreInstance, 'getNodeById').mockReturnValue(node); const group = { id: 'g1', name: 'Group 1', nodeIds: ['b'] }; @@ -1863,6 +1867,203 @@ describe('useCanvasOperations', () => { expect(groupCommand?.group.nodeIds).toEqual(['b']); }); + it('restores a placeholder anchor when deleting the last real group member', () => { + const historyStore = mockedStore(useHistoryStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({}); + + const node = createTestNode({ id: 'b', name: 'B', position: [112, 208] }); + const anchorType = mockNodeTypeDescription({ + name: NO_OP_NODE_TYPE, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + properties: [ + { + displayName: 'Empty Group Anchor', + name: 'emptyGroupAnchor', + type: 'hidden', + default: false, + validateType: undefined, + }, + ], + }); + const nodeType = mockNodeTypeDescription({ + name: node.type, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + }); + nodeTypesStore.nodeTypes = { + [node.type]: { 1: nodeType }, + [NO_OP_NODE_TYPE]: { 1: anchorType }, + }; + nodeTypesStore.getNodeType = vi.fn((type) => + type === NO_OP_NODE_TYPE ? anchorType : type === node.type ? nodeType : null, + ); + + const group = { id: 'g1', name: 'Group 1', nodeIds: [node.id] }; + const nodesById = new Map([[node.id, node]]); + workflowDocumentStoreInstance.connectionsBySourceNode = {}; + vi.spyOn(workflowDocumentStoreInstance, 'getNodeById').mockImplementation((id) => + nodesById.get(id), + ); + vi.spyOn(workflowDocumentStoreInstance, 'getNodeByName').mockImplementation( + (name) => [...nodesById.values()].find((candidate) => candidate.name === name) ?? null, + ); + vi.spyOn(workflowDocumentStoreInstance, 'getGroupForNode').mockImplementation((id) => + group.nodeIds.includes(id) ? group : undefined, + ); + vi.spyOn(workflowDocumentStoreInstance, 'getGroupById').mockReturnValue(group); + vi.spyOn(workflowDocumentStoreInstance, 'replaceNodeInGroup').mockImplementation( + (_groupId, previousNodeId, newNodeId) => { + group.nodeIds = group.nodeIds.map((id) => (id === previousNodeId ? newNodeId : id)); + }, + ); + vi.spyOn(workflowDocumentStoreInstance, 'getParentNodes').mockReturnValue([]); + vi.spyOn(workflowDocumentStoreInstance, 'getChildNodes').mockReturnValue([]); + vi.spyOn(workflowDocumentStoreInstance, 'getConnectionsBetweenNodes').mockReturnValue([]); + vi.spyOn(workflowDocumentStoreInstance, 'addNode').mockImplementation((addedNode) => { + nodesById.set(addedNode.id, addedNode); + }); + vi.spyOn(workflowDocumentStoreInstance, 'removeNodeById').mockImplementation((id) => { + nodesById.delete(id); + }); + + const { deleteNode } = useCanvasOperations(); + deleteNode(node.id, { trackHistory: true }); + + expect(workflowDocumentStoreInstance.addNode).toHaveBeenCalled(); + expect(workflowDocumentStoreInstance.replaceNodeInGroup).toHaveBeenCalled(); + + const anchor = [...nodesById.values()].find( + (candidate) => candidate.parameters?.emptyGroupAnchor === true, + ); + expect(anchor).toMatchObject({ + type: NO_OP_NODE_TYPE, + position: node.position, + parameters: { emptyGroupAnchor: true }, + placeholder: true, + }); + expect(group.nodeIds).toEqual([anchor?.id]); + expect(nodesById.has(node.id)).toBe(false); + expect(historyStore.pushCommandToUndo).toHaveBeenCalled(); + }); + + it('does not restore an anchor when deleting the selected group', () => { + const historyStore = mockedStore(useHistoryStore); + vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({}); + + const node = createTestNode({ id: 'b', name: 'B' }); + const group = { id: 'g1', name: 'Group 1', nodeIds: [node.id] }; + const nodesById = new Map([[node.id, node]]); + vi.spyOn(workflowDocumentStoreInstance, 'getNodeById').mockImplementation((id) => + nodesById.get(id), + ); + vi.spyOn(workflowDocumentStoreInstance, 'getGroupForNode').mockImplementation((id) => + group.nodeIds.includes(id) ? group : undefined, + ); + vi.spyOn(workflowDocumentStoreInstance, 'getGroupById').mockReturnValue(undefined); + vi.spyOn(workflowDocumentStoreInstance, 'removeNodeById').mockImplementation((id) => { + nodesById.delete(id); + }); + + const { deleteNodes } = useCanvasOperations(); + deleteNodes([node.id], { trackHistory: true, preserveEmptyGroupAnchor: false }); + + expect(workflowDocumentStoreInstance.addNode).not.toHaveBeenCalled(); + expect(workflowDocumentStoreInstance.removeNodeById).toHaveBeenCalledWith(node.id); + expect(nodesById.has(node.id)).toBe(false); + expect(historyStore.pushCommandToUndo).toHaveBeenCalled(); + }); + + it('does not leave an anchor add command when replacement is rejected', () => { + const historyStore = mockedStore(useHistoryStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({}); + historyStore.currentBulkAction = new BulkCommand([]); + historyStore.pushCommandToUndo.mockImplementation((command) => { + historyStore.currentBulkAction?.commands.push(command); + }); + + const node = createTestNode({ id: 'b', name: 'B', position: [112, 208] }); + const anchorType = mockNodeTypeDescription({ + name: NO_OP_NODE_TYPE, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + }); + nodeTypesStore.nodeTypes = { [NO_OP_NODE_TYPE]: { 1: anchorType } }; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(anchorType); + + const group = { id: 'g1', name: 'Group 1', nodeIds: [node.id] }; + const nodesById = new Map([[node.id, node]]); + vi.spyOn(workflowDocumentStoreInstance, 'getNodeById').mockImplementation((id) => + nodesById.get(id), + ); + vi.spyOn(workflowDocumentStoreInstance, 'getGroupForNode').mockImplementation((id) => + group.nodeIds.includes(id) ? group : undefined, + ); + vi.spyOn(workflowDocumentStoreInstance, 'getGroupById').mockReturnValue(undefined); + vi.spyOn(workflowDocumentStoreInstance, 'addNode').mockImplementation((addedNode) => { + nodesById.set(addedNode.id, addedNode); + }); + vi.spyOn(workflowDocumentStoreInstance, 'removeNodeById').mockImplementation((id) => { + nodesById.delete(id); + }); + + const replacementAllowedSpy = vi.fn().mockReturnValue(false); + vi.spyOn(nodeGroupOperationGuards, 'useCanvasNodeGroupOperationGuards').mockReturnValue({ + isConnectionRemovalAllowedForNodeGroups: vi.fn().mockReturnValue(true), + isConnectionReplacementAllowedForNodeGroups: vi + .fn() + .mockReturnValue({ outcome: 'proceed' }), + isNodeReplacementAllowedForNodeGroups: replacementAllowedSpy, + applyNodeGroupAutoExtend: vi.fn(), + }); + + const { deleteNode } = useCanvasOperations(); + deleteNode(node.id, { trackHistory: true }); + + expect(replacementAllowedSpy).toHaveBeenCalled(); + expect(workflowDocumentStoreInstance.removeNodeById).toHaveBeenCalledWith(node.id); + const commands = historyStore.currentBulkAction?.commands ?? []; + expect(commands.some((command) => command instanceof AddNodeCommand)).toBe(false); + }); + + it('deletes an existing empty-group anchor without restoring another anchor', () => { + const historyStore = mockedStore(useHistoryStore); + vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({}); + + const anchor = createTestNode({ + id: 'anchor', + name: 'No Operation, do nothing', + type: NO_OP_NODE_TYPE, + parameters: { emptyGroupAnchor: true }, + }); + const group = { id: 'g1', name: 'Group 1', nodeIds: [anchor.id] }; + const nodesById = new Map([[anchor.id, anchor]]); + vi.spyOn(workflowDocumentStoreInstance, 'getNodeById').mockImplementation((id) => + nodesById.get(id), + ); + vi.spyOn(workflowDocumentStoreInstance, 'getGroupForNode').mockImplementation((id) => + group.nodeIds.includes(id) ? group : undefined, + ); + vi.spyOn(workflowDocumentStoreInstance, 'getGroupById').mockReturnValue(undefined); + vi.spyOn(workflowDocumentStoreInstance, 'removeNodeById').mockImplementation((id) => { + nodesById.delete(id); + }); + + const { deleteNode } = useCanvasOperations(); + deleteNode(anchor.id, { trackHistory: true }); + + expect(workflowDocumentStoreInstance.addNode).not.toHaveBeenCalled(); + expect(workflowDocumentStoreInstance.replaceNodeInGroup).not.toHaveBeenCalled(); + expect(workflowDocumentStoreInstance.removeNodeById).toHaveBeenCalledWith(anchor.id); + expect( + historyStore.pushCommandToUndo.mock.calls.some( + ([command]) => command instanceof RemoveNodeGroupCommand, + ), + ).toBe(true); + }); + it('does not record any group command when the deleted node is ungrouped', () => { const historyStore = mockedStore(useHistoryStore); vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({}); @@ -7652,11 +7853,11 @@ describe('useCanvasOperations', () => { }); }); describe('node group validation', () => { - it('should replace a grouped node against the final graph and transfer group membership', () => { + it('should replace the sole group anchor and transfer all group connections', () => { const toast = useToast(); const group = { id: 'group', - nodeIds: [sourceNode.id, targetNode.id, nextNode.id], + nodeIds: [targetNode.id], name: 'Group 1', }; const connections: IConnections = { @@ -7757,7 +7958,7 @@ describe('useCanvasOperations', () => { targetNode.id, replacementNode.id, ); - expect(group.nodeIds).toEqual([sourceNode.id, replacementNode.id, nextNode.id]); + expect(group.nodeIds).toEqual([replacementNode.id]); const groupCommand = historyStore.pushCommandToUndo.mock.calls .map(([command]) => command) @@ -7765,12 +7966,8 @@ describe('useCanvasOperations', () => { | UpdateNodeGroupCommand | undefined; expect(groupCommand).toBeInstanceOf(UpdateNodeGroupCommand); - expect(groupCommand?.before.nodeIds).toEqual([sourceNode.id, targetNode.id, nextNode.id]); - expect(groupCommand?.after.nodeIds).toEqual([ - sourceNode.id, - replacementNode.id, - nextNode.id, - ]); + expect(groupCommand?.before.nodeIds).toEqual([targetNode.id]); + expect(groupCommand?.after.nodeIds).toEqual([replacementNode.id]); expect(workflowDocumentStoreInstance.removeConnection).toHaveBeenCalledTimes(2); expectConnectionRemoved(sourceNode, targetNode); diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts index 0686abaec48..283b7c4c19a 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts @@ -42,6 +42,7 @@ import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE, MESSAGE_AN_AGENT_NODE_TYPE, + NO_OP_NODE_TYPE, STICKY_NODE_TYPE, UPDATE_WEBHOOK_ID_NODE_TYPES, VIEWS, @@ -139,6 +140,7 @@ import { NodeHelpers, TelemetryHelpers, isCommunityPackageName, + isEmptyGroupAnchor, isHitlToolType, isResourceLocatorValue, } from 'n8n-workflow'; @@ -636,7 +638,10 @@ export function useCanvasOperations() { } } - function deleteNode(id: string, { trackHistory = false, trackBulk = true } = {}) { + function deleteNode( + id: string, + { trackHistory = false, trackBulk = true, preserveEmptyGroupAnchor = true } = {}, + ) { const node = workflowDocumentStore.value.getNodeById(id); if (!node) { return; @@ -652,6 +657,53 @@ export function useCanvasOperations() { uiStore.lastInteractedWithNodeId = undefined; } + const group = workflowDocumentStore.value.getGroupForNode(id); + const shouldRestoreEmptyGroupAnchor = + preserveEmptyGroupAnchor && + group?.nodeIds.length === 1 && + node.type !== STICKY_NODE_TYPE && + !isEmptyGroupAnchor(node); + + if (shouldRestoreEmptyGroupAnchor) { + const anchorNodeType = requireNodeTypeDescription(NO_OP_NODE_TYPE); + const anchorHistoryIndex = trackHistory + ? historyStore.currentBulkAction?.commands.length + : undefined; + const anchor = addNode( + { + type: NO_OP_NODE_TYPE, + typeVersion: resolveNodeVersion(anchorNodeType), + position: [...node.position], + parameters: { emptyGroupAnchor: true }, + placeholder: true, + }, + anchorNodeType, + { + forcePosition: true, + isAutoAdd: true, + openNDV: false, + trackHistory, + }, + ); + const didReplace = replaceNode(id, anchor.id, { trackHistory, trackBulk: false }); + if (didReplace) { + if (trackHistory && trackBulk) { + historyStore.stopRecordingUndo(); + } + return; + } + + // If the replacement is rejected by node-group connection policy, leave + // the original deletion path to remove the node and its group. + if (anchorHistoryIndex !== undefined) { + const anchorCommand = historyStore.currentBulkAction?.commands[anchorHistoryIndex]; + if (anchorCommand instanceof AddNodeCommand && anchorCommand.node.id === anchor.id) { + historyStore.currentBulkAction?.commands.splice(anchorHistoryIndex, 1); + } + } + workflowDocumentStore.value.removeNodeById(anchor.id); + } + connectAdjacentNodes(id, { trackHistory, validateNodeGroups: false }); deleteConnectionsByNodeId(id, { trackHistory, trackBulk: false }); @@ -700,12 +752,21 @@ export function useCanvasOperations() { trackDeleteNode(id); } - function deleteNodes(ids: string[], { trackHistory = true, trackBulk = true } = {}) { + function deleteNodes( + ids: string[], + { trackHistory = true, trackBulk = true, preserveEmptyGroupAnchor = true } = {}, + ) { if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } - ids.forEach((id) => deleteNode(id, { trackHistory, trackBulk: false })); + ids.forEach((id) => + deleteNode(id, { + trackHistory, + trackBulk: false, + preserveEmptyGroupAnchor, + }), + ); if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); @@ -3642,7 +3703,7 @@ export function useCanvasOperations() { const previousNode = workflowDocumentStore.value.getNodeById(previousId); const newNode = workflowDocumentStore.value.getNodeById(newId); - if (!previousNode || !newNode) return; + if (!previousNode || !newNode) return false; if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); @@ -3662,7 +3723,7 @@ export function useCanvasOperations() { if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } - return; + return false; } moveNewNodeToPreviousPosition(); } else { @@ -3680,6 +3741,8 @@ export function useCanvasOperations() { if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } + + return true; } async function addNodesAndConnections( @@ -3706,6 +3769,24 @@ export function useCanvasOperations() { telemetry: true, }); + let replacementGroupId: string | undefined; + if (addedNodes.length > 0 && options.replaceNodeId) { + // Auto-added helpers can follow the node that the user selected, so they + // must not become the replacement target. + const replacementNodeIndex = nodes.findLastIndex((node) => !node.isAutoAdd); + const replacementNode = + replacementNodeIndex === -1 ? addedNodes.at(-1) : addedNodes[replacementNodeIndex]; + if (replacementNode) { + const didReplace = replaceNode(options.replaceNodeId, replacementNode.id, { + trackHistory, + trackBulk: false, + }); + if (didReplace) { + replacementGroupId = workflowDocumentStore.value.getGroupForNode(replacementNode.id)?.id; + } + } + } + const allNodes = workflowDocumentStore.value.allNodes; const offsetIndex = allNodes.length - nodes.length; const connections: CanvasConnectionCreateData[] = addedConnections.map(({ from, to }) => { @@ -3741,16 +3822,35 @@ export function useCanvasOperations() { await addConnections(connections, { trackHistory, trackBulk: false }); - uiStore.resetLastInteractedWith(); - - if (addedNodes.length > 0 && options.replaceNodeId) { - const lastAddedNodeId = addedNodes[addedNodes.length - 1].id; - replaceNode(options.replaceNodeId, lastAddedNodeId, { - trackHistory, - trackBulk: false, - }); + if (replacementGroupId) { + const groupBeforeExtend = workflowDocumentStore.value.getGroupById(replacementGroupId); + if (groupBeforeExtend) { + // A creator result can include helpers for the selected node. Keep the + // connected batch together when it replaces a grouped node. + const beforeSnapshot = { ...groupBeforeExtend, nodeIds: [...groupBeforeExtend.nodeIds] }; + workflowDocumentStore.value.addNodesToGroup( + replacementGroupId, + addedNodes.map((node) => node.id), + ); + const groupAfterExtend = workflowDocumentStore.value.getGroupById(replacementGroupId); + if ( + trackHistory && + groupAfterExtend && + groupAfterExtend.nodeIds.length !== beforeSnapshot.nodeIds.length + ) { + historyStore.pushCommandToUndo( + new UpdateNodeGroupCommand( + beforeSnapshot, + { ...groupAfterExtend, nodeIds: [...groupAfterExtend.nodeIds] }, + Date.now(), + ), + ); + } + } } + uiStore.resetLastInteractedWith(); + if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } diff --git a/packages/frontend/editor-ui/src/app/views/NodeView.test.ts b/packages/frontend/editor-ui/src/app/views/NodeView.test.ts index 82cb5de4e90..7c2bc9ee1bd 100644 --- a/packages/frontend/editor-ui/src/app/views/NodeView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/NodeView.test.ts @@ -1,7 +1,12 @@ import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks'; +import type { AddedNodesAndConnections } from '@/Interface'; import { waitFor } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; -import { EVALUATION_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from 'n8n-workflow'; +import { + EVALUATION_TRIGGER_NODE_TYPE, + MANUAL_TRIGGER_NODE_TYPE, + NodeConnectionTypes, +} from 'n8n-workflow'; import { createWorkflowDocumentId, useWorkflowDocumentStore, @@ -14,9 +19,9 @@ import { useNodeTypesStore } from '../stores/nodeTypes.store'; import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store'; import { renderComponent } from '@/__tests__/render'; import NodeView from './NodeView.vue'; -import { NO_OP_NODE_TYPE, VIEWS } from '../constants'; +import { NO_OP_NODE_TYPE, SPLIT_IN_BATCHES_NODE_TYPE, VIEWS } from '../constants'; import { WorkflowIdKey, WorkflowDocumentStoreKey } from '../constants/injectionKeys'; -import { computed, defineComponent, shallowRef } from 'vue'; +import { computed, defineComponent, nextTick, shallowRef } from 'vue'; import { nodeViewEventBus } from '@/app/event-bus'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import type { Project } from '@/features/collaboration/projects/projects.types'; @@ -62,6 +67,22 @@ vi.mock('@/features/ndv/shared/views/NodeDetailsView.vue', () => ({ })); describe('NodeView', () => { + const loopReplacementBatch: AddedNodesAndConnections = { + nodes: [ + { type: SPLIT_IN_BATCHES_NODE_TYPE, name: 'Loop Over Items' }, + { + type: NO_OP_NODE_TYPE, + name: 'Replace Me', + isAutoAdd: true, + placeholder: true, + }, + ], + connections: [ + { from: { nodeIndex: 0, outputIndex: 1 }, to: { nodeIndex: 1 } }, + { from: { nodeIndex: 1 }, to: { nodeIndex: 0 } }, + ], + }; + let workflowsStore: ReturnType; let workflowDocumentStore: ReturnType; let ensureNodesAreVisible: ReturnType; @@ -101,7 +122,12 @@ describe('NodeView', () => { stubs: { // Component stubs avoid loading async subtrees that can outlive the test environment. LazyNodeCreation: defineComponent({ - emits: ['addEmptyGroup'], + emits: ['addEmptyGroup', 'addNodes'], + setup(_, { emit }) { + return { + addLoopReplacement: () => emit('addNodes', loopReplacementBatch), + }; + }, template: `<>