Files
n8n-enterprise-unlocked/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts

2259 lines
63 KiB
TypeScript

/**
* 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<INodeUi> & {
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<XYPosition>([0, 0]);
const preventOpeningNDV = !!localStorage.getItem('NodeView.preventOpeningNDV');
const editableWorkflow = computed(() => workflowsStore.workflow);
const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow());
const triggerNodes = computed<INodeUi[]>(() => {
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<string, unknown>) {
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<Pick<INodeUi, 'type' | 'typeVersion'>>,
): Promise<void> {
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<INodeUi, 'position'> & { 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<NodeConnectionType | INodeOutputConfiguration> = [];
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<NodeConnectionType | INodeOutputConfiguration> = [];
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<NodeConnectionType | INodeInputConfiguration> = [];
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<IWorkflowDataUpdate> {
// 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<string>((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<IWorkflowDataUpdate> {
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<Promise<ITag>> = [];
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<IWorkflowDataUpdate | undefined> {
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<string>();
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<string, IUsedCredential>,
): 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<string>,
): Record<string, INodeConnections> {
const connections: Record<string, INodeConnections> = {};
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<string>,
): 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,
};
}