diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index c181b233c21..a558b31769b 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -60,6 +60,7 @@ import { N8nText, type IMenuItem, } from '@n8n/design-system'; +import { injectWorkflowState } from '@/composables/useWorkflowState'; type Props = { modalName: string; @@ -74,6 +75,7 @@ const ndvStore = useNDVStore(); const settingsStore = useSettingsStore(); const uiStore = useUIStore(); const workflowsStore = useWorkflowsStore(); +const workflowState = injectWorkflowState(); const nodeTypesStore = useNodeTypesStore(); const projectsStore = useProjectsStore(); @@ -1040,7 +1042,7 @@ async function onAuthTypeChanged(type: string): Promise { uiStore.activeCredentialType = credentialsForType.name; resetCredentialData(); // Update current node auth type so credentials dropdown can be displayed properly - updateNodeAuthType(ndvStore.activeNode, type); + updateNodeAuthType(workflowState, ndvStore.activeNode, type); // Also update credential name but only if the default name is still used if (hasUnsavedChanges.value && !hasUserSpecifiedName.value) { const newDefaultName = await credentialsStore.getNewCredentialName({ diff --git a/packages/frontend/editor-ui/src/components/NodeCredentials.vue b/packages/frontend/editor-ui/src/components/NodeCredentials.vue index 89976d4c90f..84c82c8ac77 100644 --- a/packages/frontend/editor-ui/src/components/NodeCredentials.vue +++ b/packages/frontend/editor-ui/src/components/NodeCredentials.vue @@ -39,6 +39,7 @@ import { N8nText, N8nTooltip, } from '@n8n/design-system'; +import { injectWorkflowState } from '@/composables/useWorkflowState'; type Props = { node: INodeUi; overrideCredType?: NodeParameterValueType; @@ -69,6 +70,7 @@ const nodeTypesStore = useNodeTypesStore(); const ndvStore = useNDVStore(); const uiStore = useUIStore(); const workflowsStore = useWorkflowsStore(); +const workflowState = injectWorkflowState(); const nodeHelpers = useNodeHelpers(); const toast = useToast(); @@ -348,7 +350,7 @@ function onCredentialSelected( ); const authOption = getAuthTypeForNodeCredential(nodeType.value, nodeCredentialDescription); if (authOption) { - updateNodeAuthType(props.node, authOption.value); + updateNodeAuthType(workflowState, props.node, authOption.value); const parameterData = { name: `parameters.${mainNodeAuthField.value.name}`, value: authOption.value, diff --git a/packages/frontend/editor-ui/src/components/NodeSettings.vue b/packages/frontend/editor-ui/src/components/NodeSettings.vue index 7fce83a0ac1..3f1a044bab2 100644 --- a/packages/frontend/editor-ui/src/components/NodeSettings.vue +++ b/packages/frontend/editor-ui/src/components/NodeSettings.vue @@ -412,7 +412,7 @@ const valueChanged = (parameterData: IUpdateInformation) => { value: newValue, }; - workflowsStore.setNodeValue(updateInformation); + workflowState.setNodeValue(updateInformation); } }; @@ -468,7 +468,7 @@ const onNodeExecute = () => { const credentialSelected = (updateInformation: INodeUpdatePropertiesInformation) => { // Update the values on the node - workflowsStore.updateNodeProperties(updateInformation); + workflowState.updateNodeProperties(updateInformation); const node = workflowsStore.getNodeByName(updateInformation.name); diff --git a/packages/frontend/editor-ui/src/components/ParameterInput.vue b/packages/frontend/editor-ui/src/components/ParameterInput.vue index 3b6041cc065..9a7e7efc98b 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInput.vue +++ b/packages/frontend/editor-ui/src/components/ParameterInput.vue @@ -98,6 +98,7 @@ import { getParameterDisplayableOptions } from '@/utils/nodes/nodeTransforms'; import { ElColorPicker, ElDatePicker, ElDialog, ElSwitch } from 'element-plus'; import { N8nIcon, N8nInput, N8nInputNumber, N8nOption, N8nSelect } from '@n8n/design-system'; +import { injectWorkflowState } from '@/composables/useWorkflowState'; type Picker = { $emit: (arg0: string, arg1: Date) => void }; type Props = { @@ -157,6 +158,7 @@ const telemetry = useTelemetry(); const credentialsStore = useCredentialsStore(); const ndvStore = useNDVStore(); const workflowsStore = useWorkflowsStore(); +const workflowState = injectWorkflowState(); const settingsStore = useSettingsStore(); const nodeTypesStore = useNodeTypesStore(); const uiStore = useUIStore(); @@ -645,7 +647,7 @@ function isRemoteParameterOption(option: INodePropertyOptions) { function credentialSelected(updateInformation: INodeUpdatePropertiesInformation) { // Update the values on the node - workflowsStore.updateNodeProperties(updateInformation); + workflowState.updateNodeProperties(updateInformation); const updateNode = workflowsStore.getNodeByName(updateInformation.name); diff --git a/packages/frontend/editor-ui/src/components/RunData.vue b/packages/frontend/editor-ui/src/components/RunData.vue index b90df6d2386..eb3281c2a6a 100644 --- a/packages/frontend/editor-ui/src/components/RunData.vue +++ b/packages/frontend/editor-ui/src/components/RunData.vue @@ -91,6 +91,7 @@ import { N8nText, N8nTooltip, } from '@n8n/design-system'; +import { injectWorkflowState } from '@/composables/useWorkflowState'; const LazyRunDataTable = defineAsyncComponent( async () => await import('@/components/RunDataTable.vue'), ); @@ -231,6 +232,7 @@ const dataContainerRef = ref(); const nodeTypesStore = useNodeTypesStore(); const ndvStore = useNDVStore(); const workflowsStore = useWorkflowsStore(); +const workflowState = injectWorkflowState(); const sourceControlStore = useSourceControlStore(); const rootStore = useRootStore(); const schemaPreviewStore = useSchemaPreviewStore(); @@ -1344,7 +1346,7 @@ function enableNode() { }, }; - workflowsStore.updateNodeProperties(updateInformation); + workflowState.updateNodeProperties(updateInformation); } } diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts index f1f7f65e726..39b210cc793 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts @@ -639,7 +639,7 @@ describe('useCanvasOperations', () => { { id: 'node1', position: { x: 96, y: 96 } }, { id: 'node2', position: { x: 208, y: 208 } }, ]; - const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); + const setNodePositionByIdSpy = vi.spyOn(workflowState, 'setNodePositionById'); workflowsStore.getNodeById .mockReturnValueOnce( createTestNode({ @@ -713,7 +713,7 @@ describe('useCanvasOperations', () => { boundingBox: { height: 96, width: 96, x: 0, y: 0 }, }, }; - const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); + const setNodePositionByIdSpy = vi.spyOn(workflowState, 'setNodePositionById'); workflowsStore.getNodeById .mockReturnValueOnce( createTestNode({ @@ -836,13 +836,14 @@ describe('useCanvasOperations', () => { position: [0, 0], name: 'Node 1', }); + const setNodePositionByIdSpy = vi.spyOn(workflowState, 'setNodePositionById'); workflowsStore.getNodeById.mockReturnValueOnce(node); const { updateNodePosition } = useCanvasOperations(); updateNodePosition(id, position); - expect(workflowsStore.setNodePositionById).toHaveBeenCalledWith(id, [position.x, position.y]); + expect(setNodePositionByIdSpy).toHaveBeenCalledWith(id, [position.x, position.y]); }); }); @@ -958,7 +959,7 @@ describe('useCanvasOperations', () => { mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [96, 256] }), ]; - const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); + const setNodePositionByIdSpy = vi.spyOn(workflowState, 'setNodePositionById'); workflowsStore.getNodeByName.mockReturnValueOnce(nodes[1]).mockReturnValueOnce(nodes[2]); workflowsStore.getNodeById.mockReturnValueOnce(nodes[1]).mockReturnValueOnce(nodes[2]); @@ -1478,6 +1479,7 @@ describe('useCanvasOperations', () => { createTestNode({ id: '2', name: 'B' }), ]; workflowsStore.getNodesByIds.mockReturnValue(nodes); + const updateNodePropertiesSpy = vi.spyOn(workflowState, 'updateNodeProperties'); const { toggleNodesDisabled } = useCanvasOperations(); toggleNodesDisabled([nodes[0].id, nodes[1].id], { @@ -1485,7 +1487,7 @@ describe('useCanvasOperations', () => { trackBulk: true, }); - expect(workflowsStore.updateNodeProperties).toHaveBeenCalledWith({ + expect(updateNodePropertiesSpy).toHaveBeenCalledWith({ name: nodes[0].name, properties: { disabled: true, @@ -1500,7 +1502,7 @@ describe('useCanvasOperations', () => { const nodeName = 'testNode'; const node = createTestNode({ name: nodeName }); workflowsStore.getNodeByName.mockReturnValue(node); - const updateNodePropertiesSpy = vi.spyOn(workflowsStore, 'updateNodeProperties'); + const updateNodePropertiesSpy = vi.spyOn(workflowState, 'updateNodeProperties'); const { revertToggleNodeDisabled } = useCanvasOperations(); revertToggleNodeDisabled(nodeName); @@ -3783,8 +3785,6 @@ describe('useCanvasOperations', () => { describe('replaceNodeParameters', () => { it('should replace node parameters and track history', () => { const workflowsStore = mockedStore(useWorkflowsStore); - workflowState = useWorkflowState(); - vi.mocked(injectWorkflowState).mockReturnValue(workflowState); const historyStore = mockedStore(useHistoryStore); const nodeId = 'node1'; @@ -3798,14 +3798,13 @@ describe('useCanvasOperations', () => { parameters: currentParameters, }); - workflowsStore.workflow.nodes = [node]; workflowsStore.getNodeById.mockReturnValue(node); - const setNodeParameters = vi.spyOn(workflowState, 'setNodeParameters'); + workflowState.setNodeParameters = vi.fn(); const { replaceNodeParameters } = useCanvasOperations(); replaceNodeParameters(nodeId, currentParameters, newParameters, { trackHistory: true }); - expect(setNodeParameters).toHaveBeenCalledWith({ + expect(workflowState.setNodeParameters).toHaveBeenCalledWith({ name: node.name, value: newParameters, }); @@ -3834,14 +3833,13 @@ describe('useCanvasOperations', () => { parameters: currentParameters, }); - workflowsStore.workflow.nodes = [node]; workflowsStore.getNodeById.mockReturnValue(node); - const setNodeParameters = vi.spyOn(workflowState, 'setNodeParameters'); + workflowState.setNodeParameters = vi.fn(); const { replaceNodeParameters } = useCanvasOperations(); replaceNodeParameters(nodeId, currentParameters, newParameters, { trackHistory: false }); - expect(setNodeParameters).toHaveBeenCalledWith({ + expect(workflowState.setNodeParameters).toHaveBeenCalledWith({ name: node.name, value: newParameters, }); @@ -3855,14 +3853,13 @@ describe('useCanvasOperations', () => { const currentParameters = { param1: 'value1' }; const newParameters = { param1: 'value2' }; - workflowsStore.workflow.nodes = []; workflowsStore.getNodeById.mockReturnValue(undefined); - const setNodeParameters = vi.spyOn(workflowState, 'setNodeParameters'); + workflowState.setNodeParameters = vi.fn(); const { replaceNodeParameters } = useCanvasOperations(); replaceNodeParameters(nodeId, currentParameters, newParameters); - expect(setNodeParameters).not.toHaveBeenCalled(); + expect(workflowState.setNodeParameters).not.toHaveBeenCalled(); }); it('should handle bulk tracking when replacing parameters for multiple nodes', () => { @@ -3889,9 +3886,8 @@ describe('useCanvasOperations', () => { parameters: currentParameters2, }); - workflowsStore.workflow.nodes = [node1, node2]; + workflowState.setNodeParameters = vi.fn(); workflowsStore.getNodeById.mockReturnValueOnce(node1).mockReturnValueOnce(node2); - const setNodeParameters = vi.spyOn(workflowState, 'setNodeParameters'); const { replaceNodeParameters } = useCanvasOperations(); replaceNodeParameters(nodeId1, currentParameters1, newParameters1, { @@ -3905,7 +3901,7 @@ describe('useCanvasOperations', () => { expect(historyStore.startRecordingUndo).not.toHaveBeenCalled(); expect(historyStore.stopRecordingUndo).not.toHaveBeenCalled(); - expect(setNodeParameters).toHaveBeenCalledTimes(2); + expect(workflowState.setNodeParameters).toHaveBeenCalledTimes(2); }); it('should revert replaced node parameters', async () => { @@ -3922,14 +3918,13 @@ describe('useCanvasOperations', () => { parameters: newParameters, }); - workflowsStore.workflow.nodes = [node]; + workflowState.setNodeParameters = vi.fn(); workflowsStore.getNodeById.mockReturnValue(node); - const setNodeParameters = vi.spyOn(workflowState, 'setNodeParameters'); const { revertReplaceNodeParameters } = useCanvasOperations(); await revertReplaceNodeParameters(nodeId, currentParameters, newParameters); - expect(setNodeParameters).toHaveBeenCalledWith({ + expect(workflowState.setNodeParameters).toHaveBeenCalledWith({ name: node.name, value: currentParameters, }); diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts index 940a39df9f6..a7dc66cae4d 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts @@ -254,7 +254,7 @@ export function useCanvasOperations() { const oldPosition: XYPosition = [...node.position]; const newPosition: XYPosition = [position.x, position.y]; - workflowsStore.setNodePositionById(id, newPosition); + workflowState.setNodePositionById(id, newPosition); if (trackHistory) { historyStore.pushCommandToUndo( diff --git a/packages/frontend/editor-ui/src/composables/useNodeDirtiness.test.ts b/packages/frontend/editor-ui/src/composables/useNodeDirtiness.test.ts index 4b626ed6eb2..343080e4e1f 100644 --- a/packages/frontend/editor-ui/src/composables/useNodeDirtiness.test.ts +++ b/packages/frontend/editor-ui/src/composables/useNodeDirtiness.test.ts @@ -13,7 +13,15 @@ import { createTestingPinia } from '@pinia/testing'; import { NodeConnectionTypes, type IConnections, type IRunData } from 'n8n-workflow'; import { defineComponent } from 'vue'; import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded } from 'vue-router'; -import { useWorkflowState } from './useWorkflowState'; +import { useWorkflowState, injectWorkflowState, type WorkflowState } from './useWorkflowState'; + +vi.mock('@/composables/useWorkflowState', async () => { + const actual = await vi.importActual('@/composables/useWorkflowState'); + return { + ...actual, + injectWorkflowState: vi.fn(), + }; +}); describe(useNodeDirtiness, () => { let nodeTypeStore: ReturnType; @@ -21,6 +29,7 @@ describe(useNodeDirtiness, () => { let historyHelper: ReturnType; let canvasOperations: ReturnType; let uiStore: ReturnType; + let workflowState: WorkflowState; const NODE_RUN_AT = new Date('2025-01-01T00:00:01'); const WORKFLOW_UPDATED_AT = new Date('2025-01-01T00:00:10'); @@ -33,6 +42,9 @@ describe(useNodeDirtiness, () => { nodeTypeStore = useNodeTypesStore(); workflowsStore = useWorkflowsStore(); historyHelper = useHistoryHelper({} as RouteLocationNormalizedLoaded); + workflowState = useWorkflowState(); + vi.mocked(injectWorkflowState).mockReturnValue(workflowState); + canvasOperations = useCanvasOperations(); uiStore = useUIStore(); @@ -167,7 +179,7 @@ describe(useNodeDirtiness, () => { it('should not update dirtiness when the notes field is updated', () => { setupTestWorkflow('a🚨✅ -> b✅ -> c✅'); - workflowsStore.setNodeValue({ key: 'notes', name: 'b', value: 'test' }); + workflowState.setNodeValue({ key: 'notes', name: 'b', value: 'test' }); expect(useNodeDirtiness().dirtinessByName.value).toEqual({}); }); diff --git a/packages/frontend/editor-ui/src/composables/useNodeHelpers.ts b/packages/frontend/editor-ui/src/composables/useNodeHelpers.ts index 01b44e2f043..866007c6b44 100644 --- a/packages/frontend/editor-ui/src/composables/useNodeHelpers.ts +++ b/packages/frontend/editor-ui/src/composables/useNodeHelpers.ts @@ -51,6 +51,7 @@ import { useTelemetry } from './useTelemetry'; import { hasPermission } from '@/utils/rbac/permissions'; import { useCanvasStore } from '@/stores/canvas.store'; import { useSettingsStore } from '@/stores/settings.store'; +import { injectWorkflowState } from './useWorkflowState'; declare namespace HttpRequestNode { namespace V2 { @@ -67,6 +68,7 @@ export function useNodeHelpers() { const historyStore = useHistoryStore(); const nodeTypesStore = useNodeTypesStore(); const workflowsStore = useWorkflowsStore(); + const workflowState = injectWorkflowState(); const settingsStore = useSettingsStore(); const i18n = useI18n(); const canvasStore = useCanvasStore(); @@ -282,7 +284,7 @@ export function useNodeHelpers() { const nodeInputIssues = getNodeInputIssues(workflowObject.value, node, nodeType); - workflowsStore.setNodeIssue({ + workflowState.setNodeIssue({ node: node.name, type: 'input', value: nodeInputIssues?.input ? nodeInputIssues.input : null, @@ -301,7 +303,7 @@ export function useNodeHelpers() { const nodes = workflowsStore.allNodes; for (const node of nodes) { - workflowsStore.setNodeIssue({ + workflowState.setNodeIssue({ node: node.name, type: 'execution', value: hasNodeExecutionIssues(node) ? true : null, @@ -333,7 +335,7 @@ export function useNodeHelpers() { newIssues = fullNodeIssues.credentials!; } - workflowsStore.setNodeIssue({ + workflowState.setNodeIssue({ node: node.name, type: 'credentials', value: newIssues, @@ -368,7 +370,7 @@ export function useNodeHelpers() { newIssues = fullNodeIssues.parameters!; } - workflowsStore.setNodeIssue({ + workflowState.setNodeIssue({ node: node.name, type: 'parameters', value: newIssues, @@ -599,7 +601,7 @@ export function useNodeHelpers() { for (const node of nodes) { issues = getNodeCredentialIssues(node); - workflowsStore.setNodeIssue({ + workflowState.setNodeIssue({ node: node.name, type: 'credentials', value: issues?.credentials ?? null, @@ -730,7 +732,7 @@ export function useNodeHelpers() { workflow_id: workflowsStore.workflowId, }); - workflowsStore.updateNodeProperties(updateInformation); + workflowState.updateNodeProperties(updateInformation); workflowsStore.clearNodeExecutionData(node.name); updateNodeParameterIssues(node); updateNodeCredentialIssues(node); diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowSaving.ts b/packages/frontend/editor-ui/src/composables/useWorkflowSaving.ts index f9bbea0692f..70e047318d2 100644 --- a/packages/frontend/editor-ui/src/composables/useWorkflowSaving.ts +++ b/packages/frontend/editor-ui/src/composables/useWorkflowSaving.ts @@ -399,7 +399,7 @@ export function useWorkflowSaving({ router }: { router: ReturnType { let workflowsStore: ReturnType; @@ -122,4 +123,96 @@ describe('useWorkflowState', () => { expect(workflowsStore.getParametersLastUpdate('a')).toEqual(undefined); }); }); + describe('setNodeValue()', () => { + it('should update a node', () => { + const nodeName = 'Edit Fields'; + workflowsStore.addNode({ + parameters: {}, + id: '554c7ff4-7ee2-407c-8931-e34234c5056a', + name: nodeName, + type: 'n8n-nodes-base.set', + position: [680, 180], + typeVersion: 3.4, + }); + + expect(workflowsStore.nodeMetadata[nodeName].parametersLastUpdatedAt).toBe(undefined); + + workflowState.setNodeValue({ name: 'Edit Fields', key: 'executeOnce', value: true }); + + expect(workflowsStore.workflow.nodes[0].executeOnce).toBe(true); + expect(workflowsStore.nodeMetadata[nodeName].parametersLastUpdatedAt).toEqual( + expect.any(Number), + ); + }); + }); + + describe('setNodePositionById', () => { + it('should NOT update parametersLastUpdatedAt', () => { + const nodeName = 'Edit Fields'; + const nodeId = '554c7ff4-7ee2-407c-8931-e34234c5056a'; + workflowsStore.addNode({ + parameters: {}, + id: nodeId, + name: nodeName, + type: 'n8n-nodes-base.set', + position: [680, 180], + typeVersion: 3.4, + }); + + expect(workflowsStore.nodeMetadata[nodeName].parametersLastUpdatedAt).toBe(undefined); + + workflowState.setNodePositionById(nodeId, [0, 0]); + + expect(workflowsStore.workflow.nodes[0].position).toStrictEqual([0, 0]); + expect(workflowsStore.nodeMetadata[nodeName].parametersLastUpdatedAt).toBe(undefined); + }); + }); + describe('updateNodeAtIndex', () => { + it.each([ + { + description: 'should update node at given index with provided data', + nodeIndex: 0, + nodeData: { name: 'Updated Node' }, + initialNodes: [{ name: 'Original Node' }], + expectedNodes: [{ name: 'Updated Node' }], + expectedResult: true, + }, + { + description: 'should not update node if index is invalid', + nodeIndex: -1, + nodeData: { name: 'Updated Node' }, + initialNodes: [{ name: 'Original Node' }], + expectedNodes: [{ name: 'Original Node' }], + expectedResult: false, + }, + { + description: 'should return false if node data is unchanged', + nodeIndex: 0, + nodeData: { name: 'Original Node' }, + initialNodes: [{ name: 'Original Node' }], + expectedNodes: [{ name: 'Original Node' }], + expectedResult: false, + }, + { + description: 'should update multiple properties of a node', + nodeIndex: 0, + nodeData: { name: 'Updated Node', type: 'newType' }, + initialNodes: [{ name: 'Original Node', type: 'oldType' }], + expectedNodes: [{ name: 'Updated Node', type: 'newType' }], + expectedResult: true, + }, + ])('$description', ({ nodeIndex, nodeData, initialNodes, expectedNodes, expectedResult }) => { + workflowsStore.workflow.nodes = initialNodes as unknown as IWorkflowDb['nodes']; + + const result = workflowState.updateNodeAtIndex(nodeIndex, nodeData); + + expect(result).toBe(expectedResult); + expect(workflowsStore.workflow.nodes).toEqual(expectedNodes); + }); + + it('should throw error if out of bounds', () => { + workflowsStore.workflow.nodes = []; + expect(() => workflowState.updateNodeAtIndex(0, { name: 'Updated Node' })).toThrowError(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowState.ts b/packages/frontend/editor-ui/src/composables/useWorkflowState.ts index 8d8ba721d2a..6e940494cea 100644 --- a/packages/frontend/editor-ui/src/composables/useWorkflowState.ts +++ b/packages/frontend/editor-ui/src/composables/useWorkflowState.ts @@ -8,12 +8,15 @@ import type { IExecutionsStopData, INewWorkflowData, INodeUi, + INodeUpdatePropertiesInformation, IUpdateInformation, } from '@/Interface'; import { useUIStore } from '@/stores/ui.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { getPairedItemsMapping } from '@/utils/pairedItemUtils'; import { + type INodeIssueData, + type INodeIssueObjectProperty, NodeHelpers, type IDataObject, type INodeParameters, @@ -31,6 +34,15 @@ import { useWorkflowStateStore } from '@/stores/workflowState.store'; import { isObject } from '@/utils/objectUtils'; import findLast from 'lodash/findLast'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; +import isEqual from 'lodash/isEqual'; +import pick from 'lodash/pick'; +import { createEventBus } from '@n8n/utils/event-bus'; + +export type WorkflowStateBusEvents = { + updateNodeProperties: [WorkflowState, INodeUpdatePropertiesInformation]; +}; + +export const workflowStateEventBus = createEventBus(); export function useWorkflowState() { const ws = useWorkflowsStore(); @@ -224,6 +236,26 @@ export function useWorkflowState() { // Node modification //// + /** + * @returns `true` if the object was changed + */ + function updateNodeAtIndex(nodeIndex: number, nodeData: Partial): boolean { + if (nodeIndex !== -1) { + const node = ws.workflow.nodes[nodeIndex]; + const existingData = pick>(node, Object.keys(nodeData)); + const changed = !isEqual(existingData, nodeData); + + if (changed) { + Object.assign(node, nodeData); + ws.workflow.nodes[nodeIndex] = node; + ws.workflowObject.setNodes(ws.workflow.nodes); + } + + return changed; + } + return false; + } + function setNodeParameters(updateInformation: IUpdateInformation, append?: boolean): void { // Find the node that should be updated const nodeIndex = ws.workflow.nodes.findIndex((node) => { @@ -243,7 +275,7 @@ export function useWorkflowState() { ? { ...parameters, ...updateInformation.value } : updateInformation.value; - const changed = ws.updateNodeAtIndex(nodeIndex, { + const changed = updateNodeAtIndex(nodeIndex, { parameters: newParameters as INodeParameters, }); @@ -275,6 +307,95 @@ export function useWorkflowState() { } } + function setNodeValue(updateInformation: IUpdateInformation): void { + // Find the node that should be updated + const nodeIndex = ws.workflow.nodes.findIndex((node) => { + return node.name === updateInformation.name; + }); + + if (nodeIndex === -1 || !updateInformation.key) { + throw new Error( + `Node with the name "${updateInformation.name}" could not be found to set parameter.`, + ); + } + + const changed = updateNodeAtIndex(nodeIndex, { + [updateInformation.key]: updateInformation.value, + }); + + uiStore.stateIsDirty = uiStore.stateIsDirty || changed; + + const excludeKeys = ['position', 'notes', 'notesInFlow']; + + if (changed && !excludeKeys.includes(updateInformation.key)) { + ws.nodeMetadata[ws.workflow.nodes[nodeIndex].name].parametersLastUpdatedAt = Date.now(); + } + } + + function setNodePositionById(id: string, position: INodeUi['position']): void { + const node = ws.workflow.nodes.find((n) => n.id === id); + if (!node) return; + + setNodeValue({ name: node.name, key: 'position', value: position }); + } + + function updateNodeProperties( + this: WorkflowState, + updateInformation: INodeUpdatePropertiesInformation, + ): void { + // Find the node that should be updated + const nodeIndex = ws.workflow.nodes.findIndex((node) => { + return node.name === updateInformation.name; + }); + + if (nodeIndex !== -1) { + for (const key of Object.keys(updateInformation.properties)) { + const typedKey = key as keyof INodeUpdatePropertiesInformation['properties']; + const property = updateInformation.properties[typedKey]; + + const changed = updateNodeAtIndex(nodeIndex, { [key]: property }); + + if (changed) { + uiStore.stateIsDirty = true; + } + } + } + + workflowStateEventBus.emit('updateNodeProperties', [this, updateInformation]); + } + + function setNodeIssue(nodeIssueData: INodeIssueData): void { + const nodeIndex = ws.workflow.nodes.findIndex((node) => { + return node.name === nodeIssueData.node; + }); + if (nodeIndex === -1) { + return; + } + + const node = ws.workflow.nodes[nodeIndex]; + + if (nodeIssueData.value === null) { + // Remove the value if one exists + if (node.issues?.[nodeIssueData.type] === undefined) { + // No values for type exist so nothing has to get removed + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [nodeIssueData.type]: _removedNodeIssue, ...remainingNodeIssues } = node.issues; + updateNodeAtIndex(nodeIndex, { + issues: remainingNodeIssues, + }); + } else { + updateNodeAtIndex(nodeIndex, { + issues: { + ...node.issues, + [nodeIssueData.type]: nodeIssueData.value as INodeIssueObjectProperty, + }, + }); + } + } + return { // Workflow editing state resetState, @@ -296,6 +417,11 @@ export function useWorkflowState() { // Node modification setNodeParameters, setLastNodeParameters, + setNodeValue, + setNodePositionById, + setNodeIssue, + updateNodeAtIndex, + updateNodeProperties, // reexport executingNode: workflowStateStore.executingNode, diff --git a/packages/frontend/editor-ui/src/features/logStreaming.ee/components/EventDestinationSettingsModal.vue b/packages/frontend/editor-ui/src/features/logStreaming.ee/components/EventDestinationSettingsModal.vue index 6a56a928e07..52e32e43a60 100644 --- a/packages/frontend/editor-ui/src/features/logStreaming.ee/components/EventDestinationSettingsModal.vue +++ b/packages/frontend/editor-ui/src/features/logStreaming.ee/components/EventDestinationSettingsModal.vue @@ -1,5 +1,5 @@