refactor(editor): Migrate updateNodeAtIndex and usages to workflowState (#20586)

This commit is contained in:
Charlie Kolb 2025-10-10 11:11:49 +02:00 committed by GitHub
parent f3f925605e
commit 18a871efe8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 313 additions and 269 deletions

View File

@ -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<void> {
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({

View File

@ -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,

View File

@ -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);

View File

@ -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);

View File

@ -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<HTMLDivElement>();
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);
}
}

View File

@ -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,
});

View File

@ -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(

View File

@ -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<typeof useNodeTypesStore>;
@ -21,6 +29,7 @@ describe(useNodeDirtiness, () => {
let historyHelper: ReturnType<typeof useHistoryHelper>;
let canvasOperations: ReturnType<typeof useCanvasOperations>;
let uiStore: ReturnType<typeof useUIStore>;
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({});
});

View File

@ -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);

View File

@ -399,7 +399,7 @@ export function useWorkflowSaving({ router }: { router: ReturnType<typeof useRou
value: changedNodes[nodeName],
name: nodeName,
} as IUpdateInformation;
workflowsStore.setNodeValue(changes);
workflowState.setNodeValue(changes);
});
const createdTags = (workflowData.tags || []) as ITag[];

View File

@ -6,6 +6,7 @@ import {
createTestTaskData,
createTestWorkflowExecutionResponse,
} from '@/__tests__/mocks';
import type { IWorkflowDb } from '@/Interface';
describe('useWorkflowState', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
@ -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();
});
});
});

View File

