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

This commit is contained in:
Alex Grozav 2024-06-25 12:11:44 +03:00 committed by GitHub
parent b55fc60993
commit 31c456700a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1628 additions and 858 deletions

View file

@ -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;
}, {});

View file

@ -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">

View file

@ -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;

View file

@ -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
*/

View file

@ -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();
});
});

View file

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

View file

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

View file

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

View file

@ -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;

View file

@ -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,
};
});

View file

@ -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,
};
});

View file

@ -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' });

View file

@ -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

View file

@ -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'),