diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts index 7a1a26605c..3e0ac99782 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts @@ -19,7 +19,7 @@ import type { IWorkflowTemplate, IWorkflowTemplateNode, } from '@/Interface'; -import { RemoveNodeCommand } from '@/models/history'; +import { RemoveNodeCommand, ReplaceNodeParametersCommand } from '@/models/history'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useUIStore } from '@/stores/ui.store'; import { useHistoryStore } from '@/stores/history.store'; @@ -3105,6 +3105,450 @@ describe('useCanvasOperations', () => { expect(workflowsStore.addToWorkflowMetadata).toHaveBeenCalledWith({ templateId }); }); }); + describe('replaceNodeParameters', () => { + it('should replace node parameters and track history', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + + const nodeId = 'node1'; + const currentParameters = { param1: 'value1' }; + const newParameters = { param1: 'value2' }; + + const node = createTestNode({ + id: nodeId, + type: 'node', + name: 'Node 1', + parameters: currentParameters, + }); + + workflowsStore.getNodeById.mockReturnValue(node); + + const { replaceNodeParameters } = useCanvasOperations(); + replaceNodeParameters(nodeId, currentParameters, newParameters, { trackHistory: true }); + + expect(workflowsStore.setNodeParameters).toHaveBeenCalledWith({ + name: node.name, + value: newParameters, + }); + expect(historyStore.pushCommandToUndo).toHaveBeenCalledWith( + new ReplaceNodeParametersCommand( + nodeId, + currentParameters, + newParameters, + expect.any(Number), + ), + ); + }); + + it('should replace node parameters without tracking history', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + + const nodeId = 'node1'; + const currentParameters = { param1: 'value1' }; + const newParameters = { param1: 'value2' }; + + const node = createTestNode({ + id: nodeId, + type: 'node', + name: 'Node 1', + parameters: currentParameters, + }); + + workflowsStore.getNodeById.mockReturnValue(node); + + const { replaceNodeParameters } = useCanvasOperations(); + replaceNodeParameters(nodeId, currentParameters, newParameters, { trackHistory: false }); + + expect(workflowsStore.setNodeParameters).toHaveBeenCalledWith({ + name: node.name, + value: newParameters, + }); + expect(historyStore.pushCommandToUndo).not.toHaveBeenCalled(); + }); + + it('should not replace parameters if node does not exist', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + + const nodeId = 'nonexistent'; + const currentParameters = { param1: 'value1' }; + const newParameters = { param1: 'value2' }; + + workflowsStore.getNodeById.mockReturnValue(undefined); + + const { replaceNodeParameters } = useCanvasOperations(); + replaceNodeParameters(nodeId, currentParameters, newParameters); + + expect(workflowsStore.setNodeParameters).not.toHaveBeenCalled(); + }); + + it('should handle bulk tracking when replacing parameters for multiple nodes', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + + const nodeId1 = 'node1'; + const nodeId2 = 'node2'; + const currentParameters1 = { param1: 'value1' }; + const newParameters1 = { param1: 'value2' }; + const currentParameters2 = { param2: 'value3' }; + const newParameters2 = { param2: 'value4' }; + + const node1 = createTestNode({ + id: nodeId1, + type: 'node', + name: 'Node 1', + parameters: currentParameters1, + }); + const node2 = createTestNode({ + id: nodeId2, + type: 'node', + name: 'Node 2', + parameters: currentParameters2, + }); + + workflowsStore.getNodeById.mockReturnValueOnce(node1).mockReturnValueOnce(node2); + + const { replaceNodeParameters } = useCanvasOperations(); + replaceNodeParameters(nodeId1, currentParameters1, newParameters1, { + trackHistory: true, + trackBulk: false, + }); + replaceNodeParameters(nodeId2, currentParameters2, newParameters2, { + trackHistory: true, + trackBulk: false, + }); + + expect(historyStore.startRecordingUndo).not.toHaveBeenCalled(); + expect(historyStore.stopRecordingUndo).not.toHaveBeenCalled(); + expect(workflowsStore.setNodeParameters).toHaveBeenCalledTimes(2); + }); + + it('should revert replaced node parameters', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + + const nodeId = 'node1'; + const currentParameters = { param1: 'value1' }; + const newParameters = { param1: 'value2' }; + + const node = createTestNode({ + id: nodeId, + type: 'node', + name: 'Node 1', + parameters: newParameters, + }); + + workflowsStore.getNodeById.mockReturnValue(node); + + const { revertReplaceNodeParameters } = useCanvasOperations(); + await revertReplaceNodeParameters(nodeId, currentParameters, newParameters); + + expect(workflowsStore.setNodeParameters).toHaveBeenCalledWith({ + name: node.name, + value: currentParameters, + }); + }); + }); + describe('replaceNodeConnections', () => { + const sourceNode = createTestNode({ id: 'source', name: 'Source Node' }); + const targetNode = createTestNode({ id: 'target', name: 'Target Node' }); + const replacementNode = createTestNode({ id: 'replacement', name: 'Replacement Node' }); + const nextNode = createTestNode({ id: 'next', name: 'Next Node' }); + + let historyStore: ReturnType>; + let nodeTypesStore: ReturnType>; + let workflowsStore: ReturnType>; + + beforeEach(() => { + historyStore = mockedStore(useHistoryStore); + nodeTypesStore = mockedStore(useNodeTypesStore); + workflowsStore = mockedStore(useWorkflowsStore); + + const nodeTypeDescription = mockNodeTypeDescription({ + inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main], + }); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('common cases', () => { + beforeEach(() => { + workflowsStore.workflow.nodes = [sourceNode, targetNode, replacementNode, nextNode]; + workflowsStore.workflow.connections = { + [sourceNode.name]: { + [NodeConnectionTypes.Main]: [ + [ + { node: targetNode.name, type: NodeConnectionTypes.Main, index: 0 }, + { node: targetNode.name, type: NodeConnectionTypes.Main, index: 1 }, + ], + ], + }, + [targetNode.name]: { + [NodeConnectionTypes.Main]: [ + [{ node: nextNode.name, type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }; + + workflowsStore.getNodeById = vi.fn().mockImplementation((id) => { + if (id === sourceNode.id) return sourceNode; + if (id === targetNode.id) return targetNode; + if (id === replacementNode.id) return replacementNode; + if (id === nextNode.id) return nextNode; + return undefined; + }); + workflowsStore.getNodeByName = vi.fn().mockImplementation((name) => { + if (name === sourceNode.name) return sourceNode; + if (name === targetNode.name) return targetNode; + if (name === replacementNode.name) return replacementNode; + if (name === nextNode.name) return nextNode; + return undefined; + }); + }); + it('should replace connections for a node and track history', () => { + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { replaceNodeConnections } = useCanvasOperations(); + replaceNodeConnections(targetNode.id, replacementNode.id, { trackHistory: true }); + + expect(workflowsStore.removeConnection).toHaveBeenCalledTimes(3); + expect(workflowsStore.removeConnection).toHaveBeenCalledWith({ + connection: [ + { + index: 0, + node: 'Source Node', + type: NodeConnectionTypes.Main, + }, + { + index: 0, + node: 'Target Node', + type: NodeConnectionTypes.Main, + }, + ], + }); + expect(workflowsStore.removeConnection).toHaveBeenCalledWith({ + connection: [ + { + index: 0, + node: 'Source Node', + type: NodeConnectionTypes.Main, + }, + { + index: 1, + node: 'Target Node', + type: NodeConnectionTypes.Main, + }, + ], + }); + expect(workflowsStore.removeConnection).toHaveBeenCalledWith({ + connection: [ + { + index: 0, + node: 'Target Node', + type: NodeConnectionTypes.Main, + }, + { + index: 0, + node: 'Next Node', + type: NodeConnectionTypes.Main, + }, + ], + }); + expect(workflowsStore.addConnection).toHaveBeenCalledTimes(3); + expect(workflowsStore.addConnection).toHaveBeenCalledWith({ + connection: [ + { + index: 0, + node: 'Source Node', + type: 'main', + }, + { + index: 0, + node: 'Replacement Node', + type: 'main', + }, + ], + }); + expect(workflowsStore.addConnection).toHaveBeenCalledWith({ + connection: [ + { + index: 0, + node: 'Source Node', + type: NodeConnectionTypes.Main, + }, + { + index: 1, + node: 'Replacement Node', + type: NodeConnectionTypes.Main, + }, + ], + }); + expect(workflowsStore.addConnection).toHaveBeenCalledWith({ + connection: [ + { + index: 0, + node: 'Replacement Node', + type: NodeConnectionTypes.Main, + }, + { + index: 0, + node: 'Next Node', + type: NodeConnectionTypes.Main, + }, + ], + }); + + expect(historyStore.startRecordingUndo).toHaveBeenCalled(); + expect(historyStore.stopRecordingUndo).toHaveBeenCalled(); + }); + + it('should replace connections without tracking history', () => { + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { replaceNodeConnections } = useCanvasOperations(); + replaceNodeConnections(targetNode.id, replacementNode.id, { trackHistory: false }); + + expect(workflowsStore.removeConnection).toHaveBeenCalled(); + expect(workflowsStore.addConnection).toHaveBeenCalled(); + expect(historyStore.startRecordingUndo).not.toHaveBeenCalled(); + expect(historyStore.stopRecordingUndo).not.toHaveBeenCalled(); + }); + + it('should not replace connections if previous node does not exist', () => { + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { replaceNodeConnections } = useCanvasOperations(); + replaceNodeConnections('nonexistent', replacementNode.id); + + expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); + expect(workflowsStore.addConnection).not.toHaveBeenCalled(); + }); + + it('should not replace connections if new node does not exist', () => { + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { replaceNodeConnections } = useCanvasOperations(); + replaceNodeConnections(targetNode.id, 'nonexistent'); + + expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); + expect(workflowsStore.addConnection).not.toHaveBeenCalled(); + }); + + it('should respect replaceInputs being false', () => { + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { replaceNodeConnections } = useCanvasOperations(); + // nextNode only has an input connection + replaceNodeConnections(nextNode.id, replacementNode.id, { + trackHistory: true, + replaceInputs: false, + }); + + expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); + expect(workflowsStore.addConnection).not.toHaveBeenCalled(); + }); + + it('should respect replaceOutputs being false', () => { + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { replaceNodeConnections } = useCanvasOperations(); + // sourceNode only has an output connection + replaceNodeConnections(sourceNode.id, replacementNode.id, { + trackHistory: true, + replaceOutputs: false, + }); + + expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); + expect(workflowsStore.addConnection).not.toHaveBeenCalled(); + }); + }); + it('should handle bulk tracking when replacing connections for multiple nodes', () => { + const previousNode1 = createTestNode({ + id: 'node1', + name: 'Previous Node 1', + }); + const newNode1 = createTestNode({ + id: 'node2', + name: 'New Node 1', + }); + const previousNode2 = createTestNode({ + id: 'node3', + name: 'Previous Node 2', + }); + const newNode2 = createTestNode({ + id: 'node4', + name: 'New Node 2', + }); + const targetNode = createTestNode({ + id: 'node5', + name: 'Target Node', + }); + + workflowsStore.workflow.nodes = [ + previousNode1, + previousNode2, + newNode1, + newNode2, + targetNode, + ]; + workflowsStore.workflow.connections = { + [previousNode1.name]: { + [NodeConnectionTypes.Main]: [ + [{ node: targetNode.name, type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + [previousNode2.name]: { + [NodeConnectionTypes.Main]: [ + [{ node: targetNode.name, type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }; + + workflowsStore.getNodeById = vi.fn().mockImplementation((id) => { + if (id === previousNode1.id) return previousNode1; + if (id === newNode1.id) return newNode1; + if (id === previousNode2.id) return previousNode1; + if (id === newNode2.id) return newNode2; + if (id === targetNode.id) return targetNode; + + return undefined; + }); + workflowsStore.getNodeByName = vi.fn().mockImplementation((name) => { + if (name === previousNode1.name) return previousNode1; + if (name === newNode1.name) return newNode1; + if (name === previousNode2.name) return previousNode1; + if (name === newNode2.name) return newNode2; + if (name === targetNode.name) return targetNode; + return undefined; + }); + + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { replaceNodeConnections } = useCanvasOperations(); + replaceNodeConnections(previousNode1.id, newNode1.id, { + trackHistory: true, + trackBulk: false, + }); + replaceNodeConnections(previousNode2.id, newNode2.id, { + trackHistory: true, + trackBulk: false, + }); + + expect(historyStore.startRecordingUndo).not.toHaveBeenCalled(); + expect(historyStore.stopRecordingUndo).not.toHaveBeenCalled(); + expect(workflowsStore.removeConnection).toHaveBeenCalledTimes(2); + expect(workflowsStore.addConnection).toHaveBeenCalledTimes(2); + }); + }); }); function buildImportNodes() { diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts index f6f7f07267..d076879dbc 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts @@ -111,6 +111,7 @@ import { chatEventBus } from '@n8n/chat/event-buses'; import { useLogsStore } from '@/stores/logs.store'; import { isChatNode } from '@/utils/aiUtils'; import cloneDeep from 'lodash/cloneDeep'; +import uniq from 'lodash/uniq'; type AddNodeData = Partial & { type: string; @@ -469,11 +470,14 @@ export function useCanvasOperations() { if (!previousNode || !newNode) { return; } - const wf = workflowsStore.getCurrentWorkflow(); - const inputNodeNames = replaceInputs ? wf.getParentNodes(previousNode.name, 'main', 1) : []; - const outputNodeNames = replaceOutputs ? wf.getChildNodes(previousNode.name, 'main', 1) : []; + const inputNodeNames = replaceInputs + ? uniq(wf.getParentNodes(previousNode.name, 'main', 1)) + : []; + const outputNodeNames = replaceOutputs + ? uniq(wf.getChildNodes(previousNode.name, 'main', 1)) + : []; const connectionPairs = [ ...wf.getConnectionsBetweenNodes(inputNodeNames, [previousNode.name]), ...wf.getConnectionsBetweenNodes([previousNode.name], outputNodeNames),