mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(editor): Compute node position and connections when creating new nodes in new canvas (no-changelog) (#9830)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user