mirror of
https://github.com/n8n-io/n8n.git
synced 2024-08-17 00:50:42 +03:00
feat(editor): Compute node position and connections when creating new nodes in new canvas (no-changelog) (#9830)
This commit is contained in:
parent
b55fc60993
commit
31c456700a
@ -24,39 +24,65 @@ import {
|
||||
SET_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
|
||||
const mockNode = (name: string, type: string, props: Partial<INode> = {}) =>
|
||||
mock<INode>({ name, type, ...props });
|
||||
export const mockNode = ({
|
||||
id = uuid(),
|
||||
name,
|
||||
type,
|
||||
position = [0, 0],
|
||||
}: {
|
||||
id?: INode['id'];
|
||||
name: INode['name'];
|
||||
type: INode['type'];
|
||||
position?: INode['position'];
|
||||
}) => mock<INode>({ id, name, type, position });
|
||||
|
||||
const mockLoadedClass = (name: string) =>
|
||||
export const mockNodeTypeDescription = ({
|
||||
name,
|
||||
version = 1,
|
||||
credentials = [],
|
||||
}: {
|
||||
name: INodeTypeDescription['name'];
|
||||
version?: INodeTypeDescription['version'];
|
||||
credentials?: INodeTypeDescription['credentials'];
|
||||
}) =>
|
||||
mock<INodeTypeDescription>({
|
||||
name,
|
||||
displayName: name,
|
||||
version,
|
||||
defaults: {
|
||||
name,
|
||||
},
|
||||
defaultVersion: Array.isArray(version) ? version[version.length - 1] : version,
|
||||
properties: [],
|
||||
maxNodes: Infinity,
|
||||
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials,
|
||||
documentationUrl: 'https://docs',
|
||||
webhooks: undefined,
|
||||
});
|
||||
|
||||
export const mockLoadedNodeType = (name: string) =>
|
||||
mock<LoadedClass<INodeType>>({
|
||||
type: mock<INodeType>({
|
||||
// @ts-expect-error
|
||||
description: mock<INodeTypeDescription>({
|
||||
name,
|
||||
displayName: name,
|
||||
version: 1,
|
||||
properties: [],
|
||||
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
documentationUrl: 'https://docs',
|
||||
webhooks: undefined,
|
||||
}),
|
||||
description: mockNodeTypeDescription({ name }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const mockNodes = [
|
||||
mockNode('Manual Trigger', MANUAL_TRIGGER_NODE_TYPE),
|
||||
mockNode('Set', SET_NODE_TYPE),
|
||||
mockNode('Code', CODE_NODE_TYPE),
|
||||
mockNode('Rename', SET_NODE_TYPE),
|
||||
mockNode('Chat Trigger', CHAT_TRIGGER_NODE_TYPE),
|
||||
mockNode('Agent', AGENT_NODE_TYPE),
|
||||
mockNode('End', NO_OP_NODE_TYPE),
|
||||
mockNode({ name: 'Manual Trigger', type: MANUAL_TRIGGER_NODE_TYPE }),
|
||||
mockNode({ name: 'Set', type: SET_NODE_TYPE }),
|
||||
mockNode({ name: 'Code', type: CODE_NODE_TYPE }),
|
||||
mockNode({ name: 'Rename', type: SET_NODE_TYPE }),
|
||||
mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }),
|
||||
mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }),
|
||||
mockNode({ name: 'End', type: NO_OP_NODE_TYPE }),
|
||||
];
|
||||
|
||||
export const defaultNodeTypes = mockNodes.reduce<INodeTypeData>((acc, { type }) => {
|
||||
acc[type] = mockLoadedClass(type);
|
||||
acc[type] = mockLoadedNodeType(type);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CanvasConnection, CanvasElement } from '@/types';
|
||||
import type { EdgeMouseEvent, NodeDragEvent, Connection } from '@vue-flow/core';
|
||||
import type { EdgeMouseEvent, NodeDragEvent, Connection, XYPosition } from '@vue-flow/core';
|
||||
import { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core';
|
||||
import { Background } from '@vue-flow/background';
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
@ -13,11 +13,13 @@ const $style = useCssModule();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [elements: CanvasElement[]];
|
||||
'update:node:position': [id: string, position: { x: number; y: number }];
|
||||
'update:node:position': [id: string, position: XYPosition];
|
||||
'update:node:active': [id: string];
|
||||
'update:node:selected': [id?: string];
|
||||
'delete:node': [id: string];
|
||||
'delete:connection': [connection: Connection];
|
||||
'create:connection': [connection: Connection];
|
||||
'click:pane': [position: XYPosition];
|
||||
}>();
|
||||
|
||||
const props = withDefaults(
|
||||
@ -35,7 +37,9 @@ const props = withDefaults(
|
||||
},
|
||||
);
|
||||
|
||||
const { getSelectedEdges, getSelectedNodes } = useVueFlow({ id: props.id });
|
||||
const { getSelectedEdges, getSelectedNodes, viewportRef, project } = useVueFlow({
|
||||
id: props.id,
|
||||
});
|
||||
|
||||
const hoveredEdges = ref<Record<string, boolean>>({});
|
||||
|
||||
@ -57,6 +61,11 @@ function onSetNodeActive(id: string) {
|
||||
emit('update:node:active', id);
|
||||
}
|
||||
|
||||
function onSelectNode() {
|
||||
const selectedNodeId = getSelectedNodes.value[getSelectedNodes.value.length - 1]?.id;
|
||||
emit('update:node:selected', selectedNodeId);
|
||||
}
|
||||
|
||||
function onDeleteNode(id: string) {
|
||||
emit('delete:node', id);
|
||||
}
|
||||
@ -83,6 +92,16 @@ function onMouseEnterEdge(event: EdgeMouseEvent) {
|
||||
function onMouseLeaveEdge(event: EdgeMouseEvent) {
|
||||
hoveredEdges.value[event.edge.id] = false;
|
||||
}
|
||||
|
||||
function onClickPane(event: MouseEvent) {
|
||||
const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||
const position = project({
|
||||
x: event.offsetX - bounds.left,
|
||||
y: event.offsetY - bounds.top,
|
||||
});
|
||||
|
||||
emit('click:pane', position);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -99,10 +118,16 @@ function onMouseLeaveEdge(event: EdgeMouseEvent) {
|
||||
@node-drag-stop="onNodeDragStop"
|
||||
@edge-mouse-enter="onMouseEnterEdge"
|
||||
@edge-mouse-leave="onMouseLeaveEdge"
|
||||
@pane-click="onClickPane"
|
||||
@connect="onConnect"
|
||||
>
|
||||
<template #node-canvas-node="canvasNodeProps">
|
||||
<CanvasNode v-bind="canvasNodeProps" @delete="onDeleteNode" @activate="onSetNodeActive" />
|
||||
<CanvasNode
|
||||
v-bind="canvasNodeProps"
|
||||
@delete="onDeleteNode"
|
||||
@select="onSelectNode"
|
||||
@activate="onSetNodeActive"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #edge-canvas-edge="canvasEdgeProps">
|
||||
|
@ -5,6 +5,10 @@ import type { Workflow } from 'n8n-workflow';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string;
|
||||
workflow: IWorkflowDb;
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { Position } from '@vue-flow/core';
|
||||
import { computed, provide, toRef } from 'vue';
|
||||
import { computed, provide, toRef, watch } from 'vue';
|
||||
import type {
|
||||
CanvasElementData,
|
||||
CanvasConnectionPort,
|
||||
@ -17,6 +17,7 @@ import type { NodeProps } from '@vue-flow/core';
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [id: string];
|
||||
select: [id: string, selected: boolean];
|
||||
activate: [id: string];
|
||||
}>();
|
||||
|
||||
@ -36,6 +37,13 @@ const nodeType = computed(() => {
|
||||
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.selected,
|
||||
(selected) => {
|
||||
emit('select', props.id, selected);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Inputs
|
||||
*/
|
||||
|
@ -98,7 +98,7 @@ describe('GlobalExecutionsListItem', () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
getByText(`1 Jan, 2022 at ${DateTime.fromJSDate(new Date(testDate)).toFormat('hh')}:00:00`),
|
||||
getByText(`1 Jan, 2022 at ${DateTime.fromJSDate(new Date(testDate)).toFormat('HH')}:00:00`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -24,6 +24,7 @@ export const CUSTOM_API_CALL_NAME = 'Custom API Call';
|
||||
|
||||
// workflows
|
||||
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
|
||||
export const NEW_WORKFLOW_ID = 'new';
|
||||
export const DEFAULT_NODETYPE_VERSION = 1;
|
||||
export const DEFAULT_NEW_WORKFLOW_NAME = 'My workflow';
|
||||
export const MIN_WORKFLOW_NAME_LENGTH = 1;
|
||||
|
@ -26,6 +26,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
||||
|
||||
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
@ -67,6 +68,42 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||
openSource.value = view;
|
||||
}
|
||||
|
||||
function openSelectiveNodeCreator({
|
||||
connectionType,
|
||||
node,
|
||||
creatorView,
|
||||
}: {
|
||||
connectionType: NodeConnectionType;
|
||||
node: string;
|
||||
creatorView?: NodeFilterType;
|
||||
}) {
|
||||
const nodeName = node ?? ndvStore.activeNodeName;
|
||||
const nodeData = nodeName ? workflowsStore.getNodeByName(nodeName) : null;
|
||||
|
||||
ndvStore.activeNodeName = null;
|
||||
|
||||
setTimeout(() => {
|
||||
if (creatorView) {
|
||||
openNodeCreator({
|
||||
createNodeActive: true,
|
||||
nodeCreatorView: creatorView,
|
||||
});
|
||||
} else if (connectionType && nodeData) {
|
||||
openNodeCreatorForConnectingNode({
|
||||
index: 0,
|
||||
endpointUuid: createCanvasConnectionHandleString({
|
||||
mode: 'inputs',
|
||||
type: connectionType,
|
||||
index: 0,
|
||||
}),
|
||||
eventSource: NODE_CREATOR_OPEN_SOURCES.NOTICE_ERROR_MESSAGE,
|
||||
outputType: connectionType,
|
||||
sourceId: nodeData.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openNodeCreator({
|
||||
source,
|
||||
createNodeActive,
|
||||
@ -135,39 +172,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||
});
|
||||
}
|
||||
|
||||
function openSelectiveNodeCreator({
|
||||
connectionType,
|
||||
node,
|
||||
creatorView,
|
||||
}: {
|
||||
connectionType: NodeConnectionType;
|
||||
node: string;
|
||||
creatorView?: NodeFilterType;
|
||||
}) {
|
||||
const nodeName = node ?? ndvStore.activeNodeName;
|
||||
const nodeData = nodeName ? workflowsStore.getNodeByName(nodeName) : null;
|
||||
|
||||
ndvStore.activeNodeName = null;
|
||||
|
||||
setTimeout(() => {
|
||||
if (creatorView) {
|
||||
openNodeCreator({
|
||||
createNodeActive: true,
|
||||
nodeCreatorView: creatorView,
|
||||
});
|
||||
} else if (connectionType && nodeData) {
|
||||
insertNodeAfterSelected({
|
||||
index: 0,
|
||||
endpointUuid: `${nodeData.id}-input${connectionType}0`,
|
||||
eventSource: NODE_CREATOR_OPEN_SOURCES.NOTICE_ERROR_MESSAGE,
|
||||
outputType: connectionType,
|
||||
sourceId: nodeData.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function insertNodeAfterSelected(info: AIAssistantConnectionInfo) {
|
||||
function openNodeCreatorForConnectingNode(info: AIAssistantConnectionInfo) {
|
||||
const type = info.outputType ?? NodeConnectionType.Main;
|
||||
// Get the node and set it as active that new nodes
|
||||
// which get created get automatically connected
|
||||
@ -178,8 +183,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||
}
|
||||
|
||||
uiStore.lastSelectedNode = sourceNode.name;
|
||||
uiStore.lastSelectedNodeEndpointUuid =
|
||||
info.endpointUuid ?? info.connection?.target.jtk?.endpoint.uuid;
|
||||
uiStore.lastSelectedNodeEndpointUuid = info.endpointUuid ?? null;
|
||||
uiStore.lastSelectedNodeOutputIndex = info.index;
|
||||
// canvasStore.newNodeInsertPosition = null;
|
||||
|
||||
@ -247,6 +251,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||
setMergeNodes,
|
||||
openNodeCreator,
|
||||
openSelectiveNodeCreator,
|
||||
openNodeCreatorForConnectingNode,
|
||||
allNodeCreatorNodes,
|
||||
};
|
||||
});
|
||||
|
@ -31,6 +31,7 @@ import type {
|
||||
WorkflowMetadata,
|
||||
IExecutionFlattedResponse,
|
||||
IWorkflowTemplateNode,
|
||||
ITag,
|
||||
} from '@/Interface';
|
||||
import { defineStore } from 'pinia';
|
||||
import type {
|
||||
@ -73,6 +74,7 @@ import { i18n } from '@/plugins/i18n';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
|
||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||
name: '',
|
||||
@ -1502,6 +1504,31 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
clearNodeExecutionData(node.name);
|
||||
}
|
||||
|
||||
function initializeEditableWorkflow(id: string) {
|
||||
const targetWorkflow = workflowsById.value[id];
|
||||
const tags = (targetWorkflow?.tags ?? []) as ITag[];
|
||||
const tagIds = tags.map((tag) => tag.id);
|
||||
|
||||
addWorkflow(targetWorkflow);
|
||||
setWorkflow(targetWorkflow);
|
||||
setActive(targetWorkflow.active || false);
|
||||
setWorkflowId(targetWorkflow.id);
|
||||
setWorkflowName({ newName: targetWorkflow.name, setStateDirty: false });
|
||||
setWorkflowSettings(targetWorkflow.settings ?? {});
|
||||
setWorkflowPinData(targetWorkflow.pinData ?? {});
|
||||
setWorkflowVersionId(targetWorkflow.versionId);
|
||||
setWorkflowMetadata(targetWorkflow.meta);
|
||||
if (targetWorkflow.usedCredentials) {
|
||||
setUsedCredentials(targetWorkflow.usedCredentials);
|
||||
}
|
||||
setWorkflowTagIds(tagIds || []);
|
||||
|
||||
if (tags.length > 0) {
|
||||
const tagsStore = useTagsStore();
|
||||
tagsStore.upsertTags(tags);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// End Canvas V2 Functions
|
||||
//
|
||||
@ -1642,5 +1669,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
removeNodeExecutionDataById,
|
||||
setNodes,
|
||||
setConnections,
|
||||
initializeEditableWorkflow,
|
||||
};
|
||||
});
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
getUniqueNodeName,
|
||||
mapCanvasConnectionToLegacyConnection,
|
||||
parseCanvasConnectionHandleString,
|
||||
createCanvasConnectionHandleString,
|
||||
} from '@/utils/canvasUtilsV2';
|
||||
import { NodeConnectionType, type IConnections, type INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { CanvasConnection } from '@/types';
|
||||
@ -477,6 +478,35 @@ describe('parseCanvasConnectionHandleString', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCanvasConnectionHandleString', () => {
|
||||
it('should create handle string with default values', () => {
|
||||
const result = createCanvasConnectionHandleString({ mode: 'inputs' });
|
||||
expect(result).toBe('inputs/main/0');
|
||||
});
|
||||
|
||||
it('should create handle string with provided values', () => {
|
||||
const result = createCanvasConnectionHandleString({
|
||||
mode: 'outputs',
|
||||
type: NodeConnectionType.AiMemory,
|
||||
index: 2,
|
||||
});
|
||||
expect(result).toBe(`outputs/${NodeConnectionType.AiMemory}/2`);
|
||||
});
|
||||
|
||||
it('should create handle string with mode and type only', () => {
|
||||
const result = createCanvasConnectionHandleString({
|
||||
mode: 'inputs',
|
||||
type: NodeConnectionType.AiTool,
|
||||
});
|
||||
expect(result).toBe(`inputs/${NodeConnectionType.AiTool}/0`);
|
||||
});
|
||||
|
||||
it('should create handle string with mode and index only', () => {
|
||||
const result = createCanvasConnectionHandleString({ mode: 'outputs', index: 3 });
|
||||
expect(result).toBe('outputs/main/3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapCanvasConnectionToLegacyConnection', () => {
|
||||
it('should map canvas connection to legacy connection', () => {
|
||||
const sourceNode = createTestNode({ name: 'sourceNode', type: 'main' });
|
||||
|
@ -68,6 +68,18 @@ export function parseCanvasConnectionHandleString(handle: string | null | undefi
|
||||
};
|
||||
}
|
||||
|
||||
export function createCanvasConnectionHandleString({
|
||||
mode,
|
||||
type = NodeConnectionType.Main,
|
||||
index = 0,
|
||||
}: {
|
||||
mode: 'inputs' | 'outputs';
|
||||
type?: NodeConnectionType;
|
||||
index?: number;
|
||||
}) {
|
||||
return `${mode}/${type}/${index}`;
|
||||
}
|
||||
|
||||
export function mapCanvasConnectionToLegacyConnection(
|
||||
sourceNode: INodeUi,
|
||||
targetNode: INodeUi,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -839,8 +839,10 @@ export default defineComponent({
|
||||
async mounted() {
|
||||
// To be refactored (unref) when migrating to composition API
|
||||
this.onMouseMoveEnd = this.mouseUp;
|
||||
this.initializeCanvasMouseSelect();
|
||||
|
||||
this.resetWorkspace();
|
||||
|
||||
if (!this.nodeViewRef) {
|
||||
this.showError(
|
||||
new Error('NodeView reference not found'),
|
||||
|
Loading…
Reference in New Issue
Block a user