feat(editor): Compute node position and connections when creating new nodes in new canvas (no-changelog) (#9830)

This commit is contained in:
Alex Grozav
2024-06-25 12:11:44 +03:00
committed by GitHub
parent b55fc60993
commit 31c456700a
15 changed files with 1628 additions and 858 deletions

View File

@@ -2,24 +2,47 @@ import { createPinia, setActivePinia } from 'pinia';
import type { Connection } from '@vue-flow/core';
import type { IConnection } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import type { CanvasElement } from '@/types';
import type { INodeUi } from '@/Interface';
import type { ICredentialsResponse, INodeUi, IWorkflowDb, XYPosition } from '@/Interface';
import { RemoveNodeCommand } from '@/models/history';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useHistoryStore } from '@/stores/history.store';
import { useNDVStore } from '@/stores/ndv.store';
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
import { ref } from 'vue';
import {
createTestNode,
createTestWorkflowObject,
mockNode,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { useRouter } from 'vue-router';
import { mock } from 'vitest-mock-extended';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
vi.mock('vue-router', async () => {
const actual = await import('vue-router');
return {
...actual,
useRouter: () => ({}),
};
});
describe('useCanvasOperations', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let ndvStore: ReturnType<typeof useNDVStore>;
let historyStore: ReturnType<typeof useHistoryStore>;
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let credentialsStore: ReturnType<typeof useCredentialsStore>;
let canvasOperations: ReturnType<typeof useCanvasOperations>;
const lastClickPosition = ref<XYPosition>([450, 450]);
const router = useRouter();
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
@@ -28,7 +51,19 @@ describe('useCanvasOperations', () => {
uiStore = useUIStore();
ndvStore = useNDVStore();
historyStore = useHistoryStore();
canvasOperations = useCanvasOperations();
nodeTypesStore = useNodeTypesStore();
credentialsStore = useCredentialsStore();
const workflowId = 'test';
workflowsStore.workflowsById[workflowId] = mock<IWorkflowDb>({
id: workflowId,
nodes: [],
tags: [],
usedCredentials: [],
});
workflowsStore.initializeEditableWorkflow(workflowId);
canvasOperations = useCanvasOperations({ router, lastClickPosition });
});
describe('updateNodePosition', () => {
@@ -53,6 +88,218 @@ describe('useCanvasOperations', () => {
});
});
describe('setNodeSelected', () => {
it('should set last selected node when node id is provided and node exists', () => {
const nodeId = 'node1';
const nodeName = 'Node 1';
workflowsStore.getNodeById = vi.fn().mockReturnValue({ name: nodeName });
uiStore.lastSelectedNode = '';
canvasOperations.setNodeSelected(nodeId);
expect(uiStore.lastSelectedNode).toBe(nodeName);
});
it('should not change last selected node when node id is provided but node does not exist', () => {
const nodeId = 'node1';
workflowsStore.getNodeById = vi.fn().mockReturnValue(undefined);
uiStore.lastSelectedNode = 'Existing Node';
canvasOperations.setNodeSelected(nodeId);
expect(uiStore.lastSelectedNode).toBe('Existing Node');
});
it('should clear last selected node when node id is not provided', () => {
uiStore.lastSelectedNode = 'Existing Node';
canvasOperations.setNodeSelected();
expect(uiStore.lastSelectedNode).toBe('');
});
});
describe('initializeNodeDataWithDefaultCredentials', () => {
it('should throw error when node type does not exist', async () => {
vi.spyOn(nodeTypesStore, 'getNodeTypes').mockResolvedValue(undefined);
await expect(
canvasOperations.initializeNodeDataWithDefaultCredentials({ type: 'nonexistent' }),
).rejects.toThrow();
});
it('should create node with default version when version is undefined', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
name: 'example',
type: 'type',
});
expect(result.typeVersion).toBe(1);
});
it('should create node with last version when version is an array', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type', version: [1, 2] })]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: 'type',
});
expect(result.typeVersion).toBe(2);
});
it('should create node with default position when position is not provided', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: 'type',
});
expect(result.position).toEqual([0, 0]);
});
it('should create node with provided position when position is provided', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: 'type',
position: [10, 20],
});
expect(result.position).toEqual([10, 20]);
});
it('should create node with default credentials when only one credential is available', async () => {
const credential = mock<ICredentialsResponse>({ id: '1', name: 'cred', type: 'cred' });
const nodeTypeName = 'type';
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({ name: nodeTypeName, credentials: [{ name: credential.name }] }),
]);
credentialsStore.addCredentials([credential]);
// @ts-expect-error Known pinia issue when spying on store getters
vi.spyOn(credentialsStore, 'getUsableCredentialByType', 'get').mockReturnValue(() => [
credential,
]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: nodeTypeName,
});
expect(result.credentials).toEqual({ [credential.name]: { id: '1', name: credential.name } });
});
it('should not assign credentials when multiple credentials are available', async () => {
const credentialA = mock<ICredentialsResponse>({ id: '1', name: 'credA', type: 'cred' });
const credentialB = mock<ICredentialsResponse>({ id: '1', name: 'credB', type: 'cred' });
const nodeTypeName = 'type';
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: nodeTypeName,
credentials: [{ name: credentialA.name }, { name: credentialB.name }],
}),
]);
// @ts-expect-error Known pinia issue when spying on store getters
vi.spyOn(credentialsStore, 'getUsableCredentialByType', 'get').mockReturnValue(() => [
credentialA,
credentialB,
]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: 'type',
});
expect(result.credentials).toBeUndefined();
});
});
describe('addNodes', () => {
it('should add nodes at specified positions', async () => {
const nodeTypeName = 'type';
const nodes = [
mockNode({ name: 'Node 1', type: nodeTypeName, position: [30, 40] }),
mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }),
];
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: nodeTypeName,
}),
]);
await canvasOperations.addNodes(nodes, {});
expect(workflowsStore.workflow.nodes).toHaveLength(2);
expect(workflowsStore.workflow.nodes[0]).toHaveProperty('name', nodes[0].name);
expect(workflowsStore.workflow.nodes[0]).toHaveProperty('parameters', {});
expect(workflowsStore.workflow.nodes[0]).toHaveProperty('type', nodeTypeName);
expect(workflowsStore.workflow.nodes[0]).toHaveProperty('typeVersion', 1);
expect(workflowsStore.workflow.nodes[0]).toHaveProperty('position');
});
it('should add nodes at current position when position is not specified', async () => {
const nodeTypeName = 'type';
const nodes = [
mockNode({ name: 'Node 1', type: nodeTypeName, position: [40, 40] }),
mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }),
];
const workflowStoreAddNodeSpy = vi.spyOn(workflowsStore, 'addNode');
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: nodeTypeName,
}),
]);
await canvasOperations.addNodes(nodes, { position: [50, 60] });
expect(workflowStoreAddNodeSpy).toHaveBeenCalledTimes(2);
expect(workflowStoreAddNodeSpy.mock.calls[0][0].position).toEqual(
expect.arrayContaining(nodes[0].position),
);
expect(workflowStoreAddNodeSpy.mock.calls[1][0].position).toEqual(
expect.arrayContaining(nodes[1].position),
);
});
it('should adjust the position of nodes with multiple inputs', async () => {
const nodeTypeName = 'type';
const nodes = [
mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }),
mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [40, 40] }),
mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [100, 240] }),
];
const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById');
vi.spyOn(workflowsStore, 'getNodeByName')
.mockReturnValueOnce(nodes[1])
.mockReturnValueOnce(nodes[2]);
vi.spyOn(workflowsStore, 'getNodeById')
.mockReturnValueOnce(nodes[1])
.mockReturnValueOnce(nodes[2]);
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: nodeTypeName,
}),
]);
canvasOperations.editableWorkflowObject.value.getParentNodesByDepth = vi
.fn()
.mockReturnValue(nodes.map((node) => node.name));
await canvasOperations.addNodes(nodes, {});
expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2);
expect(setNodePositionByIdSpy).toHaveBeenCalledWith(nodes[1].id, expect.any(Object));
expect(setNodePositionByIdSpy).toHaveBeenCalledWith(nodes[2].id, expect.any(Object));
});
});
describe('deleteNode', () => {
it('should delete node and track history', () => {
const removeNodeByIdSpy = vi
@@ -225,6 +472,53 @@ describe('useCanvasOperations', () => {
});
});
describe('addConnections', () => {
it('should create connections between nodes', async () => {
const nodeTypeName = 'type';
const nodes = [
mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }),
mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [40, 40] }),
];
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: nodeTypeName,
}),
]);
await canvasOperations.addNodes(nodes, {});
vi.spyOn(workflowsStore, 'getNodeById')
.mockReturnValueOnce(nodes[0])
.mockReturnValueOnce(nodes[1]);
const connections = [
{ from: { nodeIndex: 0, outputIndex: 0 }, to: { nodeIndex: 1, inputIndex: 0 } },
{ from: { nodeIndex: 1, outputIndex: 0 }, to: { nodeIndex: 2, inputIndex: 0 } },
];
const offsetIndex = 0;
const addConnectionSpy = vi.spyOn(workflowsStore, 'addConnection');
await canvasOperations.addConnections(connections, { offsetIndex });
expect(addConnectionSpy).toHaveBeenCalledWith({
connection: [
{
index: 0,
node: 'Node B',
type: 'main',
},
{
index: 0,
node: 'spy',
type: 'main',
},
],
});
});
});
describe('createConnection', () => {
it('should not create a connection if source node does not exist', () => {
const addConnectionSpy = vi

View File

@@ -4,7 +4,7 @@ import { useDeviceSupport } from 'n8n-design-system';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils';
import { ref, onMounted, computed } from 'vue';
import { ref, computed } from 'vue';
import { useCanvasStore } from '@/stores/canvas.store';
import { useContextMenu } from './useContextMenu';
@@ -210,9 +210,9 @@ export default function useCanvasMouseSelect() {
const instance = computed(() => canvasStore.jsPlumbInstance);
onMounted(() => {
function initializeCanvasMouseSelect() {
_createSelectBox();
});
}
return {
selectActive,
@@ -222,5 +222,6 @@ export default function useCanvasMouseSelect() {
nodeDeselected,
nodeSelected,
deselectAllNodes,
initializeCanvasMouseSelect,
};
}

View File

@@ -1,5 +1,10 @@
import type { CanvasElement } from '@/types';
import type { INodeUi, XYPosition } from '@/Interface';
import type {
AddedNodesAndConnections,
INodeUi,
INodeUpdatePropertiesInformation,
XYPosition,
} from '@/Interface';
import { QUICKSTART_NOTE_NAME, STICKY_NODE_TYPE } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useHistoryStore } from '@/stores/history.store';
@@ -13,19 +18,76 @@ import {
RenameNodeCommand,
} from '@/models/history';
import type { Connection } from '@vue-flow/core';
import { getUniqueNodeName, mapCanvasConnectionToLegacyConnection } from '@/utils/canvasUtilsV2';
import type { IConnection } from 'n8n-workflow';
import {
getUniqueNodeName,
mapCanvasConnectionToLegacyConnection,
parseCanvasConnectionHandleString,
} from '@/utils/canvasUtilsV2';
import type {
ConnectionTypes,
IConnection,
INodeInputConfiguration,
INodeOutputConfiguration,
INodeTypeDescription,
INodeTypeNameVersion,
ITelemetryTrackProperties,
} from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { v4 as uuid } from 'uuid';
import { useSegment } from '@/stores/segment.store';
import type { Ref } from 'vue';
import { computed } from 'vue';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import type { useRouter } from 'vue-router';
import { useCanvasStore } from '@/stores/canvas.store';
export function useCanvasOperations() {
type AddNodeData = {
name?: string;
type: string;
position?: XYPosition;
};
type AddNodeOptions = {
dragAndDrop?: boolean;
openNDV?: boolean;
trackHistory?: boolean;
isAutoAdd?: boolean;
};
export function useCanvasOperations({
router,
lastClickPosition,
}: {
router: ReturnType<typeof useRouter>;
lastClickPosition: Ref<XYPosition>;
}) {
const workflowsStore = useWorkflowsStore();
const credentialsStore = useCredentialsStore();
const historyStore = useHistoryStore();
const uiStore = useUIStore();
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
const canvasStore = useCanvasStore();
const i18n = useI18n();
const toast = useToast();
const workflowHelpers = useWorkflowHelpers({ router });
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
const editableWorkflow = computed(() => workflowsStore.workflow);
const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow());
const triggerNodes = computed<INodeUi[]>(() => {
return workflowsStore.workflowTriggerNodes;
});
/**
* Node operations
*/
@@ -159,6 +221,532 @@ export function useCanvasOperations() {
ndvStore.activeNodeName = name;
}
function setNodeSelected(id?: string) {
if (!id) {
uiStore.lastSelectedNode = '';
return;
}
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
}
uiStore.lastSelectedNode = node.name;
}
async function addNodes(
nodes: AddedNodesAndConnections['nodes'],
{
dragAndDrop,
position,
}: {
dragAndDrop?: boolean;
position?: XYPosition;
} = {},
) {
let currentPosition = position;
let lastAddedNode: INodeUi | undefined;
for (const { type, name, position: nodePosition, isAutoAdd, openDetail } of nodes) {
try {
await createNode(
{
name,
type,
position: nodePosition ?? currentPosition,
},
{
dragAndDrop,
openNDV: openDetail ?? false,
trackHistory: true,
isAutoAdd,
},
);
} catch (error) {
toast.showError(error, i18n.baseText('error'));
continue;
}
lastAddedNode = editableWorkflow.value.nodes[editableWorkflow.value.nodes.length - 1];
currentPosition = [
lastAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2 + NodeViewUtils.GRID_SIZE,
lastAddedNode.position[1],
];
}
// If the last added node has multiple inputs, move them down
if (!lastAddedNode) {
return;
}
const lastNodeInputs = editableWorkflowObject.value.getParentNodesByDepth(
lastAddedNode.name,
1,
);
if (lastNodeInputs.length > 1) {
lastNodeInputs.slice(1).forEach((node, index) => {
const nodeUi = workflowsStore.getNodeByName(node.name);
if (!nodeUi) return;
updateNodePosition(nodeUi.id, {
x: nodeUi.position[0],
y: nodeUi.position[1] + 100 * (index + 1),
});
});
}
}
async function createNode(node: AddNodeData, options: AddNodeOptions = {}): Promise<INodeUi> {
const newNodeData = await resolveNodeData(node, options);
if (!newNodeData) {
throw new Error(i18n.baseText('nodeViewV2.showError.failedToCreateNode'));
}
/**
* @TODO Check if maximum node type limit reached
*/
newNodeData.name = getUniqueNodeName(newNodeData.name, workflowsStore.canvasNames);
workflowsStore.addNode(newNodeData);
// @TODO Figure out why this is needed and if we can do better...
// this.matchCredentials(node);
const lastSelectedNode = uiStore.getLastSelectedNode;
const lastSelectedNodeOutputIndex = uiStore.lastSelectedNodeOutputIndex;
const lastSelectedNodeEndpointUuid = uiStore.lastSelectedNodeEndpointUuid;
historyStore.startRecordingUndo();
const outputIndex = lastSelectedNodeOutputIndex ?? 0;
const targetEndpoint = lastSelectedNodeEndpointUuid ?? '';
// Handle connection of scoped_endpoint types
if (lastSelectedNode && !options.isAutoAdd) {
if (lastSelectedNodeEndpointUuid) {
const { type: connectionType } = parseCanvasConnectionHandleString(
lastSelectedNodeEndpointUuid,
);
if (isConnectionAllowed(lastSelectedNode, newNodeData, connectionType)) {
createConnection({
source: lastSelectedNode.id,
sourceHandle: targetEndpoint,
target: newNodeData.id,
targetHandle: `inputs/${connectionType}/0`,
});
}
} 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: lastSelectedNode.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/${outputIndex}`,
target: newNodeData.id,
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
});
}
}
historyStore.stopRecordingUndo();
return newNodeData;
}
async function initializeNodeDataWithDefaultCredentials(node: AddNodeData) {
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type);
if (!nodeTypeDescription) {
throw new Error(i18n.baseText('nodeViewV2.showError.failedToCreateNode'));
}
let nodeVersion = nodeTypeDescription.defaultVersion;
if (typeof nodeVersion === 'undefined') {
nodeVersion = Array.isArray(nodeTypeDescription.version)
? nodeTypeDescription.version.slice(-1)[0]
: nodeTypeDescription.version;
}
const newNodeData: INodeUi = {
id: uuid(),
name: node.name ?? (nodeTypeDescription.defaults.name as string),
type: nodeTypeDescription.name,
typeVersion: nodeVersion,
position: node.position ?? [0, 0],
parameters: {},
};
await loadNodeTypesProperties([{ name: newNodeData.type, version: newNodeData.typeVersion }]);
const nodeType = nodeTypesStore.getNodeType(newNodeData.type, newNodeData.typeVersion);
const nodeParameters = NodeHelpers.getNodeParameters(
nodeType?.properties ?? [],
{},
true,
false,
newNodeData,
);
newNodeData.parameters = nodeParameters ?? {};
const credentialPerType = nodeTypeDescription.credentials
?.map((type) => credentialsStore.getUsableCredentialByType(type.name))
.flat();
if (credentialPerType?.length === 1) {
const defaultCredential = credentialPerType[0];
const selectedCredentials = credentialsStore.getCredentialById(defaultCredential.id);
const selected = { id: selectedCredentials.id, name: selectedCredentials.name };
const credentials = {
[defaultCredential.type]: selected,
};
if (nodeTypeDescription.credentials) {
const authentication = nodeTypeDescription.credentials.find(
(type) => type.name === defaultCredential.type,
);
if (authentication?.displayOptions?.hide) {
return newNodeData;
}
const authDisplayOptions = authentication?.displayOptions?.show;
if (!authDisplayOptions) {
newNodeData.credentials = credentials;
return newNodeData;
}
if (Object.keys(authDisplayOptions).length === 1 && authDisplayOptions.authentication) {
// ignore complex case when there's multiple dependencies
newNodeData.credentials = credentials;
let parameters: { [key: string]: string } = {};
for (const displayOption of Object.keys(authDisplayOptions)) {
if (nodeParameters && !nodeParameters[displayOption]) {
parameters = {};
newNodeData.credentials = undefined;
break;
}
const optionValue = authDisplayOptions[displayOption]?.[0];
if (optionValue && typeof optionValue === 'string') {
parameters[displayOption] = optionValue;
}
newNodeData.parameters = {
...newNodeData.parameters,
...parameters,
};
}
}
}
}
return newNodeData;
}
/**
* Resolves the data for a new node
*/
async function resolveNodeData(node: AddNodeData, options: AddNodeOptions = {}) {
const nodeTypeDescription: INodeTypeDescription | null = nodeTypesStore.getNodeType(node.type);
if (nodeTypeDescription === null) {
toast.showMessage({
title: i18n.baseText('nodeView.showMessage.addNodeButton.title'),
message: i18n.baseText('nodeView.showMessage.addNodeButton.message', {
interpolate: { nodeTypeName: node.type },
}),
type: 'error',
});
return;
}
if (
nodeTypeDescription.maxNodes !== undefined &&
workflowHelpers.getNodeTypeCount(node.type) >= nodeTypeDescription.maxNodes
) {
showMaxNodeTypeError(nodeTypeDescription);
return;
}
const newNodeData = await initializeNodeDataWithDefaultCredentials(node);
// When pulling new connection from node or injecting into a connection
const lastSelectedNode = uiStore.getLastSelectedNode;
if (node.position) {
newNodeData.position = NodeViewUtils.getNewNodePosition(
canvasStore.getNodesWithPlaceholderNode(),
node.position,
);
} else if (lastSelectedNode) {
// @TODO Implement settings lastSelectedConnection for new canvas
const lastSelectedConnection = canvasStore.lastSelectedConnection;
if (lastSelectedConnection) {
// set when injecting into a connection
const [diffX] = NodeViewUtils.getConnectorLengths(lastSelectedConnection);
if (diffX <= NodeViewUtils.MAX_X_TO_PUSH_DOWNSTREAM_NODES) {
pushDownstreamNodes(lastSelectedNode.name, NodeViewUtils.PUSH_NODES_OFFSET, {
trackHistory: options.trackHistory,
});
}
}
// This position is set in `onMouseUp` when pulling connections
if (canvasStore.newNodeInsertPosition) {
newNodeData.position = NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, [
canvasStore.newNodeInsertPosition[0] + NodeViewUtils.GRID_SIZE,
canvasStore.newNodeInsertPosition[1] - NodeViewUtils.NODE_SIZE / 2,
]);
canvasStore.newNodeInsertPosition = null;
} else {
let yOffset = 0;
const workflow = workflowsStore.getCurrentWorkflow();
if (lastSelectedConnection) {
const sourceNodeType = nodeTypesStore.getNodeType(
lastSelectedNode.type,
lastSelectedNode.typeVersion,
);
if (sourceNodeType) {
const offsets = [
[-100, 100],
[-140, 0, 140],
[-240, -100, 100, 240],
];
const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
workflow,
lastSelectedNode,
sourceNodeType,
);
const sourceNodeOutputTypes = NodeHelpers.getConnectionTypes(sourceNodeOutputs);
const sourceNodeOutputMainOutputs = sourceNodeOutputTypes.filter(
(output) => output === NodeConnectionType.Main,
);
if (sourceNodeOutputMainOutputs.length > 1) {
const offset = offsets[sourceNodeOutputMainOutputs.length - 2];
const sourceOutputIndex = lastSelectedConnection.__meta
? lastSelectedConnection.__meta.sourceOutputIndex
: 0;
yOffset = offset[sourceOutputIndex];
}
}
}
let outputs: Array<ConnectionTypes | 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(workflow, newNodeData, nodeTypeDescription);
} catch (e) {}
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
const lastSelectedNodeType = nodeTypesStore.getNodeType(
lastSelectedNode.type,
lastSelectedNode.typeVersion,
);
// If node has only scoped outputs, position it below the last selected node
if (
outputTypes.length > 0 &&
outputTypes.every((outputName) => outputName !== NodeConnectionType.Main)
) {
const lastSelectedNodeWorkflow = workflow.getNode(lastSelectedNode.name);
if (!lastSelectedNodeWorkflow || !lastSelectedNodeType) {
return;
}
const lastSelectedInputs = NodeHelpers.getNodeInputs(
workflow,
lastSelectedNodeWorkflow,
lastSelectedNodeType,
);
const lastSelectedInputTypes = NodeHelpers.getConnectionTypes(lastSelectedInputs);
const scopedConnectionIndex = (lastSelectedInputTypes || [])
.filter((input) => input !== NodeConnectionType.Main)
.findIndex((inputType) => outputs[0] === inputType);
newNodeData.position = NodeViewUtils.getNewNodePosition(
workflowsStore.allNodes,
[
lastSelectedNode.position[0] +
(NodeViewUtils.NODE_SIZE /
(Math.max(lastSelectedNodeType?.inputs?.length ?? 1), 1)) *
scopedConnectionIndex,
lastSelectedNode.position[1] + NodeViewUtils.PUSH_NODES_OFFSET,
],
[100, 0],
);
} else {
if (!lastSelectedNodeType) {
return;
}
// Has only main outputs or no outputs at all
const inputs = NodeHelpers.getNodeInputs(
workflow,
lastSelectedNode,
lastSelectedNodeType,
);
const inputsTypes = NodeHelpers.getConnectionTypes(inputs);
let pushOffset = NodeViewUtils.PUSH_NODES_OFFSET;
if (!!inputsTypes.find((input) => input !== NodeConnectionType.Main)) {
// If the node has scoped inputs, push it down a bit more
pushOffset += 150;
}
// If a node is active then add the new node directly after the current one
newNodeData.position = NodeViewUtils.getNewNodePosition(
workflowsStore.allNodes,
[lastSelectedNode.position[0] + pushOffset, lastSelectedNode.position[1] + yOffset],
[100, 0],
);
}
}
} else {
// If added node is a trigger and it's the first one added to the canvas
// we place it at canvasAddButtonPosition to replace the canvas add button
const position =
nodeTypesStore.isTriggerNode(node.type) && triggerNodes.value.length === 0
? canvasStore.canvasAddButtonPosition
: // If no node is active find a free spot
(lastClickPosition.value as XYPosition);
newNodeData.position = NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position);
}
const localizedName = i18n.localizeNodeName(newNodeData.name, newNodeData.type);
newNodeData.name = getUniqueNodeName(localizedName, workflowsStore.canvasNames);
if (nodeTypeDescription.webhooks?.length) {
newNodeData.webhookId = uuid();
}
workflowsStore.setNodePristine(newNodeData.name, true);
uiStore.stateIsDirty = true;
if (node.type === STICKY_NODE_TYPE) {
telemetry.trackNodesPanel('nodeView.addSticky', {
workflow_id: workflowsStore.workflowId,
});
} else {
void externalHooks.run('nodeView.addNodeButton', { nodeTypeName: node.type });
useSegment().trackAddedTrigger(node.type);
const trackProperties: ITelemetryTrackProperties = {
node_type: node.type,
node_version: newNodeData.typeVersion,
is_auto_add: options.isAutoAdd,
workflow_id: workflowsStore.workflowId,
drag_and_drop: options.dragAndDrop,
};
if (lastSelectedNode) {
trackProperties.input_node_type = lastSelectedNode.type;
}
telemetry.trackNodesPanel('nodeView.addNodeButton', trackProperties);
}
// Automatically deselect all nodes and select the current one and also active
// current node. But only if it's added manually by the user (not by undo/redo mechanism)
// @TODO
// if (trackHistory) {
// this.deselectAllNodes();
// setTimeout(() => {
// this.nodeSelectedByName(newNodeData.name, showDetail && nodeTypeName !== STICKY_NODE_TYPE);
// });
// }
return newNodeData;
}
function pushDownstreamNodes(
sourceNodeName: string,
margin: number,
{ trackHistory = false }: { trackHistory?: boolean },
) {
const sourceNode = workflowsStore.nodesByName[sourceNodeName];
const workflow = workflowHelpers.getCurrentWorkflow();
const checkNodes = workflowHelpers.getConnectedNodes('downstream', workflow, sourceNodeName);
for (const nodeName of checkNodes) {
const node = workflowsStore.nodesByName[nodeName];
const oldPosition = node.position;
if (node.position[0] < sourceNode.position[0]) {
continue;
}
const updateInformation: INodeUpdatePropertiesInformation = {
name: nodeName,
properties: {
position: [node.position[0] + margin, node.position[1]],
},
};
workflowsStore.updateNodeProperties(updateInformation);
updateNodePosition(node.id, { x: node.position[0], y: node.position[1] });
if (
(trackHistory && oldPosition[0] !== updateInformation.properties.position[0]) ||
oldPosition[1] !== updateInformation.properties.position[1]
) {
historyStore.pushCommandToUndo(
new MoveNodeCommand(nodeName, oldPosition, updateInformation.properties.position),
trackHistory,
);
}
}
}
async function loadNodeTypesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
const allNodeTypeDescriptions: INodeTypeDescription[] = nodeTypesStore.allNodeTypes;
const nodesToBeFetched: INodeTypeNameVersion[] = [];
allNodeTypeDescriptions.forEach((nodeTypeDescription) => {
const nodeVersions = Array.isArray(nodeTypeDescription.version)
? nodeTypeDescription.version
: [nodeTypeDescription.version];
if (
!!nodeInfos.find(
(n) => n.name === nodeTypeDescription.name && nodeVersions.includes(n.version),
) &&
!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 showMaxNodeTypeError(nodeTypeDescription: INodeTypeDescription) {
const maxNodes = nodeTypeDescription.maxNodes;
toast.showMessage({
title: i18n.baseText('nodeView.showMessage.showMaxNodeTypeError.title'),
message: i18n.baseText('nodeView.showMessage.showMaxNodeTypeError.message', {
adjustToNumber: maxNodes,
interpolate: { nodeTypeDataDisplayName: nodeTypeDescription.displayName },
}),
type: 'error',
duration: 0,
});
}
/**
* Connection operations
*/
@@ -166,7 +754,7 @@ export function useCanvasOperations() {
function createConnection(connection: Connection) {
const sourceNode = workflowsStore.getNodeById(connection.source);
const targetNode = workflowsStore.getNodeById(connection.target);
if (!sourceNode || !targetNode || !isConnectionAllowed(sourceNode, targetNode)) {
if (!sourceNode || !targetNode) {
return;
}
@@ -175,6 +763,11 @@ export function useCanvasOperations() {
targetNode,
connection,
);
if (!isConnectionAllowed(sourceNode, targetNode, mappedConnection[1].type)) {
return;
}
workflowsStore.addConnection({
connection: mappedConnection,
});
@@ -221,55 +814,86 @@ export function useCanvasOperations() {
});
}
// @TODO Figure out a way to improve this
function isConnectionAllowed(sourceNode: INodeUi, targetNode: INodeUi): boolean {
// const targetNodeType = nodeTypesStore.getNodeType(
// targetNode.type,
// targetNode.typeVersion,
// );
//
// if (targetNodeType?.inputs?.length) {
// const workflow = this.workflowHelpers.getCurrentWorkflow();
// const workflowNode = workflow.getNode(targetNode.name);
// let inputs: Array<ConnectionTypes | INodeInputConfiguration> = [];
// if (targetNodeType) {
// inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType);
// }
//
// for (const input of inputs || []) {
// if (typeof input === 'string' || input.type !== targetInfoType || !input.filter) {
// // No filters defined or wrong connection type
// continue;
// }
//
// if (input.filter.nodes.length) {
// if (!input.filter.nodes.includes(sourceNode.type)) {
// this.dropPrevented = true;
// this.showToast({
// title: this.$locale.baseText('nodeView.showError.nodeNodeCompatible.title'),
// message: this.$locale.baseText('nodeView.showError.nodeNodeCompatible.message', {
// interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
// }),
// type: 'error',
// duration: 5000,
// });
// return false;
// }
// }
// }
// }
function isConnectionAllowed(
sourceNode: INodeUi,
targetNode: INodeUi,
targetNodeConnectionType: NodeConnectionType,
): boolean {
const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion);
if (targetNodeType?.inputs?.length) {
const workflow = workflowsStore.getCurrentWorkflow();
const workflowNode = workflow.getNode(targetNode.name);
if (!workflowNode) {
return false;
}
let inputs: Array<ConnectionTypes | INodeInputConfiguration> = [];
if (targetNodeType) {
inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType) || [];
}
for (const input of inputs) {
if (typeof input === 'string' || input.type !== targetNodeConnectionType || !input.filter) {
// No filters defined or wrong connection type
continue;
}
if (input.filter.nodes.length) {
if (!input.filter.nodes.includes(sourceNode.type)) {
// this.dropPrevented = true;
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 sourceNode.id !== targetNode.id;
}
async function addConnections(
connections: AddedNodesAndConnections['connections'],
{ offsetIndex }: { offsetIndex: number },
) {
for (const { from, to } of connections) {
const fromNode = editableWorkflow.value.nodes[offsetIndex + from.nodeIndex];
const toNode = editableWorkflow.value.nodes[offsetIndex + to.nodeIndex];
createConnection({
source: fromNode.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/${from.outputIndex ?? 0}`,
target: toNode.id,
targetHandle: `inputs/${NodeConnectionType.Main}/${to.inputIndex ?? 0}`,
});
}
}
return {
editableWorkflow,
editableWorkflowObject,
triggerNodes,
lastClickPosition,
initializeNodeDataWithDefaultCredentials,
createNode,
addNodes,
updateNodePosition,
setNodeActive,
setNodeActiveByName,
setNodeSelected,
renameNode,
revertRenameNode,
deleteNode,
revertDeleteNode,
trackDeleteNode,
addConnections,
createConnection,
deleteConnection,
revertDeleteConnection,