/** * Canvas V2 Only * @TODO Remove this notice when Canvas V2 is the only one in use */ import type { AddedNodesAndConnections, IExecutionResponse, INodeUi, ITag, IUsedCredential, IWorkflowData, IWorkflowDataUpdate, IWorkflowDb, IWorkflowTemplate, WorkflowDataWithTemplateId, XYPosition, } from '@/Interface'; import { useDataSchema } from '@/composables/useDataSchema'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useI18n } from '@n8n/i18n'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { type PinDataSource, usePinnedData } from '@/composables/usePinnedData'; import { useTelemetry } from '@/composables/useTelemetry'; import { useToast } from '@/composables/useToast'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { getExecutionErrorToastConfiguration } from '@/utils/executionUtils'; import { EnterpriseEditionFeature, FORM_TRIGGER_NODE_TYPE, MCP_TRIGGER_NODE_TYPE, STICKY_NODE_TYPE, UPDATE_WEBHOOK_ID_NODE_TYPES, WEBHOOK_NODE_TYPE, } from '@/constants'; import { AddConnectionCommand, AddNodeCommand, MoveNodeCommand, RemoveConnectionCommand, RemoveNodeCommand, RenameNodeCommand, ReplaceNodeParametersCommand, } from '@/models/history'; import { useCanvasStore } from '@/stores/canvas.store'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useExecutionsStore } from '@/stores/executions.store'; import { useHistoryStore } from '@/stores/history.store'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useSettingsStore } from '@/stores/settings.store'; import { useTagsStore } from '@/stores/tags.store'; import { useUIStore } from '@/stores/ui.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import type { CanvasConnection, CanvasConnectionCreateData, CanvasConnectionPort, CanvasNode, CanvasNodeMoveEvent, ViewportBoundaries, } from '@/types'; import { CanvasConnectionMode } from '@/types'; import { createCanvasConnectionHandleString, mapCanvasConnectionToLegacyConnection, mapLegacyConnectionsToCanvasConnections, mapLegacyConnectionToCanvasConnection, parseCanvasConnectionHandleString, } from '@/utils/canvasUtils'; import * as NodeViewUtils from '@/utils/nodeViewUtils'; import { CONFIGURABLE_NODE_SIZE, CONFIGURATION_NODE_SIZE, DEFAULT_NODE_SIZE, DEFAULT_VIEWPORT_BOUNDARIES, generateOffsets, getNodesGroupSize, PUSH_NODES_OFFSET, } from '@/utils/nodeViewUtils'; import type { Connection } from '@vue-flow/core'; import type { IConnection, IConnections, IDataObject, INode, INodeConnections, INodeCredentials, INodeInputConfiguration, INodeOutputConfiguration, INodeTypeDescription, INodeTypeNameVersion, IPinData, IWorkflowBase, NodeInputConnections, NodeParameterValueType, Workflow, NodeConnectionType, INodeParameters, } from 'n8n-workflow'; import { deepCopy, NodeConnectionTypes, NodeHelpers, TelemetryHelpers } from 'n8n-workflow'; import { computed, nextTick, ref } from 'vue'; import { useClipboard } from '@/composables/useClipboard'; import { useUniqueNodeName } from '@/composables/useUniqueNodeName'; import { isPresent } from '../utils/typesUtils'; import { useProjectsStore } from '@/stores/projects.store'; import type { CanvasLayoutEvent } from './useCanvasLayout'; 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; }; type AddNodeDataWithTypeVersion = AddNodeData & { typeVersion: INodeUi['typeVersion']; }; type AddNodesBaseOptions = { dragAndDrop?: boolean; trackHistory?: boolean; keepPristine?: boolean; telemetry?: boolean; forcePosition?: boolean; viewport?: ViewportBoundaries; }; type AddNodesOptions = AddNodesBaseOptions & { position?: XYPosition; trackBulk?: boolean; }; type AddNodeOptions = AddNodesBaseOptions & { openNDV?: boolean; isAutoAdd?: boolean; }; export function useCanvasOperations() { const rootStore = useRootStore(); const workflowsStore = useWorkflowsStore(); const credentialsStore = useCredentialsStore(); const historyStore = useHistoryStore(); const uiStore = useUIStore(); const ndvStore = useNDVStore(); const nodeTypesStore = useNodeTypesStore(); const canvasStore = useCanvasStore(); const settingsStore = useSettingsStore(); const tagsStore = useTagsStore(); const nodeCreatorStore = useNodeCreatorStore(); const executionsStore = useExecutionsStore(); const projectsStore = useProjectsStore(); const logsStore = useLogsStore(); const i18n = useI18n(); const toast = useToast(); const workflowHelpers = useWorkflowHelpers(); const nodeHelpers = useNodeHelpers(); const telemetry = useTelemetry(); const externalHooks = useExternalHooks(); const clipboard = useClipboard(); const { uniqueNodeName } = useUniqueNodeName(); const lastClickPosition = ref([0, 0]); const preventOpeningNDV = !!localStorage.getItem('NodeView.preventOpeningNDV'); const editableWorkflow = computed(() => workflowsStore.workflow); const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow()); const triggerNodes = computed(() => { return workflowsStore.workflowTriggerNodes; }); /** * Node operations */ function tidyUp({ result, source, target }: CanvasLayoutEvent) { updateNodesPosition( result.nodes.map(({ id, x, y }) => ({ id, position: { x, y } })), { trackBulk: true, trackHistory: true }, ); trackTidyUp({ result, source, target }); } function trackTidyUp({ result, source, target }: CanvasLayoutEvent) { telemetry.track( 'User tidied up canvas', { source, target, nodes_count: result.nodes.length, }, { withPostHog: true }, ); } function updateNodesPosition( events: CanvasNodeMoveEvent[], { trackHistory = false, trackBulk = true } = {}, ) { if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } events.forEach(({ id, position }) => { updateNodePosition(id, position, { trackHistory }); }); if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } } function updateNodePosition( id: string, position: CanvasNode['position'], { trackHistory = false } = {}, ) { const node = workflowsStore.getNodeById(id); if (!node) { return; } const oldPosition: XYPosition = [...node.position]; const newPosition: XYPosition = [position.x, position.y]; workflowsStore.setNodePositionById(id, newPosition); if (trackHistory) { historyStore.pushCommandToUndo( new MoveNodeCommand(node.name, oldPosition, newPosition, Date.now()), ); } } function revertUpdateNodePosition(nodeName: string, position: CanvasNode['position']) { const node = workflowsStore.getNodeByName(nodeName); if (!node) { return; } updateNodePosition(node.id, position); } function replaceNodeParameters( nodeId: string, currentParameters: INodeParameters, newParameters: INodeParameters, { trackHistory = false, trackBulk = true } = {}, ) { const node = workflowsStore.getNodeById(nodeId); if (!node) return; if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } workflowsStore.setNodeParameters({ name: node.name, value: newParameters, }); if (trackHistory) { historyStore.pushCommandToUndo( new ReplaceNodeParametersCommand(nodeId, currentParameters, newParameters, Date.now()), ); } if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } } async function revertReplaceNodeParameters( nodeId: string, currentParameters: INodeParameters, newParameters: INodeParameters, ) { replaceNodeParameters(nodeId, newParameters, currentParameters); } async function renameNode( currentName: string, newName: string, { trackHistory = false, trackBulk = true } = {}, ) { if (currentName === newName) { return; } if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } newName = uniqueNodeName(newName); // Rename the node and update the connections const workflow = workflowsStore.getCurrentWorkflow(true); try { workflow.renameNode(currentName, newName); } catch (error) { toast.showMessage({ type: 'error', title: error.message, message: error.description, }); return; } if (trackHistory) { historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName, Date.now())); } // Update also last selected node and execution data workflowsStore.renameNodeSelectedAndExecution({ old: currentName, new: newName }); workflowsStore.setNodes(Object.values(workflow.nodes)); workflowsStore.setConnections(workflow.connectionsBySourceNode); const isRenamingActiveNode = ndvStore.activeNodeName === currentName; if (isRenamingActiveNode) { ndvStore.activeNodeName = newName; } if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } } async function revertRenameNode(currentName: string, previousName: string) { await renameNode(currentName, previousName); } function connectAdjacentNodes(id: string, { trackHistory = false } = {}) { const node = workflowsStore.getNodeById(id); if (!node) { return; } const outputConnectionsByType = workflowsStore.outgoingConnectionsByNodeName(node.name); const incomingConnectionsByType = workflowsStore.incomingConnectionsByNodeName(node.name); for (const [type, incomingConnectionsByInputIndex] of Object.entries( incomingConnectionsByType, ) as Array<[NodeConnectionType, NodeInputConnections]>) { // Only connect nodes connected to the first input of a type for (const incomingConnection of incomingConnectionsByInputIndex.at(0) ?? []) { const incomingNodeId = workflowsStore.getNodeByName(incomingConnection.node)?.id; if (!incomingNodeId) continue; // Only connect to nodes connected to the first output of a type // For example on an If node, connect to the "true" main output for (const outgoingConnection of outputConnectionsByType[type]?.at(0) ?? []) { const outgoingNodeId = workflowsStore.getNodeByName(outgoingConnection.node)?.id; if (!outgoingNodeId) continue; if (trackHistory) { historyStore.pushCommandToUndo( new AddConnectionCommand( [ { node: incomingConnection.node, type, index: incomingConnection.index, }, { node: outgoingConnection.node, type, index: outgoingConnection.index, }, ], Date.now(), ), ); } createConnection({ source: incomingNodeId, sourceHandle: createCanvasConnectionHandleString({ mode: CanvasConnectionMode.Output, type, index: incomingConnection.index, }), target: outgoingNodeId, targetHandle: createCanvasConnectionHandleString({ mode: CanvasConnectionMode.Input, type, index: outgoingConnection.index, }), }); } } } } function deleteNode(id: string, { trackHistory = false, trackBulk = true } = {}) { const node = workflowsStore.getNodeById(id); if (!node) { return; } if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } if (uiStore.lastInteractedWithNodeId === id) { uiStore.lastInteractedWithNodeId = undefined; } connectAdjacentNodes(id, { trackHistory }); deleteConnectionsByNodeId(id, { trackHistory, trackBulk: false }); workflowsStore.removeNodeExecutionDataById(id); workflowsStore.removeNodeById(id); if (trackHistory) { historyStore.pushCommandToUndo(new RemoveNodeCommand(node, Date.now())); if (trackBulk) { historyStore.stopRecordingUndo(); } } trackDeleteNode(id); } function deleteNodes(ids: string[], { trackHistory = true, trackBulk = true } = {}) { if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } ids.forEach((id) => deleteNode(id, { trackHistory, trackBulk: false })); if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } } function revertDeleteNode(node: INodeUi) { workflowsStore.addNode(node); uiStore.stateIsDirty = true; } function trackDeleteNode(id: string) { const node = workflowsStore.getNodeById(id); if (!node) { return; } if (node.type === STICKY_NODE_TYPE) { telemetry.track('User deleted workflow note', { workflow_id: workflowsStore.workflowId, }); } else { void externalHooks.run('node.deleteNode', { node }); telemetry.track('User deleted node', { node_type: node.type, workflow_id: workflowsStore.workflowId, }); } } function replaceNodeConnections( previousId: string, newId: string, { trackHistory = false, trackBulk = true, replaceInputs = true, replaceOutputs = true } = {}, ) { const previousNode = workflowsStore.getNodeById(previousId); const newNode = workflowsStore.getNodeById(newId); if (!previousNode || !newNode) { return; } const wf = workflowsStore.getCurrentWorkflow(); 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), ]; if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } for (const pair of connectionPairs) { const sourceNode = workflowsStore.getNodeByName(pair[0].node); const targetNode = workflowsStore.getNodeByName(pair[1].node); if (!sourceNode || !targetNode) continue; const oldCanvasConnection = mapLegacyConnectionToCanvasConnection( sourceNode, targetNode, pair, ); deleteConnection(oldCanvasConnection, { trackHistory, trackBulk: false }); const newCanvasConnection = mapLegacyConnectionToCanvasConnection( sourceNode.name === previousNode.name ? newNode : sourceNode, targetNode.name === previousNode.name ? newNode : targetNode, [ { ...pair[0], node: pair[0].node === previousNode.name ? newNode.name : pair[0].node, }, { ...pair[1], node: pair[1].node === previousNode.name ? newNode.name : pair[1].node, }, ], ); createConnection(newCanvasConnection, { trackHistory }); } if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } } function setNodeActive(id: string) { const node = workflowsStore.getNodeById(id); if (!node) { return; } workflowsStore.setNodePristine(node.name, false); setNodeActiveByName(node.name); } function setNodeActiveByName(name: string) { ndvStore.activeNodeName = name; } function clearNodeActive() { ndvStore.activeNodeName = null; } function setNodeParameters(id: string, parameters: Record) { const node = workflowsStore.getNodeById(id); if (!node) { return; } workflowsStore.setNodeParameters( { name: node.name, value: parameters as NodeParameterValueType, }, true, ); } function setNodeSelected(id?: string) { if (!id) { uiStore.lastInteractedWithNodeId = undefined; uiStore.lastSelectedNode = ''; return; } const node = workflowsStore.getNodeById(id); if (!node) { return; } uiStore.lastInteractedWithNodeId = id; uiStore.lastSelectedNode = node.name; } function toggleNodesDisabled(ids: string[], { trackHistory = true, trackBulk = true } = {}) { if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } const nodes = workflowsStore.getNodesByIds(ids); nodeHelpers.disableNodes(nodes, { trackHistory, trackBulk: false }); if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } } function revertToggleNodeDisabled(nodeName: string) { const node = workflowsStore.getNodeByName(nodeName); if (node) { nodeHelpers.disableNodes([node]); } } function toggleNodesPinned( ids: string[], source: PinDataSource, { trackHistory = true, trackBulk = true } = {}, ) { if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } const nodes = workflowsStore.getNodesByIds(ids); const nextStatePinned = nodes.some((node) => !workflowsStore.pinDataByNodeName(node.name)); for (const node of nodes) { const pinnedDataForNode = usePinnedData(node); if (nextStatePinned) { const dataToPin = useDataSchema().getInputDataWithPinned(node); if (dataToPin.length !== 0) { pinnedDataForNode.setData(dataToPin, source); } } else { pinnedDataForNode.unsetData(source); } } if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } } function requireNodeTypeDescription( type: INodeUi['type'], version?: INodeUi['typeVersion'], ): INodeTypeDescription { return ( nodeTypesStore.getNodeType(type, version) ?? { properties: [], displayName: type, name: type, group: [], description: '', version: version ?? 1, defaults: {}, inputs: [], outputs: [], } ); } async function addNodes( nodes: AddedNodesAndConnections['nodes'], { viewport, ...options }: AddNodesOptions = {}, ) { let insertPosition = options.position; let lastAddedNode: INodeUi | undefined; const addedNodes: INodeUi[] = []; const nodesWithTypeVersion = nodes.map((node) => { const typeVersion = node.typeVersion ?? resolveNodeVersion(requireNodeTypeDescription(node.type)); return { ...node, typeVersion, }; }); await loadNodeTypesProperties(nodesWithTypeVersion); if (options.trackHistory && options.trackBulk) { historyStore.startRecordingUndo(); } for (const [index, nodeAddData] of nodesWithTypeVersion.entries()) { const { isAutoAdd, openDetail: openNDV, ...node } = nodeAddData; const position = node.position ?? insertPosition; const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion); try { const newNode = addNode( { ...node, position, }, nodeTypeDescription, { ...options, ...(index === 0 ? { viewport } : {}), openNDV, isAutoAdd, }, ); lastAddedNode = newNode; addedNodes.push(newNode); } catch (error) { toast.showError(error, i18n.baseText('error')); console.error(error); continue; } // When we're adding multiple nodes, increment the X position for the next one insertPosition = [ lastAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2 + NodeViewUtils.GRID_SIZE, lastAddedNode.position[1], ]; } if (lastAddedNode) { updatePositionForNodeWithMultipleInputs(lastAddedNode); } if (options.trackHistory && options.trackBulk) { historyStore.stopRecordingUndo(); } if (!options.keepPristine) { uiStore.stateIsDirty = true; } return addedNodes; } function updatePositionForNodeWithMultipleInputs(node: INodeUi) { const inputNodes = editableWorkflowObject.value.getParentNodesByDepth(node.name, 1); if (inputNodes.length > 1) { inputNodes.slice(1).forEach((inputNode, index) => { const nodeUi = workflowsStore.getNodeByName(inputNode.name); if (!nodeUi) return; updateNodePosition(nodeUi.id, { x: nodeUi.position[0], y: nodeUi.position[1] + 100 * (index + 1), }); }); } } /** * Check if maximum allowed number of this type of node has been reached */ function checkMaxNodesOfTypeReached(nodeTypeDescription: INodeTypeDescription) { if ( nodeTypeDescription.maxNodes !== undefined && workflowHelpers.getNodeTypeCount(nodeTypeDescription.name) >= nodeTypeDescription.maxNodes ) { throw new Error( i18n.baseText('nodeView.showMessage.showMaxNodeTypeError.message', { adjustToNumber: nodeTypeDescription.maxNodes, interpolate: { nodeTypeDataDisplayName: nodeTypeDescription.displayName }, }), ); } } function addNode( node: AddNodeDataWithTypeVersion, nodeTypeDescription: INodeTypeDescription, options: AddNodeOptions = {}, ): INodeUi { checkMaxNodesOfTypeReached(nodeTypeDescription); const nodeData = resolveNodeData(node, nodeTypeDescription, { viewport: options.viewport, }); if (!nodeData) { throw new Error(i18n.baseText('nodeViewV2.showError.failedToCreateNode')); } workflowsStore.addNode(nodeData); if (options.trackHistory) { historyStore.pushCommandToUndo(new AddNodeCommand(nodeData, Date.now())); } if (!options.isAutoAdd) { createConnectionToLastInteractedWithNode(nodeData, options); } void nextTick(() => { if (!options.keepPristine) { uiStore.stateIsDirty = true; } workflowsStore.setNodePristine(nodeData.name, true); nodeHelpers.matchCredentials(nodeData); nodeHelpers.updateNodeParameterIssues(nodeData); nodeHelpers.updateNodeCredentialIssues(nodeData); nodeHelpers.updateNodeInputIssues(nodeData); if (options.telemetry) { trackAddNode(nodeData, options); } if (nodeData.type !== STICKY_NODE_TYPE) { void externalHooks.run('nodeView.addNodeButton', { nodeTypeName: nodeData.type }); if (options.openNDV && !preventOpeningNDV) { ndvStore.setActiveNodeName(nodeData.name); } } }); return nodeData; } async function revertAddNode(nodeName: string) { const node = workflowsStore.getNodeByName(nodeName); if (!node) { return; } deleteNode(node.id); } function createConnectionToLastInteractedWithNode(node: INodeUi, options: AddNodeOptions = {}) { const lastInteractedWithNode = uiStore.lastInteractedWithNode; if (!lastInteractedWithNode) { return; } const lastInteractedWithNodeId = lastInteractedWithNode.id; const lastInteractedWithNodeConnection = uiStore.lastInteractedWithNodeConnection; const lastInteractedWithNodeHandle = uiStore.lastInteractedWithNodeHandle; // If we have a specific endpoint to connect to if (lastInteractedWithNodeHandle) { const { type: connectionType, mode } = parseCanvasConnectionHandleString( lastInteractedWithNodeHandle, ); const nodeId = node.id; const nodeHandle = createCanvasConnectionHandleString({ mode: CanvasConnectionMode.Input, type: connectionType, index: 0, }); if (mode === CanvasConnectionMode.Input) { createConnection({ source: nodeId, sourceHandle: nodeHandle, target: lastInteractedWithNodeId, targetHandle: lastInteractedWithNodeHandle, }); } else { createConnection({ source: lastInteractedWithNodeId, sourceHandle: lastInteractedWithNodeHandle, target: nodeId, targetHandle: nodeHandle, }); } } else { // If a node is last selected then connect between the active and its child ones // Connect active node to the newly created one createConnection({ source: lastInteractedWithNodeId, sourceHandle: createCanvasConnectionHandleString({ mode: CanvasConnectionMode.Output, type: NodeConnectionTypes.Main, index: 0, }), target: node.id, targetHandle: createCanvasConnectionHandleString({ mode: CanvasConnectionMode.Input, type: NodeConnectionTypes.Main, index: 0, }), }); } if (lastInteractedWithNodeConnection) { deleteConnection(lastInteractedWithNodeConnection, { trackHistory: options.trackHistory }); const targetNode = workflowsStore.getNodeById(lastInteractedWithNodeConnection.target); if (targetNode) { createConnection({ source: node.id, sourceHandle: createCanvasConnectionHandleString({ mode: CanvasConnectionMode.Input, type: NodeConnectionTypes.Main, index: 0, }), target: lastInteractedWithNodeConnection.target, targetHandle: lastInteractedWithNodeConnection.targetHandle, }); } } } function trackAddNode(nodeData: INodeUi, options: AddNodeOptions) { switch (nodeData.type) { case STICKY_NODE_TYPE: trackAddStickyNoteNode(); break; default: trackAddDefaultNode(nodeData, options); } } function trackAddStickyNoteNode() { telemetry.track('User inserted workflow note', { workflow_id: workflowsStore.workflowId, }); } function trackAddDefaultNode(nodeData: INodeUi, options: AddNodeOptions) { nodeCreatorStore.onNodeAddedToCanvas({ node_id: nodeData.id, node_type: nodeData.type, node_version: nodeData.typeVersion, is_auto_add: options.isAutoAdd, workflow_id: workflowsStore.workflowId, drag_and_drop: options.dragAndDrop, input_node_type: uiStore.lastInteractedWithNode ? uiStore.lastInteractedWithNode.type : undefined, }); } /** * Resolves the data for a new node */ function resolveNodeData( node: AddNodeDataWithTypeVersion, nodeTypeDescription: INodeTypeDescription, options: { viewport?: ViewportBoundaries; forcePosition?: boolean } = {}, ) { const id = node.id ?? nodeHelpers.assignNodeId(node as INodeUi); const name = node.name ?? nodeHelpers.getDefaultNodeName(node) ?? (nodeTypeDescription.defaults.name as string); const type = nodeTypeDescription.name; const typeVersion = node.typeVersion; const position = options.forcePosition && node.position ? node.position : resolveNodePosition(node as INodeUi, nodeTypeDescription, { viewport: options.viewport, }); const disabled = node.disabled ?? false; const parameters = node.parameters ?? {}; const nodeData: INodeUi = { ...node, id, name, type, typeVersion, position, disabled, parameters, }; resolveNodeName(nodeData); resolveNodeParameters(nodeData, nodeTypeDescription); resolveNodeWebhook(nodeData, nodeTypeDescription); return nodeData; } async function loadNodeTypesProperties( nodes: Array>, ): Promise { const allNodeTypeDescriptions: INodeTypeDescription[] = nodeTypesStore.allNodeTypes; const nodesToBeFetched: INodeTypeNameVersion[] = []; allNodeTypeDescriptions.forEach((nodeTypeDescription) => { const nodeVersions = Array.isArray(nodeTypeDescription.version) ? nodeTypeDescription.version : [nodeTypeDescription.version]; if ( !!nodes.find( (n) => n.type === nodeTypeDescription.name && nodeVersions.includes(n.typeVersion), ) && !nodeTypeDescription.hasOwnProperty('properties') ) { nodesToBeFetched.push({ name: nodeTypeDescription.name, version: Array.isArray(nodeTypeDescription.version) ? nodeTypeDescription.version.slice(-1)[0] : nodeTypeDescription.version, }); } }); if (nodesToBeFetched.length > 0) { // Only call API if node information is actually missing await nodeTypesStore.getNodesInformation(nodesToBeFetched); } } function resolveNodeVersion(nodeTypeDescription: INodeTypeDescription) { let nodeVersion = nodeTypeDescription.defaultVersion; if (typeof nodeVersion === 'undefined') { nodeVersion = Array.isArray(nodeTypeDescription.version) ? nodeTypeDescription.version.slice(-1)[0] : nodeTypeDescription.version; } return nodeVersion; } function resolveNodeParameters(node: INodeUi, nodeTypeDescription: INodeTypeDescription) { const nodeParameters = NodeHelpers.getNodeParameters( nodeTypeDescription?.properties ?? [], node.parameters, true, false, node, nodeTypeDescription, ); node.parameters = nodeParameters ?? {}; } function resolveNodePosition( node: Omit & { position?: INodeUi['position'] }, nodeTypeDescription: INodeTypeDescription, options: { viewport?: ViewportBoundaries } = {}, ) { // Available when // - clicking the plus button of a node handle // - dragging an edge / connection of a node handle // - selecting a node, adding a node via the node creator const lastInteractedWithNode = uiStore.lastInteractedWithNode; // Available when clicking the plus button of a node edge / connection const lastInteractedWithNodeConnection = uiStore.lastInteractedWithNodeConnection; // Available when dragging an edge / connection from a node const lastInteractedWithNodeHandle = uiStore.lastInteractedWithNodeHandle; const { type: connectionType, index: connectionIndex } = parseCanvasConnectionHandleString( lastInteractedWithNodeHandle ?? lastInteractedWithNodeConnection?.sourceHandle ?? '', ); const nodeSize = connectionType === NodeConnectionTypes.Main ? DEFAULT_NODE_SIZE : CONFIGURATION_NODE_SIZE; let pushOffsets: XYPosition = [nodeSize[0] / 2, nodeSize[1] / 2]; let position: XYPosition | undefined = node.position; if (position) { return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position, { offset: pushOffsets, size: nodeSize, viewport: options.viewport, normalize: false, }); } if (lastInteractedWithNode) { const lastInteractedWithNodeTypeDescription = nodeTypesStore.getNodeType( lastInteractedWithNode.type, lastInteractedWithNode.typeVersion, ); const lastInteractedWithNodeObject = editableWorkflowObject.value.getNode( lastInteractedWithNode.name, ); const newNodeInsertPosition = uiStore.lastCancelledConnectionPosition; if (newNodeInsertPosition) { // When pulling / cancelling a connection. // The new node should be placed at the same position as the mouse up event, // designated by the `newNodeInsertPosition` value. const xOffset = connectionType === NodeConnectionTypes.Main ? 0 : -nodeSize[0] / 2; const yOffset = connectionType === NodeConnectionTypes.Main ? -nodeSize[1] / 2 : 0; position = [newNodeInsertPosition[0] + xOffset, newNodeInsertPosition[1] + yOffset]; uiStore.lastCancelledConnectionPosition = undefined; } else if (lastInteractedWithNodeTypeDescription && lastInteractedWithNodeObject) { // When // - clicking the plus button of a node handle // - clicking the plus button of a node edge / connection // - selecting a node, adding a node via the node creator const lastInteractedWithNodeInputs = NodeHelpers.getNodeInputs( editableWorkflowObject.value, lastInteractedWithNodeObject, lastInteractedWithNodeTypeDescription, ); const lastInteractedWithNodeInputTypes = NodeHelpers.getConnectionTypes( lastInteractedWithNodeInputs, ); const lastInteractedWithNodeScopedInputTypes = ( lastInteractedWithNodeInputTypes || [] ).filter((input) => input !== NodeConnectionTypes.Main); const lastInteractedWithNodeOutputs = NodeHelpers.getNodeOutputs( editableWorkflowObject.value, lastInteractedWithNodeObject, lastInteractedWithNodeTypeDescription, ); const lastInteractedWithNodeOutputTypes = NodeHelpers.getConnectionTypes( lastInteractedWithNodeOutputs, ); const lastInteractedWithNodeMainOutputs = lastInteractedWithNodeOutputTypes.filter( (output) => output === NodeConnectionTypes.Main, ); let yOffset = 0; if (lastInteractedWithNodeConnection) { // When clicking the plus button of a node edge / connection // Compute the y offset for the new node based on the number of main outputs of the source node // and shift the downstream nodes accordingly shiftDownstreamNodesPosition(lastInteractedWithNode.name, PUSH_NODES_OFFSET, { trackHistory: true, }); } if (lastInteractedWithNodeMainOutputs.length > 1) { const yOffsetValues = generateOffsets( lastInteractedWithNodeMainOutputs.length, NodeViewUtils.NODE_SIZE, NodeViewUtils.GRID_SIZE, ); yOffset = yOffsetValues[connectionIndex]; } let outputs: Array = []; try { // It fails when the outputs are an expression. As those nodes have // normally no outputs by default and the only reason we need the // outputs here is to calculate the position, it is fine to assume // that they have no outputs and are so treated as a regular node // with only "main" outputs. outputs = NodeHelpers.getNodeOutputs( editableWorkflowObject.value, node as INode, nodeTypeDescription, ); } catch (e) {} const outputTypes = NodeHelpers.getConnectionTypes(outputs); pushOffsets = [100, 0]; if ( outputTypes.length > 0 && outputTypes.every((outputName) => outputName !== NodeConnectionTypes.Main) ) { // When the added node has only non-main outputs (configuration nodes) // We want to place the new node directly below the last interacted with node. const scopedConnectionIndex = lastInteractedWithNodeScopedInputTypes.findIndex( (inputType) => outputs[0] === inputType, ); const lastInteractedWithNodeWidthDivisions = Math.max( lastInteractedWithNodeScopedInputTypes.length + 1, 1, ); position = [ lastInteractedWithNode.position[0] + (CONFIGURABLE_NODE_SIZE[0] / lastInteractedWithNodeWidthDivisions) * (scopedConnectionIndex + 1) - nodeSize[0] / 2, lastInteractedWithNode.position[1] + PUSH_NODES_OFFSET, ]; } else { // When the node has only main outputs, mixed outputs, or no outputs at all // We want to place the new node directly to the right of the last interacted with node. let pushOffset = PUSH_NODES_OFFSET; if ( !!lastInteractedWithNodeInputTypes.find((input) => input !== NodeConnectionTypes.Main) ) { // If the node has scoped inputs, push it down a bit more pushOffset += 140; } // If a node is active then add the new node directly after the current one position = [ lastInteractedWithNode.position[0] + pushOffset, lastInteractedWithNode.position[1] + yOffset, ]; } } } if (!position) { if (nodeTypesStore.isTriggerNode(node.type) && triggerNodes.value.length === 0) { // When added node is a trigger, and it's the first one added to the canvas // we place it at root to replace the canvas add button position = [0, 0]; } else { // When no position is set, we place the node at the last clicked position position = lastClickPosition.value; } } return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position, { offset: pushOffsets, size: nodeSize, viewport: options.viewport, }); } function resolveNodeName(node: INodeUi) { const localizedName = i18n.localizeNodeName(rootStore.defaultLocale, node.name, node.type); node.name = uniqueNodeName(localizedName); } function resolveNodeWebhook(node: INodeUi, nodeTypeDescription: INodeTypeDescription) { if (nodeTypeDescription.webhooks?.length && !node.webhookId) { nodeHelpers.assignWebhookId(node); } // if it's a webhook and the path is empty set the UUID as the default path if ( [WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, MCP_TRIGGER_NODE_TYPE].includes(node.type) && node.parameters.path === '' ) { node.parameters.path = node.webhookId as string; } } /** * Moves all downstream nodes of a node */ function shiftDownstreamNodesPosition( sourceNodeName: string, margin: number, { trackHistory = false }: { trackHistory?: boolean }, ) { const sourceNode = workflowsStore.nodesByName[sourceNodeName]; const checkNodes = workflowHelpers.getConnectedNodes( 'downstream', editableWorkflowObject.value, sourceNodeName, ); for (const nodeName of checkNodes) { const node = workflowsStore.nodesByName[nodeName]; if (!node || !sourceNode || node.position[0] < sourceNode.position[0]) { continue; } updateNodePosition( node.id, { x: node.position[0] + margin, y: node.position[1], }, { trackHistory }, ); } } /** * Connection operations */ function createConnection( connection: Connection, { trackHistory = false, keepPristine = false } = {}, ) { const sourceNode = workflowsStore.getNodeById(connection.source); const targetNode = workflowsStore.getNodeById(connection.target); if (!sourceNode || !targetNode) { return; } if (trackHistory) { historyStore.pushCommandToUndo( new AddConnectionCommand( mapCanvasConnectionToLegacyConnection(sourceNode, targetNode, connection), Date.now(), ), ); } const mappedConnection = mapCanvasConnectionToLegacyConnection( sourceNode, targetNode, connection, ); if (!isConnectionAllowed(sourceNode, targetNode, mappedConnection[0], mappedConnection[1])) { return; } workflowsStore.addConnection({ connection: mappedConnection, }); void nextTick(() => { nodeHelpers.updateNodeInputIssues(sourceNode); nodeHelpers.updateNodeInputIssues(targetNode); }); if (!keepPristine) { uiStore.stateIsDirty = true; } } function revertCreateConnection(connection: [IConnection, IConnection]) { const sourceNodeName = connection[0].node; const sourceNode = workflowsStore.getNodeByName(sourceNodeName); const targetNodeName = connection[1].node; const targetNode = workflowsStore.getNodeByName(targetNodeName); if (!sourceNode || !targetNode) { return; } deleteConnection(mapLegacyConnectionToCanvasConnection(sourceNode, targetNode, connection)); } function deleteConnectionsByNodeId( targetNodeId: string, { trackHistory = false, trackBulk = true } = {}, ) { const targetNode = workflowsStore.getNodeById(targetNodeId); if (!targetNode) { return; } if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } const connections = cloneDeep(workflowsStore.workflow.connections); for (const nodeName of Object.keys(connections)) { const node = workflowsStore.getNodeByName(nodeName); if (!node) { continue; } for (const type of Object.keys(connections[nodeName])) { for (const index of Object.keys(connections[nodeName][type])) { const connectionsToDelete = connections[nodeName][type][parseInt(index, 10)] ?? []; for (const connectionIndex of Object.keys(connectionsToDelete)) { const connectionData = connectionsToDelete[parseInt(connectionIndex, 10)]; if (!connectionData) { continue; } const connectionDataNode = workflowsStore.getNodeByName(connectionData.node); if ( connectionDataNode && (connectionDataNode.id === targetNode.id || node.name === targetNode.name) ) { deleteConnection( { source: node.id, sourceHandle: createCanvasConnectionHandleString({ mode: CanvasConnectionMode.Output, type: type as NodeConnectionType, index: parseInt(index, 10), }), target: connectionDataNode.id, targetHandle: createCanvasConnectionHandleString({ mode: CanvasConnectionMode.Input, type: connectionData.type as NodeConnectionType, index: connectionData.index, }), }, { trackHistory, trackBulk: false }, ); } } } } } delete workflowsStore.workflow.connections[targetNode.name]; if (trackHistory && trackBulk) { historyStore.stopRecordingUndo(); } } function deleteConnection( connection: Connection, { trackHistory = false, trackBulk = true } = {}, ) { const sourceNode = workflowsStore.getNodeById(connection.source); const targetNode = workflowsStore.getNodeById(connection.target); if (!sourceNode || !targetNode) { return; } const mappedConnection = mapCanvasConnectionToLegacyConnection( sourceNode, targetNode, connection, ); if (trackHistory && trackBulk) { historyStore.startRecordingUndo(); } workflowsStore.removeConnection({ connection: mappedConnection, }); if (trackHistory) { historyStore.pushCommandToUndo(new RemoveConnectionCommand(mappedConnection, Date.now())); if (trackBulk) { historyStore.stopRecordingUndo(); } } } function revertDeleteConnection(connection: [IConnection, IConnection]) { workflowsStore.addConnection({ connection, }); } function revalidateNodeConnections(id: string, connectionMode: CanvasConnectionMode) { const node = workflowsStore.getNodeById(id); const isInput = connectionMode === CanvasConnectionMode.Input; if (!node) { return; } const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion); if (!nodeType) { return; } const connections = mapLegacyConnectionsToCanvasConnections( workflowsStore.workflow.connections, workflowsStore.workflow.nodes, ); connections.forEach((connection) => { const isRelevantConnection = isInput ? connection.target === id : connection.source === id; if (isRelevantConnection) { const otherNodeId = isInput ? connection.source : connection.target; const otherNode = workflowsStore.getNodeById(otherNodeId); if (!otherNode || !connection.data) { return; } const [firstNode, secondNode] = isInput ? [otherNode, node] : [node, otherNode]; if ( !isConnectionAllowed( firstNode, secondNode, connection.data.source, connection.data.target, ) ) { void nextTick(() => deleteConnection(connection)); } } }); } function revalidateNodeInputConnections(id: string) { return revalidateNodeConnections(id, CanvasConnectionMode.Input); } function revalidateNodeOutputConnections(id: string) { return revalidateNodeConnections(id, CanvasConnectionMode.Output); } function isConnectionAllowed( sourceNode: INodeUi, targetNode: INodeUi, sourceConnection: IConnection | CanvasConnectionPort, targetConnection: IConnection | CanvasConnectionPort, ): boolean { const blocklist = [STICKY_NODE_TYPE]; if (sourceConnection.type !== targetConnection.type) { return false; } if (blocklist.includes(sourceNode.type) || blocklist.includes(targetNode.type)) { return false; } const sourceNodeType = nodeTypesStore.getNodeType(sourceNode.type, sourceNode.typeVersion); const sourceWorkflowNode = editableWorkflowObject.value.getNode(sourceNode.name); if (!sourceWorkflowNode) { return false; } let sourceNodeOutputs: Array = []; if (sourceNodeType) { sourceNodeOutputs = NodeHelpers.getNodeOutputs( editableWorkflowObject.value, sourceWorkflowNode, sourceNodeType, ) || []; } const sourceNodeHasOutputConnectionOfType = !!sourceNodeOutputs.find((output) => { const outputType = typeof output === 'string' ? output : output.type; return outputType === sourceConnection.type; }); const sourceNodeHasOutputConnectionPortOfType = sourceConnection.index < sourceNodeOutputs.length; if (!sourceNodeHasOutputConnectionOfType || !sourceNodeHasOutputConnectionPortOfType) { return false; } const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion); const targetWorkflowNode = editableWorkflowObject.value.getNode(targetNode.name); if (!targetWorkflowNode) { return false; } let targetNodeInputs: Array = []; if (targetNodeType) { targetNodeInputs = NodeHelpers.getNodeInputs( editableWorkflowObject.value, targetWorkflowNode, targetNodeType, ) || []; } const targetNodeHasInputConnectionOfType = !!targetNodeInputs.find((input) => { const inputType = typeof input === 'string' ? input : input.type; if (inputType !== targetConnection.type) return false; const filter = typeof input === 'object' && 'filter' in input ? input.filter : undefined; if (filter?.nodes.length && !filter.nodes.includes(sourceNode.type)) { toast.showToast({ title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'), message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', { interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name }, }), type: 'error', duration: 5000, }); return false; } return true; }); const targetNodeHasInputConnectionPortOfType = targetConnection.index < targetNodeInputs.length; return targetNodeHasInputConnectionOfType && targetNodeHasInputConnectionPortOfType; } async function addConnections( connections: CanvasConnectionCreateData[] | CanvasConnection[], { trackBulk = true, trackHistory = false, keepPristine = false } = {}, ) { await nextTick(); // Connection creation relies on the nodes being already added to the store if (trackBulk && trackHistory) { historyStore.startRecordingUndo(); } for (const connection of connections) { createConnection(connection, { trackHistory, keepPristine }); } if (trackBulk && trackHistory) { historyStore.stopRecordingUndo(); } if (!keepPristine) { uiStore.stateIsDirty = true; } } /** * Workspace operations */ function resetWorkspace() { // Reset node creator nodeCreatorStore.setNodeCreatorState({ createNodeActive: false }); nodeCreatorStore.setShowScrim(false); // Make sure that if there is a waiting test-webhook, it gets removed if (workflowsStore.executionWaitingForWebhook) { try { void workflowsStore.removeTestWebhook(workflowsStore.workflowId); } catch (error) {} } // Reset editable workflow state workflowsStore.resetWorkflow(); workflowsStore.resetState(); workflowsStore.currentWorkflowExecutions = []; workflowsStore.setActiveExecutionId(undefined); // Reset actions uiStore.resetLastInteractedWith(); uiStore.stateIsDirty = false; // Reset executions executionsStore.activeExecution = null; // Reset credentials updates nodeHelpers.credentialsUpdated.value = false; } function initializeWorkspace(data: IWorkflowDb) { workflowHelpers.initState(data); data.nodes.forEach((node) => { const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion); nodeHelpers.matchCredentials(node); resolveNodeParameters(node, nodeTypeDescription); resolveNodeWebhook(node, nodeTypeDescription); }); workflowsStore.setNodes(data.nodes); workflowsStore.setConnections(data.connections); } /** * Import operations */ function removeUnknownCredentials(workflow: IWorkflowDataUpdate) { if (!workflow?.nodes) return; for (const node of workflow.nodes) { if (!node.credentials) continue; for (const [name, credential] of Object.entries(node.credentials)) { if (typeof credential === 'string' || credential.id === null) continue; if (!credentialsStore.getCredentialById(credential.id)) { delete node.credentials[name]; } } } } async function addImportedNodesToWorkflow( data: IWorkflowDataUpdate, { trackBulk = true, trackHistory = false, viewport = DEFAULT_VIEWPORT_BOUNDARIES } = {}, ): Promise { // Because nodes with the same name maybe already exist, it could // be needed that they have to be renamed. Also could it be possible // that nodes are not allowed to be created because they have a create // limit set. So we would then link the new nodes with the already existing ones. // In this object all that nodes get saved in the format: // old-name -> new-name const nodeNameTable: { [key: string]: string; } = {}; const newNodeNames = new Set((data.nodes ?? []).map((node) => node.name)); if (!data.nodes) { // No nodes to add throw new Error(i18n.baseText('nodeView.noNodesGivenToAdd')); } // Get how many of the nodes of the types which have // a max limit set already exist const nodeTypesCount = workflowHelpers.getNodeTypesMaxCount(); let oldName: string; let newName: string; const createNodes: INode[] = []; await nodeHelpers.loadNodesProperties( data.nodes.map((node) => ({ name: node.type, version: node.typeVersion })), ); data.nodes.forEach((node) => { if (nodeTypesCount[node.type] !== undefined) { if (nodeTypesCount[node.type].exist >= nodeTypesCount[node.type].max) { // Node is not allowed to be created so // do not add it to the create list but // add the name of the existing node // that this one gets linked up instead. nodeNameTable[node.name] = nodeTypesCount[node.type].nodeNames[0]; return; } else { // Node can be created but increment the // counter in case multiple ones are // supposed to be created nodeTypesCount[node.type].exist += 1; } } oldName = node.name; const localized = i18n.localizeNodeName(rootStore.defaultLocale, node.name, node.type); newNodeNames.delete(oldName); newName = uniqueNodeName(localized, Array.from(newNodeNames)); newNodeNames.add(newName); nodeNameTable[oldName] = newName; createNodes.push(node); }); // Get only the connections of the nodes that get created const newConnections: IConnections = {}; const currentConnections = data.connections ?? {}; const createNodeNames = createNodes.map((node) => node.name); let sourceNode, type, sourceIndex, connectionIndex, connectionData; for (sourceNode of Object.keys(currentConnections)) { if (!createNodeNames.includes(sourceNode)) { // Node does not get created so skip output connections continue; } const connection: INodeConnections = {}; for (type of Object.keys(currentConnections[sourceNode])) { connection[type] = []; for ( sourceIndex = 0; sourceIndex < currentConnections[sourceNode][type].length; sourceIndex++ ) { const nodeSourceConnections = []; const connectionsToCheck = currentConnections[sourceNode][type][sourceIndex]; if (connectionsToCheck) { for ( connectionIndex = 0; connectionIndex < connectionsToCheck.length; connectionIndex++ ) { connectionData = connectionsToCheck[connectionIndex]; if (!createNodeNames.includes(connectionData.node)) { // Node does not get created so skip input connection continue; } nodeSourceConnections.push(connectionData); // Add connection } } connection[type].push(nodeSourceConnections); } } newConnections[sourceNode] = connection; } // Create a workflow with the new nodes and connections that we can use // the rename method const tempWorkflow: Workflow = workflowsStore.getWorkflow(createNodes, newConnections); // Rename all the nodes of which the name changed for (oldName in nodeNameTable) { if (oldName === nodeNameTable[oldName]) { // Name did not change so skip continue; } tempWorkflow.renameNode(oldName, nodeNameTable[oldName]); } if (data.pinData) { let pinDataSuccess = true; for (const nodeName of Object.keys(data.pinData)) { // Pin data limit reached if (!pinDataSuccess) { toast.showError( new Error(i18n.baseText('ndv.pinData.error.tooLarge.description')), i18n.baseText('ndv.pinData.error.tooLarge.title'), ); continue; } const node = tempWorkflow.nodes[nodeNameTable[nodeName]]; try { const pinnedDataForNode = usePinnedData(node); pinnedDataForNode.setData(data.pinData[nodeName], 'add-nodes'); pinDataSuccess = true; } catch (error) { pinDataSuccess = false; console.error(error); } } } // Add the nodes with the changed node names, expressions and connections if (trackBulk && trackHistory) { historyStore.startRecordingUndo(); } await addNodes(Object.values(tempWorkflow.nodes), { trackBulk: false, trackHistory, viewport, }); await addConnections( mapLegacyConnectionsToCanvasConnections( tempWorkflow.connectionsBySourceNode, Object.values(tempWorkflow.nodes), ), { trackBulk: false, trackHistory }, ); if (trackBulk && trackHistory) { historyStore.stopRecordingUndo(); } uiStore.stateIsDirty = true; return { nodes: Object.values(tempWorkflow.nodes), connections: tempWorkflow.connectionsBySourceNode, }; } async function importWorkflowData( workflowData: IWorkflowDataUpdate, source: string, { importTags = true, trackBulk = true, trackHistory = true, viewport, }: { importTags?: boolean; trackBulk?: boolean; trackHistory?: boolean; viewport?: ViewportBoundaries; } = {}, ): Promise { uiStore.resetLastInteractedWith(); // If it is JSON check if it looks on the first look like data we can use if (!workflowData.hasOwnProperty('nodes') || !workflowData.hasOwnProperty('connections')) { return {}; } try { const nodeIdMap: { [prev: string]: string } = {}; if (workflowData.nodes) { const nodeNames = new Set(workflowData.nodes.map((node) => node.name)); workflowData.nodes.forEach((node: INode) => { // Provide a new name for nodes that don't have one if (!node.name) { const nodeType = nodeTypesStore.getNodeType(node.type); const newName = uniqueNodeName( nodeType?.displayName ?? node.type, Array.from(nodeNames), ); node.name = newName; nodeNames.add(newName); } // Generate new webhookId if workflow already contains a node with the same webhookId if (node.webhookId && UPDATE_WEBHOOK_ID_NODE_TYPES.includes(node.type)) { const isDuplicate = Object.values(workflowHelpers.getCurrentWorkflow().nodes).some( (n) => n.webhookId === node.webhookId, ); if (isDuplicate) { nodeHelpers.assignWebhookId(node); if (node.parameters.path) { node.parameters.path = node.webhookId; } else if ((node.parameters.options as IDataObject).path) { (node.parameters.options as IDataObject).path = node.webhookId; } } } // Set all new ids when pasting/importing workflows if (node.id) { const previousId = node.id; const newId = nodeHelpers.assignNodeId(node); nodeIdMap[newId] = previousId; } else { nodeHelpers.assignNodeId(node); } }); } removeUnknownCredentials(workflowData); const nodeGraph = JSON.stringify( TelemetryHelpers.generateNodesGraph( workflowData as IWorkflowBase, workflowHelpers.getNodeTypes(), { nodeIdMap, sourceInstanceId: workflowData.meta && workflowData.meta.instanceId !== rootStore.instanceId ? workflowData.meta.instanceId : '', isCloudDeployment: settingsStore.isCloudDeployment, }, ).nodeGraph, ); if (source === 'paste') { telemetry.track('User pasted nodes', { workflow_id: workflowsStore.workflowId, node_graph_string: nodeGraph, }); } else if (source === 'duplicate') { telemetry.track('User duplicated nodes', { workflow_id: workflowsStore.workflowId, node_graph_string: nodeGraph, }); } else { telemetry.track('User imported workflow', { source, workflow_id: workflowsStore.workflowId, node_graph_string: nodeGraph, }); } // Fix the node position as it could be totally offscreen // and the pasted nodes would so not be directly visible to // the user workflowHelpers.updateNodePositions( workflowData, NodeViewUtils.getNewNodePosition(editableWorkflow.value.nodes, lastClickPosition.value, { ...(workflowData.nodes && workflowData.nodes.length > 1 ? { size: getNodesGroupSize(workflowData.nodes) } : {}), viewport, }), ); await addImportedNodesToWorkflow(workflowData, { trackBulk, trackHistory, viewport, }); if (importTags && settingsStore.areTagsEnabled && Array.isArray(workflowData.tags)) { await importWorkflowTags(workflowData); } return workflowData; } catch (error) { toast.showError(error, i18n.baseText('nodeView.showError.importWorkflowData.title')); return {}; } } async function importWorkflowTags(workflowData: IWorkflowDataUpdate) { const allTags = await tagsStore.fetchAll(); const tagNames = new Set(allTags.map((tag) => tag.name)); const workflowTags = workflowData.tags as ITag[]; const notFound = workflowTags.filter((tag) => !tagNames.has(tag.name)); const creatingTagPromises: Array> = []; for (const tag of notFound) { const creationPromise = tagsStore.create(tag.name).then((newTag: ITag) => { allTags.push(newTag); return newTag; }); creatingTagPromises.push(creationPromise); } await Promise.all(creatingTagPromises); const tagIds = workflowTags.reduce((accu: string[], imported: ITag) => { const tag = allTags.find((t) => t.name === imported.name); if (tag) { accu.push(tag.id); } return accu; }, []); workflowsStore.addWorkflowTagIds(tagIds); } async function fetchWorkflowDataFromUrl(url: string): Promise { let workflowData: IWorkflowDataUpdate; canvasStore.startLoading(); try { workflowData = await workflowsStore.getWorkflowFromUrl(url); } catch (error) { toast.showError(error, i18n.baseText('nodeView.showError.getWorkflowDataFromUrl.title')); return; } finally { canvasStore.stopLoading(); } return workflowData; } function getNodesToSave(nodes: INode[]): IWorkflowData { const data = { nodes: [] as INodeUi[], connections: {} as IConnections, pinData: {} as IPinData, } satisfies IWorkflowData; const exportedNodeNames = new Set(); for (const node of nodes) { const nodeSaveData = workflowHelpers.getNodeDataToSave(node); const pinDataForNode = workflowsStore.pinDataByNodeName(node.name); if (pinDataForNode) { data.pinData[node.name] = pinDataForNode; } if ( nodeSaveData.credentials && settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing] ) { nodeSaveData.credentials = filterAllowedCredentials( nodeSaveData.credentials, workflowsStore.usedCredentials, ); } data.nodes.push(nodeSaveData); exportedNodeNames.add(node.name); } data.connections = getConnectionsForNodes(data.nodes, exportedNodeNames); workflowHelpers.removeForeignCredentialsFromWorkflow(data, credentialsStore.allCredentials); return data; } function filterAllowedCredentials( credentials: INodeCredentials, usedCredentials: Record, ): INodeCredentials { return Object.fromEntries( Object.entries(credentials).filter(([, credential]) => { return ( credential.id && (!usedCredentials[credential.id] || usedCredentials[credential.id]?.currentUserHasAccess) ); }), ); } function getConnectionsForNodes( nodes: INodeUi[], includeNodeNames: Set, ): Record { const connections: Record = {}; for (const node of nodes) { const outgoingConnections = workflowsStore.outgoingConnectionsByNodeName(node.name); if (!Object.keys(outgoingConnections).length) continue; const filteredConnections = filterConnectionsByNodes(outgoingConnections, includeNodeNames); if (Object.keys(filteredConnections).length) { connections[node.name] = filteredConnections; } } return connections; } function filterConnectionsByNodes( connections: INodeConnections, includeNodeNames: Set, ): INodeConnections { const filteredConnections: INodeConnections = {}; for (const [type, typeConnections] of Object.entries(connections)) { const validConnections = typeConnections.map((sourceConnections) => (sourceConnections ?? []).filter((connection) => includeNodeNames.has(connection.node)), ); if (validConnections.length) { filteredConnections[type] = validConnections; } } return filteredConnections; } async function duplicateNodes(ids: string[], options: { viewport?: ViewportBoundaries } = {}) { const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids))); const result = await importWorkflowData(workflowData, 'duplicate', { viewport: options.viewport, importTags: false, }); return result.nodes?.map((node) => node.id).filter(isPresent) ?? []; } async function copyNodes(ids: string[]) { const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids))); workflowData.meta = { ...workflowData.meta, ...workflowsStore.workflow.meta, instanceId: rootStore.instanceId, }; await clipboard.copy(JSON.stringify(workflowData, null, 2)); telemetry.track('User copied nodes', { node_types: workflowData.nodes.map((node) => node.type), workflow_id: workflowsStore.workflowId, }); } async function cutNodes(ids: string[]) { await copyNodes(ids); deleteNodes(ids); } async function openExecution(executionId: string) { let data: IExecutionResponse | undefined; try { data = await workflowsStore.getExecution(executionId); } catch (error) { toast.showError(error, i18n.baseText('nodeView.showError.openExecution.title')); return; } if (data === undefined) { throw new Error(`Execution with id "${executionId}" could not be found!`); } if (data.status === 'error' && data.data?.resultData.error) { const { title, message } = getExecutionErrorToastConfiguration({ error: data.data.resultData.error, lastNodeExecuted: data.data.resultData.lastNodeExecuted, }); toast.showMessage({ title, message, type: 'error', duration: 0 }); } initializeWorkspace(data.workflowData); workflowsStore.setWorkflowExecutionData(data); if (!['manual', 'evaluation'].includes(data.mode)) { workflowsStore.setWorkflowPinData({}); } uiStore.stateIsDirty = false; return data; } function startChat(source?: 'node' | 'main') { if (!workflowsStore.allNodes.some(isChatNode)) { return; } const workflow = workflowsStore.getCurrentWorkflow(); logsStore.toggleOpen(true); const payload = { workflow_id: workflow.id, button_type: source, }; void externalHooks.run('nodeView.onOpenChat', payload); telemetry.track('User clicked chat open button', payload); setTimeout(() => { chatEventBus.emit('focusInput'); }, 0); } async function importTemplate({ id, name, workflow, }: { id: string | number; name?: string; workflow: IWorkflowTemplate['workflow'] | WorkflowDataWithTemplateId; }) { const convertedNodes = workflow.nodes?.map(workflowsStore.convertTemplateNodeToNodeUi); if (workflow.connections) { workflowsStore.setConnections(workflow.connections); } await addNodes(convertedNodes ?? []); await workflowsStore.getNewWorkflowDataAndMakeShareable(name, projectsStore.currentProjectId); workflowsStore.addToWorkflowMetadata({ templateId: `${id}` }); } function tryToOpenSubworkflowInNewTab(nodeId: string): boolean { const node = workflowsStore.getNodeById(nodeId); if (!node) return false; const subWorkflowId = NodeHelpers.getSubworkflowId(node); if (!subWorkflowId) return false; window.open(`${rootStore.baseUrl}workflow/${subWorkflowId}`, '_blank'); return true; } return { lastClickPosition, editableWorkflow, editableWorkflowObject, triggerNodes, requireNodeTypeDescription, addNodes, addNode, resolveNodePosition, revertAddNode, updateNodesPosition, updateNodePosition, tidyUp, revertUpdateNodePosition, setNodeActive, setNodeActiveByName, clearNodeActive, setNodeSelected, toggleNodesDisabled, revertToggleNodeDisabled, toggleNodesPinned, setNodeParameters, renameNode, revertRenameNode, replaceNodeParameters, revertReplaceNodeParameters, deleteNode, deleteNodes, copyNodes, cutNodes, duplicateNodes, getNodesToSave, revertDeleteNode, addConnections, createConnection, revertCreateConnection, deleteConnection, revertDeleteConnection, deleteConnectionsByNodeId, revalidateNodeInputConnections, revalidateNodeOutputConnections, isConnectionAllowed, filterConnectionsByNodes, connectAdjacentNodes, importWorkflowData, fetchWorkflowDataFromUrl, resetWorkspace, initializeWorkspace, resolveNodeWebhook, openExecution, startChat, importTemplate, replaceNodeConnections, tryToOpenSubworkflowInNewTab, }; }