@ -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<WorkflowStateBusEvents>();
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<INodeUi>): boolean {
if (nodeIndex !== -1) {
const node = ws.workflow.nodes[nodeIndex];
const existingData = pick<Partial<INodeUi>>(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,

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import get from 'lodash/get';
import set from 'lodash/set';
import unset from 'lodash/unset';
@ -60,6 +60,11 @@ import {
N8nSelect,
N8nText,
} from '@n8n/design-system';
import {
injectWorkflowState,
type WorkflowStateBusEvents,
workflowStateEventBus,
} from '@/composables/useWorkflowState';
defineOptions({ name: 'EventDestinationSettingsModal' });
@ -83,6 +88,7 @@ const telemetry = useTelemetry();
const logStreamingStore = useLogStreamingStore();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const uiStore = useUIStore();
const unchanged = ref(!isNew);
@ -160,22 +166,24 @@ const canManageLogStreaming = computed(() =>
hasPermission(['rbac'], { rbac: { scope: 'logStreaming:manage' } }),
);
function onUpdateNodeProperties(event: WorkflowStateBusEvents['updateNodeProperties']) {
const updateInformation = event[1];
if (updateInformation.name === destination.id) {
if ('credentials' in updateInformation.properties) {
unchanged.value = false;
nodeParameters.value.credentials = updateInformation.properties
.credentials as NodeParameterValueType;
}
}
}
onMounted(() => {
setupNode(Object.assign(deepCopy(defaultMessageEventBusDestinationOptions), destination));
workflowsStore.$onAction(({ name, args }) => {
if (name === 'updateNodeProperties') {
for (const arg of args) {
if (arg.name === destination.id) {
if ('credentials' in arg.properties) {
unchanged.value = false;
nodeParameters.value.credentials = arg.properties.credentials as NodeParameterValueType;
}
}
}
}
});
workflowStateEventBus.on('updateNodeProperties', onUpdateNodeProperties);
});
onUnmounted(() => workflowStateEventBus.off('updateNodeProperties', onUpdateNodeProperties));
function onInput() {
unchanged.value = false;
testMessageSent.value = false;
@ -259,7 +267,7 @@ function valueChanged(parameterData: IUpdateInformation) {
}
nodeParameters.value = deepCopy(nodeParametersCopy);
workflowsStore.updateNodeProperties({
workflowState.updateNodeProperties({
name: node.value.name,
properties: { parameters: nodeParameters.value, position: [0, 0] },
});

View File

@ -5,9 +5,11 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { TemplateCredentialKey } from '../utils/templateTransforms';
import { useCredentialSetupState } from './useCredentialSetupState';
import { injectWorkflowState } from '@/composables/useWorkflowState';
export const useSetupWorkflowCredentialsModalState = () => {
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const credentialsStore = useCredentialsStore();
const nodeHelpers = useNodeHelpers();
@ -63,7 +65,7 @@ export const useSetupWorkflowCredentialsModalState = () => {
};
usages.usedBy.forEach((node) => {
workflowsStore.updateNodeProperties({
workflowState.updateNodeProperties({
name: node.name,
properties: {
credentials: {
@ -97,7 +99,7 @@ export const useSetupWorkflowCredentialsModalState = () => {
const credentials = { ...node.credentials };
delete credentials[usages.credentialType];
workflowsStore.updateNodeProperties({
workflowState.updateNodeProperties({
name: node.name,
properties: {
credentials,

View File

@ -25,6 +25,7 @@ import { v4 as uuid } from 'uuid';
import { useWorkflowsStore } from './workflows.store';
import { computed, ref } from 'vue';
import type { TelemetryNdvSource } from '@/types/telemetry';
import { injectWorkflowState } from '@/composables/useWorkflowState';
const DEFAULT_MAIN_PANEL_DIMENSIONS = {
relativeLeft: 1,
@ -94,6 +95,7 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
const lastSetActiveNodeSource = ref<TelemetryNdvSource>();
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const activeNode = computed(() => {
return workflowsStore.getNodeByName(activeNodeName.value || '');
@ -371,7 +373,7 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
return node.name === activeNode.name;
});
workflowsStore.updateNodeAtIndex(nodeIndex, {
workflowState.updateNodeAtIndex(nodeIndex, {
issues: {
...activeNode.issues,
...issues,

View File

@ -1030,51 +1030,6 @@ describe('useWorkflowsStore', () => {
});
});
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);
workflowsStore.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);
workflowsStore.setNodePositionById(nodeId, [0, 0]);
expect(workflowsStore.workflow.nodes[0].position).toStrictEqual([0, 0]);
expect(workflowsStore.nodeMetadata[nodeName].parametersLastUpdatedAt).toBe(undefined);
});
});
describe('setNodes()', () => {
it('should transform credential-only nodes', () => {
const setNodeId = '1';
@ -1103,55 +1058,6 @@ describe('useWorkflowsStore', () => {
});
});
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 = workflowsStore.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(() => workflowsStore.updateNodeAtIndex(0, { name: 'Updated Node' })).toThrowError();
});
});
describe('findNodeByPartialId', () => {
test.each([
[[], 'D', undefined],
@ -1596,7 +1502,7 @@ describe('useWorkflowsStore', () => {
await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe('n0'));
workflowsStore.removeNode(n0);
await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe('n1'));
workflowsStore.setNodeValue({ name: 'n1', key: 'disabled', value: true });
useWorkflowState().setNodeValue({ name: 'n1', key: 'disabled', value: true });
await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe(undefined));
});
});

View File

@ -17,9 +17,7 @@ import type {
IExecutionsListResponse,
INodeMetadata,
INodeUi,
INodeUpdatePropertiesInformation,
IStartRunData,
IUpdateInformation,
IUsedCredential,
IWorkflowDb,
IWorkflowsMap,
@ -44,8 +42,6 @@ import type {
INodeCredentials,
INodeCredentialsDetails,
INodeExecutionData,
INodeIssueData,
INodeIssueObjectProperty,
INodeTypes,
IPinData,
IRunData,
@ -63,8 +59,6 @@ import {
TelemetryHelpers,
} from 'n8n-workflow';
import * as workflowUtils from 'n8n-workflow/common';
import isEqual from 'lodash/isEqual';
import pick from 'lodash/pick';
import { useRootStore } from '@n8n/stores/useRootStore';
import * as workflowsApi from '@/api/workflows';
@ -516,13 +510,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return workflow.value.nodes.map((node) => ({ ...node }));
}
function setNodePositionById(id: string, position: INodeUi['position']): void {
const node = workflow.value.nodes.find((n) => n.id === id);
if (!node) return;
setNodeValue({ name: node.name, key: 'position', value: position });
}
function convertTemplateNodeToNodeUi(node: IWorkflowTemplateNode): INodeUi {
const filteredCredentials = Object.keys(node.credentials ?? {}).reduce<INodeCredentials>(
(credentials, curr) => {
@ -1229,57 +1216,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
workflowObject.value.setConnections(value);
}
/**
* @returns `true` if the object was changed
*/
function updateNodeAtIndex(nodeIndex: number, nodeData: Partial<INodeUi>): boolean {
if (nodeIndex !== -1) {
const node = workflow.value.nodes[nodeIndex];
const existingData = pick<Partial<INodeUi>>(node, Object.keys(nodeData));
const changed = !isEqual(existingData, nodeData);
if (changed) {
Object.assign(node, nodeData);
workflow.value.nodes[nodeIndex] = node;
workflowObject.value.setNodes(workflow.value.nodes);
}
return changed;
}
return false;
}
function setNodeIssue(nodeIssueData: INodeIssueData): void {
const nodeIndex = workflow.value.nodes.findIndex((node) => {
return node.name === nodeIssueData.node;
});
if (nodeIndex === -1) {
return;
}
const node = workflow.value.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;
}
const { [nodeIssueData.type]: removedNodeIssue, ...remainingNodeIssues } = node.issues;
updateNodeAtIndex(nodeIndex, {
issues: remainingNodeIssues,
});
} else {
updateNodeAtIndex(nodeIndex, {
issues: {
...node.issues,
[nodeIssueData.type]: nodeIssueData.value as INodeIssueObjectProperty,
},
});
}
}
function addNode(nodeData: INodeUi): void {
// @TODO(ckolb): Reminder to refactor useActions:setAddedNodeActionParameters
// which listens to this function being called, when this is moved to workflowState soon
@ -1321,51 +1257,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}
}
function updateNodeProperties(updateInformation: INodeUpdatePropertiesInformation): void {
// Find the node that should be updated
const nodeIndex = workflow.value.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;
}
}
}
}
function setNodeValue(updateInformation: IUpdateInformation): void {
// Find the node that should be updated
const nodeIndex = workflow.value.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)) {
nodeMetadata.value[workflow.value.nodes[nodeIndex].name].parametersLastUpdatedAt = Date.now();
}
}
async function trackNodeExecution(pushData: PushPayload<'nodeExecuteAfter'>): Promise<void> {
const nodeName = pushData.nodeName;
@ -1905,12 +1796,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
removeConnection,
removeAllNodeConnection,
renameNodeSelectedAndExecution,
updateNodeAtIndex,
setNodeIssue,
addNode,
removeNode,
updateNodeProperties,
setNodeValue,
updateNodeExecutionRunData,
updateNodeExecutionStatus,
clearNodeExecutionData,
@ -1931,7 +1818,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
resetChatMessages,
appendChatMessage,
checkIfNodeHasChatParent,
setNodePositionById,
removeNodeById,
removeNodeConnectionsById,
removeNodeExecutionDataById,

View File

@ -10,7 +10,6 @@ import {
import { i18n as locale } from '@n8n/i18n';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { isJsonKeyObject } from '@/utils/typesUtils';
import {
isResourceLocatorValue,
@ -23,6 +22,7 @@ import {
type ResourceMapperField,
type Themed,
} from 'n8n-workflow';
import type { WorkflowState } from '@/composables/useWorkflowState';
/*
Constants and utility functions mainly used to get information about
@ -360,7 +360,11 @@ export const getCredentialsRelatedFields = (
return fields;
};
export const updateNodeAuthType = (node: INodeUi | null, type: string) => {
export const updateNodeAuthType = (
workflowState: WorkflowState,
node: INodeUi | null,
type: string,
) => {
if (!node) {
return;
}
@ -377,7 +381,7 @@ export const updateNodeAuthType = (node: INodeUi | null, type: string) => {
},
},
};
useWorkflowsStore().updateNodeProperties(updateInformation);
workflowState.updateNodeProperties(updateInformation);
}
}
};