mirror of
https://github.com/n8n-io/n8n.git
synced 2024-09-11 13:15:28 +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,
|
SET_NODE_TYPE,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
const mockNode = (name: string, type: string, props: Partial<INode> = {}) =>
|
export const mockNode = ({
|
||||||
mock<INode>({ name, type, ...props });
|
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>>({
|
mock<LoadedClass<INodeType>>({
|
||||||
type: mock<INodeType>({
|
type: mock<INodeType>({
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
description: mock<INodeTypeDescription>({
|
description: mockNodeTypeDescription({ name }),
|
||||||
name,
|
|
||||||
displayName: name,
|
|
||||||
version: 1,
|
|
||||||
properties: [],
|
|
||||||
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
|
||||||
inputs: ['main'],
|
|
||||||
outputs: ['main'],
|
|
||||||
documentationUrl: 'https://docs',
|
|
||||||
webhooks: undefined,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mockNodes = [
|
export const mockNodes = [
|
||||||
mockNode('Manual Trigger', MANUAL_TRIGGER_NODE_TYPE),
|
mockNode({ name: 'Manual Trigger', type: MANUAL_TRIGGER_NODE_TYPE }),
|
||||||
mockNode('Set', SET_NODE_TYPE),
|
mockNode({ name: 'Set', type: SET_NODE_TYPE }),
|
||||||
mockNode('Code', CODE_NODE_TYPE),
|
mockNode({ name: 'Code', type: CODE_NODE_TYPE }),
|
||||||
mockNode('Rename', SET_NODE_TYPE),
|
mockNode({ name: 'Rename', type: SET_NODE_TYPE }),
|
||||||
mockNode('Chat Trigger', CHAT_TRIGGER_NODE_TYPE),
|
mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }),
|
||||||
mockNode('Agent', AGENT_NODE_TYPE),
|
mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }),
|
||||||
mockNode('End', NO_OP_NODE_TYPE),
|
mockNode({ name: 'End', type: NO_OP_NODE_TYPE }),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const defaultNodeTypes = mockNodes.reduce<INodeTypeData>((acc, { type }) => {
|
export const defaultNodeTypes = mockNodes.reduce<INodeTypeData>((acc, { type }) => {
|
||||||
acc[type] = mockLoadedClass(type);
|
acc[type] = mockLoadedNodeType(type);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { CanvasConnection, CanvasElement } from '@/types';
|
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 { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core';
|
||||||
import { Background } from '@vue-flow/background';
|
import { Background } from '@vue-flow/background';
|
||||||
import { Controls } from '@vue-flow/controls';
|
import { Controls } from '@vue-flow/controls';
|
||||||
@ -13,11 +13,13 @@ const $style = useCssModule();
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [elements: CanvasElement[]];
|
'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:active': [id: string];
|
||||||
|
'update:node:selected': [id?: string];
|
||||||
'delete:node': [id: string];
|
'delete:node': [id: string];
|
||||||
'delete:connection': [connection: Connection];
|
'delete:connection': [connection: Connection];
|
||||||
'create:connection': [connection: Connection];
|
'create:connection': [connection: Connection];
|
||||||
|
'click:pane': [position: XYPosition];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = withDefaults(
|
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>>({});
|
const hoveredEdges = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
@ -57,6 +61,11 @@ function onSetNodeActive(id: string) {
|
|||||||
emit('update:node:active', id);
|
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) {
|
function onDeleteNode(id: string) {
|
||||||
emit('delete:node', id);
|
emit('delete:node', id);
|
||||||
}
|
}
|
||||||
@ -83,6 +92,16 @@ function onMouseEnterEdge(event: EdgeMouseEvent) {
|
|||||||
function onMouseLeaveEdge(event: EdgeMouseEvent) {
|
function onMouseLeaveEdge(event: EdgeMouseEvent) {
|
||||||
hoveredEdges.value[event.edge.id] = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -99,10 +118,16 @@ function onMouseLeaveEdge(event: EdgeMouseEvent) {
|
|||||||
@node-drag-stop="onNodeDragStop"
|
@node-drag-stop="onNodeDragStop"
|
||||||
@edge-mouse-enter="onMouseEnterEdge"
|
@edge-mouse-enter="onMouseEnterEdge"
|
||||||
@edge-mouse-leave="onMouseLeaveEdge"
|
@edge-mouse-leave="onMouseLeaveEdge"
|
||||||
|
@pane-click="onClickPane"
|
||||||
@connect="onConnect"
|
@connect="onConnect"
|
||||||
>
|
>
|
||||||
<template #node-canvas-node="canvasNodeProps">
|
<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>
|
||||||
|
|
||||||
<template #edge-canvas-edge="canvasEdgeProps">
|
<template #edge-canvas-edge="canvasEdgeProps">
|
||||||
|
@ -5,6 +5,10 @@ import type { Workflow } from 'n8n-workflow';
|
|||||||
import type { IWorkflowDb } from '@/Interface';
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id?: string;
|
id?: string;
|
||||||
workflow: IWorkflowDb;
|
workflow: IWorkflowDb;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Position } from '@vue-flow/core';
|
import { Position } from '@vue-flow/core';
|
||||||
import { computed, provide, toRef } from 'vue';
|
import { computed, provide, toRef, watch } from 'vue';
|
||||||
import type {
|
import type {
|
||||||
CanvasElementData,
|
CanvasElementData,
|
||||||
CanvasConnectionPort,
|
CanvasConnectionPort,
|
||||||
@ -17,6 +17,7 @@ import type { NodeProps } from '@vue-flow/core';
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
delete: [id: string];
|
delete: [id: string];
|
||||||
|
select: [id: string, selected: boolean];
|
||||||
activate: [id: string];
|
activate: [id: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@ -36,6 +37,13 @@ const nodeType = computed(() => {
|
|||||||
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
|
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selected,
|
||||||
|
(selected) => {
|
||||||
|
emit('select', props.id, selected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inputs
|
* Inputs
|
||||||
*/
|
*/
|
||||||
|
@ -98,7 +98,7 @@ describe('GlobalExecutionsListItem', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,24 +2,47 @@ import { createPinia, setActivePinia } from 'pinia';
|
|||||||
import type { Connection } from '@vue-flow/core';
|
import type { Connection } from '@vue-flow/core';
|
||||||
import type { IConnection } from 'n8n-workflow';
|
import type { IConnection } from 'n8n-workflow';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||||
import type { CanvasElement } from '@/types';
|
import type { CanvasElement } from '@/types';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { ICredentialsResponse, INodeUi, IWorkflowDb, XYPosition } from '@/Interface';
|
||||||
import { RemoveNodeCommand } from '@/models/history';
|
import { RemoveNodeCommand } from '@/models/history';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useHistoryStore } from '@/stores/history.store';
|
import { useHistoryStore } from '@/stores/history.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.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', () => {
|
describe('useCanvasOperations', () => {
|
||||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
let uiStore: ReturnType<typeof useUIStore>;
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
let ndvStore: ReturnType<typeof useNDVStore>;
|
let ndvStore: ReturnType<typeof useNDVStore>;
|
||||||
let historyStore: ReturnType<typeof useHistoryStore>;
|
let historyStore: ReturnType<typeof useHistoryStore>;
|
||||||
|
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
|
||||||
|
let credentialsStore: ReturnType<typeof useCredentialsStore>;
|
||||||
let canvasOperations: ReturnType<typeof useCanvasOperations>;
|
let canvasOperations: ReturnType<typeof useCanvasOperations>;
|
||||||
|
|
||||||
|
const lastClickPosition = ref<XYPosition>([450, 450]);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
@ -28,7 +51,19 @@ describe('useCanvasOperations', () => {
|
|||||||
uiStore = useUIStore();
|
uiStore = useUIStore();
|
||||||
ndvStore = useNDVStore();
|
ndvStore = useNDVStore();
|
||||||
historyStore = useHistoryStore();
|
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', () => {
|
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', () => {
|
describe('deleteNode', () => {
|
||||||
it('should delete node and track history', () => {
|
it('should delete node and track history', () => {
|
||||||
const removeNodeByIdSpy = vi
|
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', () => {
|
describe('createConnection', () => {
|
||||||
it('should not create a connection if source node does not exist', () => {
|
it('should not create a connection if source node does not exist', () => {
|
||||||
const addConnectionSpy = vi
|
const addConnectionSpy = vi
|
||||||
|
@ -4,7 +4,7 @@ import { useDeviceSupport } from 'n8n-design-system';
|
|||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils';
|
import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils';
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import { useContextMenu } from './useContextMenu';
|
import { useContextMenu } from './useContextMenu';
|
||||||
|
|
||||||
@ -210,9 +210,9 @@ export default function useCanvasMouseSelect() {
|
|||||||
|
|
||||||
const instance = computed(() => canvasStore.jsPlumbInstance);
|
const instance = computed(() => canvasStore.jsPlumbInstance);
|
||||||
|
|
||||||
onMounted(() => {
|
function initializeCanvasMouseSelect() {
|
||||||
_createSelectBox();
|
_createSelectBox();
|
||||||
});
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectActive,
|
selectActive,
|
||||||
@ -222,5 +222,6 @@ export default function useCanvasMouseSelect() {
|
|||||||
nodeDeselected,
|
nodeDeselected,
|
||||||
nodeSelected,
|
nodeSelected,
|
||||||
deselectAllNodes,
|
deselectAllNodes,
|
||||||
|
initializeCanvasMouseSelect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import type { CanvasElement } from '@/types';
|
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 { QUICKSTART_NOTE_NAME, STICKY_NODE_TYPE } from '@/constants';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useHistoryStore } from '@/stores/history.store';
|
import { useHistoryStore } from '@/stores/history.store';
|
||||||
@ -13,19 +18,76 @@ import {
|
|||||||
RenameNodeCommand,
|
RenameNodeCommand,
|
||||||
} from '@/models/history';
|
} from '@/models/history';
|
||||||
import type { Connection } from '@vue-flow/core';
|
import type { Connection } from '@vue-flow/core';
|
||||||
import { getUniqueNodeName, mapCanvasConnectionToLegacyConnection } from '@/utils/canvasUtilsV2';
|
import {
|
||||||
import type { IConnection } from 'n8n-workflow';
|
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 { 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 workflowsStore = useWorkflowsStore();
|
||||||
|
const credentialsStore = useCredentialsStore();
|
||||||
const historyStore = useHistoryStore();
|
const historyStore = useHistoryStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
const canvasStore = useCanvasStore();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const externalHooks = useExternalHooks();
|
const externalHooks = useExternalHooks();
|
||||||
|
|
||||||
|
const editableWorkflow = computed(() => workflowsStore.workflow);
|
||||||
|
const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
|
||||||
|
const triggerNodes = computed<INodeUi[]>(() => {
|
||||||
|
return workflowsStore.workflowTriggerNodes;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node operations
|
* Node operations
|
||||||
*/
|
*/
|
||||||
@ -159,6 +221,532 @@ export function useCanvasOperations() {
|
|||||||
ndvStore.activeNodeName = name;
|
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
|
* Connection operations
|
||||||
*/
|
*/
|
||||||
@ -166,7 +754,7 @@ export function useCanvasOperations() {
|
|||||||
function createConnection(connection: Connection) {
|
function createConnection(connection: Connection) {
|
||||||
const sourceNode = workflowsStore.getNodeById(connection.source);
|
const sourceNode = workflowsStore.getNodeById(connection.source);
|
||||||
const targetNode = workflowsStore.getNodeById(connection.target);
|
const targetNode = workflowsStore.getNodeById(connection.target);
|
||||||
if (!sourceNode || !targetNode || !isConnectionAllowed(sourceNode, targetNode)) {
|
if (!sourceNode || !targetNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +763,11 @@ export function useCanvasOperations() {
|
|||||||
targetNode,
|
targetNode,
|
||||||
connection,
|
connection,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isConnectionAllowed(sourceNode, targetNode, mappedConnection[1].type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
workflowsStore.addConnection({
|
workflowsStore.addConnection({
|
||||||
connection: mappedConnection,
|
connection: mappedConnection,
|
||||||
});
|
});
|
||||||
@ -221,55 +814,86 @@ export function useCanvasOperations() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// @TODO Figure out a way to improve this
|
function isConnectionAllowed(
|
||||||
function isConnectionAllowed(sourceNode: INodeUi, targetNode: INodeUi): boolean {
|
sourceNode: INodeUi,
|
||||||
// const targetNodeType = nodeTypesStore.getNodeType(
|
targetNode: INodeUi,
|
||||||
// targetNode.type,
|
targetNodeConnectionType: NodeConnectionType,
|
||||||
// targetNode.typeVersion,
|
): boolean {
|
||||||
// );
|
const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion);
|
||||||
//
|
|
||||||
// if (targetNodeType?.inputs?.length) {
|
if (targetNodeType?.inputs?.length) {
|
||||||
// const workflow = this.workflowHelpers.getCurrentWorkflow();
|
const workflow = workflowsStore.getCurrentWorkflow();
|
||||||
// const workflowNode = workflow.getNode(targetNode.name);
|
const workflowNode = workflow.getNode(targetNode.name);
|
||||||
// let inputs: Array<ConnectionTypes | INodeInputConfiguration> = [];
|
if (!workflowNode) {
|
||||||
// if (targetNodeType) {
|
return false;
|
||||||
// inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType);
|
}
|
||||||
// }
|
|
||||||
//
|
let inputs: Array<ConnectionTypes | INodeInputConfiguration> = [];
|
||||||
// for (const input of inputs || []) {
|
if (targetNodeType) {
|
||||||
// if (typeof input === 'string' || input.type !== targetInfoType || !input.filter) {
|
inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType) || [];
|
||||||
// // No filters defined or wrong connection type
|
}
|
||||||
// continue;
|
|
||||||
// }
|
for (const input of inputs) {
|
||||||
//
|
if (typeof input === 'string' || input.type !== targetNodeConnectionType || !input.filter) {
|
||||||
// if (input.filter.nodes.length) {
|
// No filters defined or wrong connection type
|
||||||
// if (!input.filter.nodes.includes(sourceNode.type)) {
|
continue;
|
||||||
// this.dropPrevented = true;
|
}
|
||||||
// this.showToast({
|
|
||||||
// title: this.$locale.baseText('nodeView.showError.nodeNodeCompatible.title'),
|
if (input.filter.nodes.length) {
|
||||||
// message: this.$locale.baseText('nodeView.showError.nodeNodeCompatible.message', {
|
if (!input.filter.nodes.includes(sourceNode.type)) {
|
||||||
// interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
|
// this.dropPrevented = true;
|
||||||
// }),
|
toast.showToast({
|
||||||
// type: 'error',
|
title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'),
|
||||||
// duration: 5000,
|
message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', {
|
||||||
// });
|
interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
|
||||||
// return false;
|
}),
|
||||||
// }
|
type: 'error',
|
||||||
// }
|
duration: 5000,
|
||||||
// }
|
});
|
||||||
// }
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return sourceNode.id !== targetNode.id;
|
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 {
|
return {
|
||||||
|
editableWorkflow,
|
||||||
|
editableWorkflowObject,
|
||||||
|
triggerNodes,
|
||||||
|
lastClickPosition,
|
||||||
|
initializeNodeDataWithDefaultCredentials,
|
||||||
|
createNode,
|
||||||
|
addNodes,
|
||||||
updateNodePosition,
|
updateNodePosition,
|
||||||
setNodeActive,
|
setNodeActive,
|
||||||
setNodeActiveByName,
|
setNodeActiveByName,
|
||||||
|
setNodeSelected,
|
||||||
renameNode,
|
renameNode,
|
||||||
revertRenameNode,
|
revertRenameNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
revertDeleteNode,
|
revertDeleteNode,
|
||||||
trackDeleteNode,
|
trackDeleteNode,
|
||||||
|
addConnections,
|
||||||
createConnection,
|
createConnection,
|
||||||
deleteConnection,
|
deleteConnection,
|
||||||
revertDeleteConnection,
|
revertDeleteConnection,
|
||||||
|
@ -24,6 +24,7 @@ export const CUSTOM_API_CALL_NAME = 'Custom API Call';
|
|||||||
|
|
||||||
// workflows
|
// workflows
|
||||||
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
|
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
|
||||||
|
export const NEW_WORKFLOW_ID = 'new';
|
||||||
export const DEFAULT_NODETYPE_VERSION = 1;
|
export const DEFAULT_NODETYPE_VERSION = 1;
|
||||||
export const DEFAULT_NEW_WORKFLOW_NAME = 'My workflow';
|
export const DEFAULT_NEW_WORKFLOW_NAME = 'My workflow';
|
||||||
export const MIN_WORKFLOW_NAME_LENGTH = 1;
|
export const MIN_WORKFLOW_NAME_LENGTH = 1;
|
||||||
|
@ -26,6 +26,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
|
|||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
|
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
||||||
|
|
||||||
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
@ -67,6 +68,42 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
|||||||
openSource.value = view;
|
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({
|
function openNodeCreator({
|
||||||
source,
|
source,
|
||||||
createNodeActive,
|
createNodeActive,
|
||||||
@ -135,39 +172,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSelectiveNodeCreator({
|
function openNodeCreatorForConnectingNode(info: AIAssistantConnectionInfo) {
|
||||||
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) {
|
|
||||||
const type = info.outputType ?? NodeConnectionType.Main;
|
const type = info.outputType ?? NodeConnectionType.Main;
|
||||||
// Get the node and set it as active that new nodes
|
// Get the node and set it as active that new nodes
|
||||||
// which get created get automatically connected
|
// which get created get automatically connected
|
||||||
@ -178,8 +183,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uiStore.lastSelectedNode = sourceNode.name;
|
uiStore.lastSelectedNode = sourceNode.name;
|
||||||
uiStore.lastSelectedNodeEndpointUuid =
|
uiStore.lastSelectedNodeEndpointUuid = info.endpointUuid ?? null;
|
||||||
info.endpointUuid ?? info.connection?.target.jtk?.endpoint.uuid;
|
|
||||||
uiStore.lastSelectedNodeOutputIndex = info.index;
|
uiStore.lastSelectedNodeOutputIndex = info.index;
|
||||||
// canvasStore.newNodeInsertPosition = null;
|
// canvasStore.newNodeInsertPosition = null;
|
||||||
|
|
||||||
@ -247,6 +251,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
|||||||
setMergeNodes,
|
setMergeNodes,
|
||||||
openNodeCreator,
|
openNodeCreator,
|
||||||
openSelectiveNodeCreator,
|
openSelectiveNodeCreator,
|
||||||
|
openNodeCreatorForConnectingNode,
|
||||||
allNodeCreatorNodes,
|
allNodeCreatorNodes,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -31,6 +31,7 @@ import type {
|
|||||||
WorkflowMetadata,
|
WorkflowMetadata,
|
||||||
IExecutionFlattedResponse,
|
IExecutionFlattedResponse,
|
||||||
IWorkflowTemplateNode,
|
IWorkflowTemplateNode,
|
||||||
|
ITag,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import type {
|
import type {
|
||||||
@ -73,6 +74,7 @@ import { i18n } from '@/plugins/i18n';
|
|||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import { useTagsStore } from '@/stores/tags.store';
|
||||||
|
|
||||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||||
name: '',
|
name: '',
|
||||||
@ -1502,6 +1504,31 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
clearNodeExecutionData(node.name);
|
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
|
// End Canvas V2 Functions
|
||||||
//
|
//
|
||||||
@ -1642,5 +1669,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
removeNodeExecutionDataById,
|
removeNodeExecutionDataById,
|
||||||
setNodes,
|
setNodes,
|
||||||
setConnections,
|
setConnections,
|
||||||
|
initializeEditableWorkflow,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
getUniqueNodeName,
|
getUniqueNodeName,
|
||||||
mapCanvasConnectionToLegacyConnection,
|
mapCanvasConnectionToLegacyConnection,
|
||||||
parseCanvasConnectionHandleString,
|
parseCanvasConnectionHandleString,
|
||||||
|
createCanvasConnectionHandleString,
|
||||||
} from '@/utils/canvasUtilsV2';
|
} from '@/utils/canvasUtilsV2';
|
||||||
import { NodeConnectionType, type IConnections, type INodeTypeDescription } from 'n8n-workflow';
|
import { NodeConnectionType, type IConnections, type INodeTypeDescription } from 'n8n-workflow';
|
||||||
import type { CanvasConnection } from '@/types';
|
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', () => {
|
describe('mapCanvasConnectionToLegacyConnection', () => {
|
||||||
it('should map canvas connection to legacy connection', () => {
|
it('should map canvas connection to legacy connection', () => {
|
||||||
const sourceNode = createTestNode({ name: 'sourceNode', type: 'main' });
|
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(
|
export function mapCanvasConnectionToLegacyConnection(
|
||||||
sourceNode: INodeUi,
|
sourceNode: INodeUi,
|
||||||
targetNode: INodeUi,
|
targetNode: INodeUi,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -839,8 +839,10 @@ export default defineComponent({
|
|||||||
async mounted() {
|
async mounted() {
|
||||||
// To be refactored (unref) when migrating to composition API
|
// To be refactored (unref) when migrating to composition API
|
||||||
this.onMouseMoveEnd = this.mouseUp;
|
this.onMouseMoveEnd = this.mouseUp;
|
||||||
|
this.initializeCanvasMouseSelect();
|
||||||
|
|
||||||
this.resetWorkspace();
|
this.resetWorkspace();
|
||||||
|
|
||||||
if (!this.nodeViewRef) {
|
if (!this.nodeViewRef) {
|
||||||
this.showError(
|
this.showError(
|
||||||
new Error('NodeView reference not found'),
|
new Error('NodeView reference not found'),
|
||||||
|
Loading…
Reference in New Issue
Block a user