feat(editor): Add ability to add a node between two nodes in new canvas (no-changelog) (#10006)

This commit is contained in:
Alex Grozav 2024-07-11 17:05:51 +03:00 committed by GitHub
parent d651be4e01
commit 1aae65dfdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 517 additions and 146 deletions

View file

@ -24,6 +24,7 @@ const emit = defineEmits<{
'create:connection': [connection: Connection];
'create:connection:end': [connection: Connection];
'create:connection:cancelled': [handle: ConnectStartEvent];
'click:connection:add': [connection: Connection];
'click:pane': [position: XYPosition];
}>();
@ -123,6 +124,10 @@ function onDeleteConnection(connection: Connection) {
emit('delete:connection', connection);
}
function onClickConnectionAdd(connection: Connection) {
emit('click:connection:add', connection);
}
function onRunNode(id: string) {
emit('run:node', id);
}
@ -190,6 +195,7 @@ function onClickPane(event: MouseEvent) {
<Edge
v-bind="canvasEdgeProps"
:hovered="hoveredEdges[canvasEdgeProps.id]"
@add="onClickConnectionAdd"
@delete="onDeleteConnection"
/>
</template>

View file

@ -5,8 +5,11 @@ import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@vue-flow/core';
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
import { computed, useCssModule } from 'vue';
import type { CanvasConnectionData } from '@/types';
import { NodeConnectionType } from 'n8n-workflow';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
const emit = defineEmits<{
add: [connection: Connection];
delete: [connection: Connection];
}>();
@ -18,6 +21,12 @@ const props = defineProps<CanvasEdgeProps>();
const $style = useCssModule();
const connectionType = computed(() =>
isValidNodeConnectionType(props.data.source.type)
? props.data.source.type
: NodeConnectionType.Main,
);
const isFocused = computed(() => props.selected || props.hovered);
const status = computed(() => props.data.status);
@ -86,6 +95,10 @@ const connection = computed<Connection>(() => ({
targetHandle: props.targetHandleId,
}));
function onAdd() {
emit('add', connection.value);
}
function onDelete() {
emit('delete', connection.value);
}
@ -105,7 +118,13 @@ function onDelete() {
:label-show-bg="false"
/>
<EdgeLabelRenderer>
<CanvasEdgeToolbar :class="edgeToolbarClasses" :style="edgeToolbarStyle" @delete="onDelete" />
<CanvasEdgeToolbar
:type="connectionType"
:class="edgeToolbarClasses"
:style="edgeToolbarStyle"
@add="onAdd"
@delete="onDelete"
/>
</EdgeLabelRenderer>
</template>

View file

@ -1,11 +1,17 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { computed, useCssModule } from 'vue';
import { NodeConnectionType } from 'n8n-workflow';
const emit = defineEmits<{
add: [];
delete: [];
}>();
const props = defineProps<{
type: NodeConnectionType;
}>();
const $style = useCssModule();
const i18n = useI18n();
@ -14,6 +20,12 @@ const classes = computed(() => ({
[$style.canvasEdgeToolbar]: true,
}));
const isAddButtonVisible = computed(() => props.type === NodeConnectionType.Main);
function onAdd() {
emit('add');
}
function onDelete() {
emit('delete');
}
@ -21,6 +33,15 @@ function onDelete() {
<template>
<div :class="classes" data-test-id="canvas-edge-toolbar">
<N8nIconButton
v-if="isAddButtonVisible"
data-test-id="add-connection-button"
type="tertiary"
size="small"
icon="plus"
:title="i18n.baseText('node.add')"
@click="onAdd"
/>
<N8nIconButton
data-test-id="delete-connection-button"
type="tertiary"
@ -37,6 +58,7 @@ function onDelete() {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-2xs);
pointer-events: all;
}
</style>

View file

@ -15,6 +15,11 @@ import {
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import {
createCanvasConnectionHandleString,
createCanvasConnectionId,
} from '@/utils/canvasUtilsV2';
import { CanvasConnectionMode } from '@/types';
beforeEach(() => {
const pinia = createPinia();
@ -243,6 +248,25 @@ describe('useCanvasMapping', () => {
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
const source = manualTriggerNode.id;
const sourceHandle = createCanvasConnectionHandleString({
type: NodeConnectionType.Main,
index: 0,
mode: CanvasConnectionMode.Output,
});
const target = setNode.id;
const targetHandle = createCanvasConnectionHandleString({
type: NodeConnectionType.Main,
index: 0,
mode: CanvasConnectionMode.Input,
});
const connectionId = createCanvasConnectionId({
source,
sourceHandle,
target,
targetHandle,
});
expect(mappedConnections.value).toEqual([
{
data: {
@ -257,12 +281,12 @@ describe('useCanvasMapping', () => {
type: NodeConnectionType.Main,
},
},
id: `[${manualTriggerNode.id}/${NodeConnectionType.Main}/0][${setNode.id}/${NodeConnectionType.Main}/0]`,
id: connectionId,
label: '',
source: manualTriggerNode.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/0`,
target: setNode.id,
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
source,
sourceHandle,
target,
targetHandle,
type: 'canvas-edge',
animated: false,
},
@ -293,6 +317,44 @@ describe('useCanvasMapping', () => {
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
const sourceA = manualTriggerNode.id;
const sourceHandleA = createCanvasConnectionHandleString({
type: NodeConnectionType.AiTool,
index: 0,
mode: CanvasConnectionMode.Output,
});
const targetA = setNode.id;
const targetHandleA = createCanvasConnectionHandleString({
type: NodeConnectionType.AiTool,
index: 0,
mode: CanvasConnectionMode.Input,
});
const connectionIdA = createCanvasConnectionId({
source: sourceA,
sourceHandle: sourceHandleA,
target: targetA,
targetHandle: targetHandleA,
});
const sourceB = manualTriggerNode.id;
const sourceHandleB = createCanvasConnectionHandleString({
type: NodeConnectionType.AiDocument,
index: 0,
mode: CanvasConnectionMode.Output,
});
const targetB = setNode.id;
const targetHandleB = createCanvasConnectionHandleString({
type: NodeConnectionType.AiDocument,
index: 1,
mode: CanvasConnectionMode.Input,
});
const connectionIdB = createCanvasConnectionId({
source: sourceB,
sourceHandle: sourceHandleB,
target: targetB,
targetHandle: targetHandleB,
});
expect(mappedConnections.value).toEqual([
{
data: {
@ -307,12 +369,12 @@ describe('useCanvasMapping', () => {
type: NodeConnectionType.AiTool,
},
},
id: `[${manualTriggerNode.id}/${NodeConnectionType.AiTool}/0][${setNode.id}/${NodeConnectionType.AiTool}/0]`,
id: connectionIdA,
label: '',
source: manualTriggerNode.id,
sourceHandle: `outputs/${NodeConnectionType.AiTool}/0`,
target: setNode.id,
targetHandle: `inputs/${NodeConnectionType.AiTool}/0`,
source: sourceA,
sourceHandle: sourceHandleA,
target: targetA,
targetHandle: targetHandleA,
type: 'canvas-edge',
animated: false,
},
@ -329,12 +391,12 @@ describe('useCanvasMapping', () => {
type: NodeConnectionType.AiDocument,
},
},
id: `[${manualTriggerNode.id}/${NodeConnectionType.AiDocument}/0][${setNode.id}/${NodeConnectionType.AiDocument}/1]`,
id: connectionIdB,
label: '',
source: manualTriggerNode.id,
sourceHandle: `outputs/${NodeConnectionType.AiDocument}/0`,
target: setNode.id,
targetHandle: `inputs/${NodeConnectionType.AiDocument}/1`,
source: sourceB,
sourceHandle: sourceHandleB,
target: targetB,
targetHandle: targetHandleB,
type: 'canvas-edge',
animated: false,
},

View file

@ -5,12 +5,7 @@
import type { CanvasNode } from '@/types';
import { CanvasConnectionMode } from '@/types';
import type {
AddedNodesAndConnections,
INodeUi,
INodeUpdatePropertiesInformation,
XYPosition,
} from '@/Interface';
import type { AddedNodesAndConnections, INodeUi, XYPosition } from '@/Interface';
import {
FORM_TRIGGER_NODE_TYPE,
QUICKSTART_NOTE_NAME,
@ -31,7 +26,9 @@ import {
} from '@/models/history';
import type { Connection } from '@vue-flow/core';
import {
createCanvasConnectionHandleString,
getUniqueNodeName,
getVueFlowConnectorLengths,
mapCanvasConnectionToLegacyConnection,
parseCanvasConnectionHandleString,
} from '@/utils/canvasUtilsV2';
@ -334,6 +331,7 @@ export function useCanvasOperations({
nodeHelpers.matchCredentials(newNodeData);
const lastSelectedNode = uiStore.getLastSelectedNode;
const lastSelectedNodeConnection = uiStore.lastSelectedNodeConnection;
const lastSelectedNodeOutputIndex = uiStore.lastSelectedNodeOutputIndex;
const lastSelectedNodeEndpointUuid = uiStore.lastSelectedNodeEndpointUuid;
@ -378,11 +376,37 @@ export function useCanvasOperations({
// Connect active node to the newly created one
createConnection({
source: lastSelectedNode.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/${outputIndex}`,
sourceHandle: createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: outputIndex,
}),
target: newNodeData.id,
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
targetHandle: createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
}),
});
}
if (lastSelectedNodeConnection) {
deleteConnection(lastSelectedNodeConnection, { trackHistory: options.trackHistory });
const targetNode = workflowsStore.getNodeById(lastSelectedNodeConnection.target);
if (targetNode) {
createConnection({
source: newNodeData.id,
sourceHandle: createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
}),
target: lastSelectedNodeConnection.target,
targetHandle: lastSelectedNodeConnection.targetHandle,
});
}
}
}
historyStore.stopRecordingUndo();
@ -516,11 +540,10 @@ export function useCanvasOperations({
node.position,
);
} else if (lastSelectedNode) {
// @TODO Implement settings lastSelectedConnection for new canvas
const lastSelectedConnection = canvasStore.lastSelectedConnection;
if (lastSelectedConnection) {
if (uiStore.lastSelectedNodeConnection) {
// set when injecting into a connection
const [diffX] = NodeViewUtils.getConnectorLengths(lastSelectedConnection);
const [diffX] = getVueFlowConnectorLengths(uiStore.lastSelectedNodeConnection);
if (diffX <= NodeViewUtils.MAX_X_TO_PUSH_DOWNSTREAM_NODES) {
pushDownstreamNodes(lastSelectedNode.name, NodeViewUtils.PUSH_NODES_OFFSET, {
trackHistory: options.trackHistory,
@ -537,7 +560,7 @@ export function useCanvasOperations({
canvasStore.newNodeInsertPosition = null;
} else {
let yOffset = 0;
if (lastSelectedConnection) {
if (uiStore.lastSelectedNodeConnection) {
const sourceNodeType = nodeTypesStore.getNodeType(
lastSelectedNode.type,
lastSelectedNode.typeVersion,
@ -556,16 +579,15 @@ export function useCanvasOperations({
sourceNodeType,
);
const sourceNodeOutputTypes = NodeHelpers.getConnectionTypes(sourceNodeOutputs);
const sourceNodeOutputMainOutputs = sourceNodeOutputTypes.filter(
(output) => output === NodeConnectionType.Main,
);
if (sourceNodeOutputMainOutputs.length > 1) {
const { index: sourceOutputIndex } = parseCanvasConnectionHandleString(
uiStore.lastSelectedNodeConnection.sourceHandle,
);
const offset = offsets[sourceNodeOutputMainOutputs.length - 2];
const sourceOutputIndex = lastSelectedConnection.__meta
? lastSelectedConnection.__meta.sourceOutputIndex
: 0;
yOffset = offset[sourceOutputIndex];
}
}
@ -729,31 +751,18 @@ export function useCanvasOperations({
);
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]],
updateNodePosition(
node.id,
{
x: node.position[0] + margin,
y: 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,
);
}
{ trackHistory },
);
}
}

View file

@ -960,6 +960,7 @@
"node.disable": "Deactivate",
"node.enable": "Activate",
"node.delete": "Delete",
"node.add": "Add",
"node.issues": "Issues",
"node.nodeIsExecuting": "Node is executing",
"node.nodeIsWaitingTill": "Node is waiting until {date} {time}",

View file

@ -12,7 +12,6 @@ import type {
SimplifiedNodeType,
ActionsRecord,
ToggleNodeCreatorOptions,
NewConnectionInfo,
} from '@/Interface';
import { computed, ref } from 'vue';
@ -26,7 +25,14 @@ 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';
import {
createCanvasConnectionHandleString,
parseCanvasConnectionHandleString,
} from '@/utils/canvasUtilsV2';
import type { Connection } from '@vue-flow/core';
import { CanvasConnectionMode } from '@/types';
import { isVueFlowConnection } from '@/utils/typeGuards';
import type { PartialBy } from '@/utils/typeHelpers';
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
const workflowsStore = useWorkflowsStore();
@ -90,15 +96,15 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
});
} else if (connectionType && nodeData) {
openNodeCreatorForConnectingNode({
index: 0,
endpointUuid: createCanvasConnectionHandleString({
mode: 'inputs',
type: connectionType,
index: 0,
}),
connection: {
source: nodeData.id,
sourceHandle: createCanvasConnectionHandleString({
mode: 'inputs',
type: connectionType,
index: 0,
}),
},
eventSource: NODE_CREATOR_OPEN_SOURCES.NOTICE_ERROR_MESSAGE,
outputType: connectionType,
sourceId: nodeData.id,
});
}
});
@ -172,35 +178,43 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
});
}
function openNodeCreatorForConnectingNode(info: NewConnectionInfo) {
const type = info.outputType ?? NodeConnectionType.Main;
function openNodeCreatorForConnectingNode({
connection,
eventSource,
nodeCreatorView,
}: {
connection: PartialBy<Connection, 'target' | 'targetHandle'>;
eventSource?: NodeCreatorOpenSource;
nodeCreatorView?: NodeFilterType;
}) {
// Get the node and set it as active that new nodes
// which get created get automatically connected
// to it.
const sourceNode = workflowsStore.getNodeById(info.sourceId);
const sourceNode = workflowsStore.getNodeById(connection.source);
if (!sourceNode) {
return;
}
const { type, index, mode } = parseCanvasConnectionHandleString(connection.sourceHandle);
uiStore.lastSelectedNode = sourceNode.name;
uiStore.lastSelectedNodeEndpointUuid = info.endpointUuid ?? null;
uiStore.lastSelectedNodeOutputIndex = info.index;
uiStore.lastSelectedNodeEndpointUuid = connection.sourceHandle ?? null;
uiStore.lastSelectedNodeOutputIndex = index;
// canvasStore.newNodeInsertPosition = null;
// @TODO Add connection to store
// if (info.connection) {
// canvasStore.setLastSelectedConnection(info.connection);
// }
if (isVueFlowConnection(connection)) {
uiStore.lastSelectedNodeConnection = connection;
}
openNodeCreator({
source: info.eventSource,
source: eventSource,
createNodeActive: true,
nodeCreatorView: info.nodeCreatorView,
nodeCreatorView,
});
// TODO: The animation is a bit glitchy because we're updating view stack immediately
// after the node creator is opened
const isOutput = info.connection?.endpoints[0].parameters.connection === 'source';
const isOutput = mode === CanvasConnectionMode.Output;
const isScopedConnection =
type !== NodeConnectionType.Main && nodeConnectionTypes.includes(type);

View file

@ -69,6 +69,7 @@ import {
updateTheme,
} from './ui.utils';
import { computed, ref } from 'vue';
import type { Connection } from '@vue-flow/core';
let savedTheme: ThemeOption = 'system';
try {
@ -177,6 +178,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
const lastSelectedNode = ref<string | null>(null);
const lastSelectedNodeOutputIndex = ref<number | null>(null);
const lastSelectedNodeEndpointUuid = ref<string | null>(null);
const lastSelectedNodeConnection = ref<Connection | null>(null);
const nodeViewOffsetPosition = ref<[number, number]>([0, 0]);
const nodeViewMoveInProgress = ref<boolean>(false);
const selectedNodes = ref<INodeUi[]>([]);
@ -619,6 +621,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
selectedNodes,
bannersHeight,
lastSelectedNodeEndpointUuid,
lastSelectedNodeConnection,
nodeViewOffsetPosition,
nodeViewMoveInProgress,
nodeViewInitialized,

View file

@ -5,12 +5,14 @@ import {
mapCanvasConnectionToLegacyConnection,
parseCanvasConnectionHandleString,
createCanvasConnectionHandleString,
createCanvasConnectionId,
} from '@/utils/canvasUtilsV2';
import { NodeConnectionType, type IConnections, type INodeTypeDescription } from 'n8n-workflow';
import type { CanvasConnection } from '@/types';
import type { INodeUi } from '@/Interface';
import type { Connection } from '@vue-flow/core';
import { createTestNode } from '@/__tests__/mocks';
import { CanvasConnectionMode } from '@/types';
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-uuid'),
@ -47,15 +49,34 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
nodes,
);
const source = nodes[0].id;
const sourceHandle = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 0,
});
const target = nodes[1].id;
const targetHandle = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
});
const id = createCanvasConnectionId({
source,
target,
sourceHandle,
targetHandle,
});
expect(result).toEqual([
{
id: '[1/main/0][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
id,
source,
target,
sourceHandle,
targetHandle,
data: {
fromNodeName: 'Node A',
fromNodeName: nodes[0].name,
source: {
index: 0,
type: NodeConnectionType.Main,
@ -147,15 +168,53 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
nodes,
);
const sourceA = nodes[0].id;
const sourceHandleA = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 0,
});
const targetA = nodes[1].id;
const targetHandleA = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
});
const connectionIdA = createCanvasConnectionId({
source: sourceA,
target: targetA,
sourceHandle: sourceHandleA,
targetHandle: targetHandleA,
});
const sourceB = nodes[0].id;
const sourceHandleB = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 1,
});
const targetB = nodes[1].id;
const targetHandleB = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 1,
});
const connectionIdB = createCanvasConnectionId({
source: sourceB,
target: targetB,
sourceHandle: sourceHandleB,
targetHandle: targetHandleB,
});
expect(result).toEqual([
{
id: '[1/main/0][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
id: connectionIdA,
source: sourceA,
target: targetA,
sourceHandle: sourceHandleA,
targetHandle: targetHandleA,
data: {
fromNodeName: 'Node A',
fromNodeName: nodes[0].name,
source: {
index: 0,
type: NodeConnectionType.Main,
@ -167,13 +226,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
},
},
{
id: '[1/main/1][2/main/1]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/1',
targetHandle: 'inputs/main/1',
id: connectionIdB,
source: sourceA,
target: targetB,
sourceHandle: sourceHandleB,
targetHandle: targetHandleB,
data: {
fromNodeName: 'Node A',
fromNodeName: nodes[0].name,
source: {
index: 1,
type: NodeConnectionType.Main,
@ -228,15 +287,53 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
nodes,
);
const sourceA = nodes[0].id;
const sourceHandleA = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 0,
});
const targetA = nodes[1].id;
const targetHandleA = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
});
const connectionIdA = createCanvasConnectionId({
source: sourceA,
target: targetA,
sourceHandle: sourceHandleA,
targetHandle: targetHandleA,
});
const sourceB = nodes[0].id;
const sourceHandleB = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 1,
});
const targetB = nodes[2].id;
const targetHandleB = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
});
const connectionIdB = createCanvasConnectionId({
source: sourceB,
target: targetB,
sourceHandle: sourceHandleB,
targetHandle: targetHandleB,
});
expect(result).toEqual([
{
id: '[1/main/0][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
id: connectionIdA,
source: sourceA,
target: targetA,
sourceHandle: sourceHandleA,
targetHandle: targetHandleA,
data: {
fromNodeName: 'Node A',
fromNodeName: nodes[0].name,
source: {
index: 0,
type: NodeConnectionType.Main,
@ -248,13 +345,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
},
},
{
id: '[1/main/1][3/main/0]',
source: '1',
target: '3',
sourceHandle: 'outputs/main/1',
targetHandle: 'inputs/main/0',
id: connectionIdB,
source: sourceB,
target: targetB,
sourceHandle: sourceHandleB,
targetHandle: targetHandleB,
data: {
fromNodeName: 'Node A',
fromNodeName: nodes[0].name,
source: {
index: 1,
type: NodeConnectionType.Main,
@ -312,15 +409,72 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
nodes,
);
const sourceA = nodes[0].id;
const sourceHandleA = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 0,
});
const targetA = nodes[1].id;
const targetHandleA = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
});
const connectionIdA = createCanvasConnectionId({
source: sourceA,
target: targetA,
sourceHandle: sourceHandleA,
targetHandle: targetHandleA,
});
const sourceB = nodes[0].id;
const sourceHandleB = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.AiMemory,
index: 0,
});
const targetB = nodes[2].id;
const targetHandleB = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.AiMemory,
index: 1,
});
const connectionIdB = createCanvasConnectionId({
source: sourceB,
target: targetB,
sourceHandle: sourceHandleB,
targetHandle: targetHandleB,
});
const sourceC = nodes[1].id;
const sourceHandleC = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 0,
});
const targetC = nodes[2].id;
const targetHandleC = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
});
const connectionIdC = createCanvasConnectionId({
source: sourceC,
target: targetC,
sourceHandle: sourceHandleC,
targetHandle: targetHandleC,
});
expect(result).toEqual([
{
id: '[1/main/0][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
id: connectionIdA,
source: sourceA,
target: targetA,
sourceHandle: sourceHandleA,
targetHandle: targetHandleA,
data: {
fromNodeName: 'Node A',
fromNodeName: nodes[0].name,
source: {
index: 0,
type: NodeConnectionType.Main,
@ -332,13 +486,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
},
},
{
id: `[1/${NodeConnectionType.AiMemory}/0][3/${NodeConnectionType.AiMemory}/1]`,
source: '1',
target: '3',
sourceHandle: `outputs/${NodeConnectionType.AiMemory}/0`,
targetHandle: `inputs/${NodeConnectionType.AiMemory}/1`,
id: connectionIdB,
source: sourceB,
target: targetB,
sourceHandle: sourceHandleB,
targetHandle: targetHandleB,
data: {
fromNodeName: 'Node A',
fromNodeName: nodes[0].name,
source: {
index: 0,
type: NodeConnectionType.AiMemory,
@ -350,13 +504,13 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
},
},
{
id: '[2/main/0][3/main/0]',
source: '2',
target: '3',
sourceHandle: 'outputs/main/0',
targetHandle: 'inputs/main/0',
id: connectionIdC,
source: sourceC,
target: targetC,
sourceHandle: sourceHandleC,
targetHandle: targetHandleC,
data: {
fromNodeName: 'Node B',
fromNodeName: nodes[1].name,
source: {
index: 0,
type: NodeConnectionType.Main,
@ -403,15 +557,36 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
nodes,
);
const source = nodes[0].id;
const sourceHandle = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: NodeConnectionType.Main,
index: 1,
});
const target = nodes[1].id;
const targetHandle = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: NodeConnectionType.Main,
index: 0,
});
const id = createCanvasConnectionId({
source,
target,
sourceHandle,
targetHandle,
});
expect(result).toEqual([
{
id: '[1/main/1][2/main/0]',
source: '1',
target: '2',
sourceHandle: 'outputs/main/1',
targetHandle: 'inputs/main/0',
id,
source,
target,
sourceHandle,
targetHandle,
data: {
fromNodeName: 'Node A',
fromNodeName: nodes[0].name,
source: {
index: 1,
type: NodeConnectionType.Main,

View file

@ -1,11 +1,13 @@
import type { IConnection, IConnections, INodeTypeDescription } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { CanvasConnection, CanvasConnectionPortType, CanvasConnectionPort } from '@/types';
import type { CanvasConnection, CanvasConnectionPort } from '@/types';
import { CanvasConnectionMode } from '@/types';
import type { Connection } from '@vue-flow/core';
import { v4 as uuid } from 'uuid';
import { isValidCanvasConnectionMode, isValidNodeConnectionType } from '@/utils/typeGuards';
import { NodeConnectionType } from 'n8n-workflow';
import type { Connection as VueFlowConnection } from '@vue-flow/core/dist/types/connection';
import { PUSH_NODES_OFFSET } from '@/utils/nodeViewUtils';
export function mapLegacyConnectionsToCanvasConnections(
legacyConnections: IConnections,
@ -14,33 +16,54 @@ export function mapLegacyConnectionsToCanvasConnections(
const mappedConnections: CanvasConnection[] = [];
Object.keys(legacyConnections).forEach((fromNodeName) => {
const fromId = nodes.find((node) => node.name === fromNodeName)?.id;
const fromConnectionTypes = Object.keys(legacyConnections[fromNodeName]);
const fromId = nodes.find((node) => node.name === fromNodeName)?.id ?? '';
const fromConnectionTypes = Object.keys(
legacyConnections[fromNodeName],
) as NodeConnectionType[];
fromConnectionTypes.forEach((fromConnectionType) => {
const fromPorts = legacyConnections[fromNodeName][fromConnectionType];
fromPorts.forEach((toPorts, fromIndex) => {
toPorts.forEach((toPort) => {
const toId = nodes.find((node) => node.name === toPort.node)?.id;
const toConnectionType = toPort.type;
const toId = nodes.find((node) => node.name === toPort.node)?.id ?? '';
const toConnectionType = toPort.type as NodeConnectionType;
const toIndex = toPort.index;
const sourceHandle = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: fromConnectionType,
index: fromIndex,
});
const targetHandle = createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: toConnectionType,
index: toIndex,
});
const connectionId = createCanvasConnectionId({
source: fromId,
sourceHandle,
target: toId,
targetHandle,
});
if (fromId && toId) {
mappedConnections.push({
id: `[${fromId}/${fromConnectionType}/${fromIndex}][${toId}/${toConnectionType}/${toIndex}]`,
id: connectionId,
source: fromId,
target: toId,
sourceHandle: `${CanvasConnectionMode.Output}/${fromConnectionType}/${fromIndex}`,
targetHandle: `${CanvasConnectionMode.Input}/${toConnectionType}/${toIndex}`,
sourceHandle,
targetHandle,
data: {
fromNodeName,
source: {
index: fromIndex,
type: fromConnectionType as CanvasConnectionPortType,
type: fromConnectionType,
},
target: {
index: toIndex,
type: toConnectionType as CanvasConnectionPortType,
type: toConnectionType,
},
},
});
@ -71,6 +94,21 @@ export function parseCanvasConnectionHandleString(handle: string | null | undefi
};
}
/**
* Get the width and height of a connection
*
* @TODO See whether this is actually needed or just a legacy jsPlumb check
*/
export function getVueFlowConnectorLengths(connection: VueFlowConnection): [number, number] {
const connectionId = createCanvasConnectionId(connection);
const edgeRef = document.getElementById(connectionId);
if (!edgeRef) {
return [PUSH_NODES_OFFSET, PUSH_NODES_OFFSET];
}
return [edgeRef.clientWidth, edgeRef.clientHeight];
}
export function createCanvasConnectionHandleString({
mode,
type = NodeConnectionType.Main,
@ -83,6 +121,10 @@ export function createCanvasConnectionHandleString({
return `${mode}/${type}/${index}`;
}
export function createCanvasConnectionId(connection: Connection) {
return `[${connection.source}/${connection.sourceHandle}][${connection.target}/${connection.targetHandle}]`;
}
export function mapCanvasConnectionToLegacyConnection(
sourceNode: INodeUi,
targetNode: INodeUi,

View file

@ -8,6 +8,7 @@ import { nodeConnectionTypes } from 'n8n-workflow';
import type { IExecutionResponse, ICredentialsResponse, NewCredentialsModal } from '@/Interface';
import type { jsPlumbDOMElement } from '@jsplumb/browser-ui';
import type { Connection } from '@jsplumb/core';
import type { Connection as VueFlowConnection } from '@vue-flow/core';
import type { RouteLocationRaw } from 'vue-router';
import type { CanvasConnectionMode } from '@/types';
import { canvasConnectionModes } from '@/types';
@ -66,7 +67,7 @@ export function isDateObject(date: unknown): date is Date {
}
export function isValidNodeConnectionType(
connectionType: string,
connectionType: string | undefined,
): connectionType is NodeConnectionType {
return nodeConnectionTypes.includes(connectionType as NodeConnectionType);
}
@ -75,6 +76,15 @@ export function isValidCanvasConnectionMode(mode: string): mode is CanvasConnect
return canvasConnectionModes.includes(mode as CanvasConnectionMode);
}
export function isVueFlowConnection(connection: object): connection is VueFlowConnection {
return (
'source' in connection &&
'target' in connection &&
'sourceHandle' in connection &&
'targetHandle' in connection
);
}
export function isTriggerPanelObject(
triggerPanel: INodeTypeDescription['triggerPanel'],
): triggerPanel is TriggerPanelDefinition {

View file

@ -80,7 +80,6 @@ import { useTagsStore } from '@/stores/tags.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNDVStore } from '@/stores/ndv.store';
import { getNodeViewTab } from '@/utils/canvasUtils';
import { parseCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue';
@ -529,14 +528,13 @@ function onCreateConnection(connection: Connection) {
}
function onCreateConnectionCancelled(event: ConnectStartEvent) {
const { type, index } = parseCanvasConnectionHandleString(event.handleId);
setTimeout(() => {
nodeCreatorStore.openNodeCreatorForConnectingNode({
index,
endpointUuid: event.handleId,
connection: {
source: event.nodeId,
sourceHandle: event.handleId,
},
eventSource: NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
outputType: type,
sourceId: event.nodeId,
});
});
}
@ -574,6 +572,8 @@ async function onAddNodesAndConnections(
await addConnections(connections, {
offsetIndex: editableWorkflow.value.nodes.length - nodes.length,
});
uiStore.lastSelectedNodeConnection = null;
}
async function onSwitchActiveNode(nodeName: string) {
@ -588,6 +588,13 @@ function onOpenNodeCreator(options: ToggleNodeCreatorOptions) {
nodeCreatorStore.openNodeCreator(options);
}
function onClickConnectionAdd(connection: Connection) {
nodeCreatorStore.openNodeCreatorForConnectingNode({
connection,
eventSource: NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
});
}
/**
* Executions
*/
@ -1079,6 +1086,7 @@ onBeforeUnmount(() => {
@create:connection="onCreateConnection"
@create:connection:cancelled="onCreateConnectionCancelled"
@delete:connection="onDeleteConnection"
@click:connection:add="onClickConnectionAdd"
@click:pane="onClickPane"
>
<div :class="$style.executionButtons">