diff --git a/cypress/e2e/48-subworkflow-inputs.cy.ts b/cypress/e2e/48-subworkflow-inputs.cy.ts index 716d253b8c..bde27c9770 100644 --- a/cypress/e2e/48-subworkflow-inputs.cy.ts +++ b/cypress/e2e/48-subworkflow-inputs.cy.ts @@ -55,13 +55,16 @@ describe('Sub-workflow creation and typed usage', () => { openNode('Execute Workflow'); + let openedUrl = ''; + // Prevent sub-workflow from opening in new window cy.window().then((win) => { cy.stub(win, 'open').callsFake((url) => { - cy.visit(url); + openedUrl = url; }); }); selectResourceLocatorItem('workflowId', 0, 'Create a'); + cy.then(() => cy.visit(openedUrl)); // ************************** // NAVIGATE TO CHILD WORKFLOW // ************************** diff --git a/packages/frontend/editor-ui/src/components/ResourceMapper/ResourceMapper.vue b/packages/frontend/editor-ui/src/components/ResourceMapper/ResourceMapper.vue index e4f4caed3f..eae8c2ae0c 100644 --- a/packages/frontend/editor-ui/src/components/ResourceMapper/ResourceMapper.vue +++ b/packages/frontend/editor-ui/src/components/ResourceMapper/ResourceMapper.vue @@ -13,7 +13,7 @@ import type { ResourceMapperFields, ResourceMapperValue, } from 'n8n-workflow'; -import { NodeHelpers } from 'n8n-workflow'; +import { deepCopy, NodeHelpers } from 'n8n-workflow'; import { computed, onMounted, reactive, watch } from 'vue'; import MappingModeSelect from './MappingModeSelect.vue'; import MatchingColumnsSelect from './MatchingColumnsSelect.vue'; @@ -537,7 +537,9 @@ function emitValueChanged(): void { pruneParamValues(); emit('valueChanged', { name: `${props.path}`, - value: state.paramValue, + // deepCopy ensures that mutations to state.paramValue that occur in + // this component are never visible to the store without explicit event emits + value: deepCopy(state.paramValue), node: props.node?.name, }); updateNodeIssues(); diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts index edd8273a18..584fac988d 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts @@ -31,6 +31,7 @@ import * as apiUtils from '@/utils/apiUtils'; import { useSettingsStore } from '@/stores/settings.store'; import { useLocalStorage } from '@vueuse/core'; import { ref } from 'vue'; +import { createTestNode } from '@/__tests__/mocks'; vi.mock('@/stores/ndv.store', () => ({ useNDVStore: vi.fn(() => ({ @@ -1041,6 +1042,36 @@ describe('useWorkflowsStore', () => { }); }); + describe('setNodeParameters', () => { + beforeEach(() => { + workflowsStore.setNodes([createTestNode({ name: 'a', parameters: { p: 1, q: true } })]); + }); + + it('should set node parameters', () => { + expect(workflowsStore.nodesByName.a.parameters).toEqual({ p: 1, q: true }); + + workflowsStore.setNodeParameters({ name: 'a', value: { q: false, r: 's' } }); + + expect(workflowsStore.nodesByName.a.parameters).toEqual({ q: false, r: 's' }); + }); + + it('should set node parameters preserving existing ones if append=true', () => { + expect(workflowsStore.nodesByName.a.parameters).toEqual({ p: 1, q: true }); + + workflowsStore.setNodeParameters({ name: 'a', value: { q: false, r: 's' } }, true); + + expect(workflowsStore.nodesByName.a.parameters).toEqual({ p: 1, q: false, r: 's' }); + }); + + it('should not update last parameter update time if parameters are set to the same value', () => { + expect(workflowsStore.getParametersLastUpdate('a')).toEqual(undefined); + + workflowsStore.setNodeParameters({ name: 'a', value: { p: 1, q: true } }); + + expect(workflowsStore.getParametersLastUpdate('a')).toEqual(undefined); + }); + }); + describe('renameNodeSelectedAndExecution', () => { it('should rename node and update execution data', () => { const nodeName = 'Rename me'; diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts index 3ae30e29ea..87a4ffc33f 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts @@ -1290,12 +1290,12 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { return false; } - function setNodeIssue(nodeIssueData: INodeIssueData): boolean { + function setNodeIssue(nodeIssueData: INodeIssueData): void { const nodeIndex = workflow.value.nodes.findIndex((node) => { return node.name === nodeIssueData.node; }); if (nodeIndex === -1) { - return false; + return; } const node = workflow.value.nodes[nodeIndex]; @@ -1304,7 +1304,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { // Remove the value if one exists if (node.issues?.[nodeIssueData.type] === undefined) { // No values for type exist so nothing has to get removed - return true; + return; } const { [nodeIssueData.type]: removedNodeIssue, ...remainingNodeIssues } = node.issues; @@ -1319,7 +1319,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { }, }); } - return true; } function addNode(nodeData: INodeUi): void { @@ -1390,12 +1389,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { if (nodeIndex !== -1) { for (const key of Object.keys(updateInformation.properties)) { - uiStore.stateIsDirty = true; - const typedKey = key as keyof INodeUpdatePropertiesInformation['properties']; const property = updateInformation.properties[typedKey]; - updateNodeAtIndex(nodeIndex, { [key]: property }); + const changed = updateNodeAtIndex(nodeIndex, { [key]: property }); + + if (changed) { + uiStore.stateIsDirty = true; + } } } } @@ -1420,7 +1421,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { const excludeKeys = ['position', 'notes', 'notesInFlow']; - if (!excludeKeys.includes(updateInformation.key)) { + if (changed && !excludeKeys.includes(updateInformation.key)) { nodeMetadata.value[workflow.value.nodes[nodeIndex].name].parametersLastUpdatedAt = Date.now(); } } @@ -1439,17 +1440,19 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { const node = workflow.value.nodes[nodeIndex]; - uiStore.stateIsDirty = true; const newParameters = !!append && isObject(updateInformation.value) ? { ...node.parameters, ...updateInformation.value } : updateInformation.value; - updateNodeAtIndex(nodeIndex, { + const changed = updateNodeAtIndex(nodeIndex, { parameters: newParameters as INodeParameters, }); - nodeMetadata.value[node.name].parametersLastUpdatedAt = Date.now(); + if (changed) { + uiStore.stateIsDirty = true; + nodeMetadata.value[node.name].parametersLastUpdatedAt = Date.now(); + } } function setLastNodeParameters(updateInformation: IUpdateInformation): void {