feat(editor): Add remove node and connections functionality to canvas v2 (#9602)

This commit is contained in:
Alex Grozav 2024-06-04 15:36:27 +03:00 committed by GitHub
parent 202c91e7ed
commit f6a466cd87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 876 additions and 125 deletions

View file

@ -93,11 +93,11 @@ export function createTestWorkflow(options: {
} as IWorkflowDb;
}
export function createTestNode(
node: Partial<INode> & { name: INode['name']; type: INode['type'] },
): INode {
export function createTestNode(node: Partial<INode> = {}): INode {
return {
id: uuid(),
name: 'Node',
type: 'n8n-nodes-base.test',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},

View file

@ -1,23 +1,25 @@
<script lang="ts" setup>
import type { CanvasConnection, CanvasElement } from '@/types';
import type { NodeDragEvent, Connection } from '@vue-flow/core';
import { VueFlow, PanelPosition } from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import CanvasNode from './elements/nodes/CanvasNode.vue';
import CanvasEdge from './elements/edges/CanvasEdge.vue';
import { useCssModule } from 'vue';
import { onMounted, onUnmounted, useCssModule } from 'vue';
const $style = useCssModule();
const emit = defineEmits<{
'update:modelValue': [elements: CanvasElement[]];
'update:node:position': [id: string, position: { x: number; y: number }];
'delete:node': [id: string];
'delete:connection': [connection: Connection];
'create:connection': [connection: Connection];
}>();
withDefaults(
const props = withDefaults(
defineProps<{
id?: string;
elements: CanvasElement[];
@ -32,15 +34,40 @@ withDefaults(
},
);
const { getSelectedEdges, getSelectedNodes } = useVueFlow({ id: props.id });
onMounted(() => {
document.addEventListener('keydown', onKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown);
});
function onNodeDragStop(e: NodeDragEvent) {
e.nodes.forEach((node) => {
emit('update:node:position', node.id, node.position);
});
}
function onDeleteNode(id: string) {
emit('delete:node', id);
}
function onDeleteConnection(connection: Connection) {
emit('delete:connection', connection);
}
function onConnect(...args: unknown[]) {
emit('create:connection', args[0] as Connection);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Delete') {
getSelectedEdges.value.forEach(onDeleteConnection);
getSelectedNodes.value.forEach(({ id }) => onDeleteNode(id));
}
}
</script>
<template>
@ -58,11 +85,11 @@ function onConnect(...args: unknown[]) {
@connect="onConnect"
>
<template #node-canvas-node="canvasNodeProps">
<CanvasNode v-bind="canvasNodeProps" />
<CanvasNode v-bind="canvasNodeProps" @delete="onDeleteNode" />
</template>
<template #edge-canvas-edge="canvasEdgeProps">
<CanvasEdge v-bind="canvasEdgeProps" />
<CanvasEdge v-bind="canvasEdgeProps" @delete="onDeleteConnection" />
</template>
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="16" />

View file

@ -1,15 +1,28 @@
<script lang="ts" setup>
import type { EdgeProps } from '@vue-flow/core';
import { BaseEdge, getBezierPath } from '@vue-flow/core';
import { computed } from 'vue';
/* eslint-disable vue/no-multiple-template-root */
import type { Connection, EdgeProps } from '@vue-flow/core';
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@vue-flow/core';
import { computed, useCssModule } from 'vue';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits<{
delete: [connection: Connection];
}>();
const props = defineProps<EdgeProps>();
const i18n = useI18n();
const $style = useCssModule();
const edgeStyle = computed(() => ({
strokeWidth: 2,
...props.style,
}));
const edgeLabelStyle = computed(() => ({
transform: `translate(-50%, -50%) translate(${path.value[1]}px,${path.value[2]}px)`,
}));
const path = computed(() =>
getBezierPath({
sourceX: props.sourceX,
@ -20,6 +33,17 @@ const path = computed(() =>
targetPosition: props.targetPosition,
}),
);
const connection = computed<Connection>(() => ({
source: props.source,
target: props.target,
sourceHandle: props.sourceHandleId,
targetHandle: props.targetHandleId,
}));
function onDelete() {
emit('delete', connection.value);
}
</script>
<template>
@ -37,4 +61,23 @@ const path = computed(() =>
:label-bg-padding="[2, 4]"
:label-bg-border-radius="2"
/>
<EdgeLabelRenderer>
<div :class="[$style.edgeToolbar, 'nodrag', 'nopan']" :style="edgeLabelStyle">
<N8nIconButton
data-test-id="delete-connection-button"
type="tertiary"
size="small"
icon="trash"
:title="i18n.baseText('node.delete')"
@click="onDelete"
/>
</div>
</EdgeLabelRenderer>
</template>
<style lang="scss" module>
.edgeToolbar {
pointer-events: all;
position: absolute;
}
</style>

View file

@ -15,6 +15,10 @@ import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import type { NodeProps } from '@vue-flow/core';
const emit = defineEmits<{
delete: [id: string];
}>();
const props = defineProps<NodeProps<CanvasElementData>>();
const inputs = computed(() => props.data.inputs);
@ -89,6 +93,10 @@ provide(CanvasNodeKey, {
selected,
nodeType,
});
function onDelete() {
emit('delete', props.id);
}
</script>
<template>
@ -121,6 +129,7 @@ provide(CanvasNodeKey, {
v-if="nodeType"
data-test-id="canvas-node-toolbar"
:class="$style.canvasNodeToolbar"
@delete="onDelete"
/>
<CanvasNodeRenderer v-if="nodeType">

View file

@ -1,12 +1,16 @@
<script setup lang="ts">
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits(['delete']);
const $style = useCssModule();
const node = inject(CanvasNodeKey);
const data = computed(() => node?.data.value);
const i18n = useI18n();
const $style = useCssModule();
const data = computed(() => node?.data.value);
// @TODO
const workflowRunning = false;
@ -20,8 +24,9 @@ function executeNode() {}
// @TODO
function toggleDisableNode() {}
// @TODO
function deleteNode() {}
function deleteNode() {
emit('delete');
}
// @TODO
function openContextMenu(_e: MouseEvent, _type: string) {}
@ -38,7 +43,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
size="small"
icon="play"
:disabled="workflowRunning"
:title="$locale.baseText('node.testStep')"
:title="i18n.baseText('node.testStep')"
@click="executeNode"
/>
<N8nIconButton
@ -56,7 +61,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
size="small"
text
icon="trash"
:title="$locale.baseText('node.delete')"
:title="i18n.baseText('node.delete')"
@click="deleteNode"
/>
<N8nIconButton

View file

@ -0,0 +1,306 @@
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import type { CanvasElement } from '@/types';
import type { INodeUi } 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 { createPinia, setActivePinia } from 'pinia';
import { createTestNode } from '@/__tests__/mocks';
import type { Connection } from '@vue-flow/core';
import type { IConnection } from 'n8n-workflow';
describe('useCanvasOperations', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let historyStore: ReturnType<typeof useHistoryStore>;
let canvasOperations: ReturnType<typeof useCanvasOperations>;
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
workflowsStore = useWorkflowsStore();
uiStore = useUIStore();
historyStore = useHistoryStore();
canvasOperations = useCanvasOperations();
});
describe('updateNodePosition', () => {
it('should update node position', () => {
const setNodePositionByIdSpy = vi
.spyOn(workflowsStore, 'setNodePositionById')
.mockImplementation(() => {});
const id = 'node1';
const position: CanvasElement['position'] = { x: 10, y: 20 };
const node = createTestNode({
id,
type: 'node',
position: [0, 0],
name: 'Node 1',
});
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(node);
canvasOperations.updateNodePosition(id, position);
expect(setNodePositionByIdSpy).toHaveBeenCalledWith(id, [position.x, position.y]);
});
});
describe('deleteNode', () => {
it('should delete node and track history', () => {
const removeNodeByIdSpy = vi
.spyOn(workflowsStore, 'removeNodeById')
.mockImplementation(() => {});
const removeNodeConnectionsByIdSpy = vi
.spyOn(workflowsStore, 'removeNodeConnectionsById')
.mockImplementation(() => {});
const removeNodeExecutionDataByIdSpy = vi
.spyOn(workflowsStore, 'removeNodeExecutionDataById')
.mockImplementation(() => {});
const pushCommandToUndoSpy = vi
.spyOn(historyStore, 'pushCommandToUndo')
.mockImplementation(() => {});
const id = 'node1';
const node: INodeUi = createTestNode({
id,
type: 'node',
position: [10, 20],
name: 'Node 1',
});
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
canvasOperations.deleteNode(id, { trackHistory: true });
expect(removeNodeByIdSpy).toHaveBeenCalledWith(id);
expect(removeNodeConnectionsByIdSpy).toHaveBeenCalledWith(id);
expect(removeNodeExecutionDataByIdSpy).toHaveBeenCalledWith(id);
expect(pushCommandToUndoSpy).toHaveBeenCalledWith(new RemoveNodeCommand(node));
});
it('should delete node without tracking history', () => {
const removeNodeByIdSpy = vi
.spyOn(workflowsStore, 'removeNodeById')
.mockImplementation(() => {});
const removeNodeConnectionsByIdSpy = vi
.spyOn(workflowsStore, 'removeNodeConnectionsById')
.mockImplementation(() => {});
const removeNodeExecutionDataByIdSpy = vi
.spyOn(workflowsStore, 'removeNodeExecutionDataById')
.mockImplementation(() => {});
const pushCommandToUndoSpy = vi
.spyOn(historyStore, 'pushCommandToUndo')
.mockImplementation(() => {});
const id = 'node1';
const node = createTestNode({
id,
type: 'node',
position: [10, 20],
name: 'Node 1',
parameters: {},
});
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
canvasOperations.deleteNode(id, { trackHistory: false });
expect(removeNodeByIdSpy).toHaveBeenCalledWith(id);
expect(removeNodeConnectionsByIdSpy).toHaveBeenCalledWith(id);
expect(removeNodeExecutionDataByIdSpy).toHaveBeenCalledWith(id);
expect(pushCommandToUndoSpy).not.toHaveBeenCalled();
});
});
describe('revertDeleteNode', () => {
it('should revert delete node', () => {
const addNodeSpy = vi.spyOn(workflowsStore, 'addNode').mockImplementation(() => {});
const node = createTestNode({
id: 'node1',
type: 'node',
position: [10, 20],
name: 'Node 1',
parameters: {},
});
canvasOperations.revertDeleteNode(node);
expect(addNodeSpy).toHaveBeenCalledWith(node);
});
});
describe('createConnection', () => {
it('should not create a connection if source node does not exist', () => {
const addConnectionSpy = vi
.spyOn(workflowsStore, 'addConnection')
.mockImplementation(() => {});
const connection: Connection = { source: 'nonexistent', target: 'targetNode' };
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(undefined);
canvasOperations.createConnection(connection);
expect(addConnectionSpy).not.toHaveBeenCalled();
expect(uiStore.stateIsDirty).toBe(false);
});
it('should not create a connection if target node does not exist', () => {
const addConnectionSpy = vi
.spyOn(workflowsStore, 'addConnection')
.mockImplementation(() => {});
const connection: Connection = { source: 'sourceNode', target: 'nonexistent' };
vi.spyOn(workflowsStore, 'getNodeById')
.mockReturnValueOnce(createTestNode())
.mockReturnValueOnce(undefined);
canvasOperations.createConnection(connection);
expect(addConnectionSpy).not.toHaveBeenCalled();
expect(uiStore.stateIsDirty).toBe(false);
});
// @TODO Implement once the isConnectionAllowed method is implemented
it.skip('should not create a connection if connection is not allowed', () => {
const addConnectionSpy = vi
.spyOn(workflowsStore, 'addConnection')
.mockImplementation(() => {});
const connection: Connection = { source: 'sourceNode', target: 'targetNode' };
vi.spyOn(workflowsStore, 'getNodeById')
.mockReturnValueOnce(createTestNode())
.mockReturnValueOnce(createTestNode());
canvasOperations.createConnection(connection);
expect(addConnectionSpy).not.toHaveBeenCalled();
expect(uiStore.stateIsDirty).toBe(false);
});
it('should create a connection if source and target nodes exist and connection is allowed', () => {
const addConnectionSpy = vi
.spyOn(workflowsStore, 'addConnection')
.mockImplementation(() => {});
const nodeA = createTestNode({
id: 'a',
type: 'node',
name: 'Node A',
});
const nodeB = createTestNode({
id: 'b',
type: 'node',
name: 'Node B',
});
const connection: Connection = {
source: nodeA.id,
sourceHandle: 'outputs/main/0',
target: nodeB.id,
targetHandle: 'inputs/main/0',
};
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB);
canvasOperations.createConnection(connection);
expect(addConnectionSpy).toHaveBeenCalledWith({
connection: [
{ index: 0, node: nodeA.name, type: 'main' },
{ index: 0, node: nodeB.name, type: 'main' },
],
});
expect(uiStore.stateIsDirty).toBe(true);
});
});
describe('deleteConnection', () => {
it('should not delete a connection if source node does not exist', () => {
const removeConnectionSpy = vi
.spyOn(workflowsStore, 'removeConnection')
.mockImplementation(() => {});
const connection: Connection = { source: 'nonexistent', target: 'targetNode' };
vi.spyOn(workflowsStore, 'getNodeById')
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(createTestNode());
canvasOperations.deleteConnection(connection);
expect(removeConnectionSpy).not.toHaveBeenCalled();
});
it('should not delete a connection if target node does not exist', () => {
const removeConnectionSpy = vi
.spyOn(workflowsStore, 'removeConnection')
.mockImplementation(() => {});
const connection: Connection = { source: 'sourceNode', target: 'nonexistent' };
vi.spyOn(workflowsStore, 'getNodeById')
.mockReturnValueOnce(createTestNode())
.mockReturnValueOnce(undefined);
canvasOperations.deleteConnection(connection);
expect(removeConnectionSpy).not.toHaveBeenCalled();
});
it('should delete a connection if source and target nodes exist', () => {
const removeConnectionSpy = vi
.spyOn(workflowsStore, 'removeConnection')
.mockImplementation(() => {});
const nodeA = createTestNode({
id: 'a',
type: 'node',
name: 'Node A',
});
const nodeB = createTestNode({
id: 'b',
type: 'node',
name: 'Node B',
});
const connection: Connection = {
source: nodeA.id,
sourceHandle: 'outputs/main/0',
target: nodeB.id,
targetHandle: 'inputs/main/0',
};
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB);
canvasOperations.deleteConnection(connection);
expect(removeConnectionSpy).toHaveBeenCalledWith({
connection: [
{ index: 0, node: nodeA.name, type: 'main' },
{ index: 0, node: nodeB.name, type: 'main' },
],
});
});
});
describe('revertDeleteConnection', () => {
it('should revert delete connection', () => {
const addConnectionSpy = vi
.spyOn(workflowsStore, 'addConnection')
.mockImplementation(() => {});
const connection: [IConnection, IConnection] = [
{ node: 'sourceNode', type: 'type', index: 1 },
{ node: 'targetNode', type: 'type', index: 2 },
];
canvasOperations.revertDeleteConnection(connection);
expect(addConnectionSpy).toHaveBeenCalledWith({ connection });
});
});
});

View file

@ -0,0 +1,215 @@
import type { CanvasElement } from '@/types';
import type { INodeUi, XYPosition } from '@/Interface';
import { QUICKSTART_NOTE_NAME, STICKY_NODE_TYPE } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useHistoryStore } from '@/stores/history.store';
import { useUIStore } from '@/stores/ui.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { MoveNodeCommand, RemoveConnectionCommand, RemoveNodeCommand } from '@/models/history';
import type { Connection } from '@vue-flow/core';
import { mapCanvasConnectionToLegacyConnection } from '@/utils/canvasUtilsV2';
import type { IConnection } from 'n8n-workflow';
export function useCanvasOperations() {
const workflowsStore = useWorkflowsStore();
const historyStore = useHistoryStore();
const uiStore = useUIStore();
const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
/**
* Node operations
*/
function updateNodePosition(
id: string,
position: CanvasElement['position'],
{ trackHistory = false, trackBulk = true } = {},
) {
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
}
if (trackHistory && trackBulk) {
historyStore.startRecordingUndo();
}
const oldPosition: XYPosition = [...node.position];
const newPosition: XYPosition = [position.x, position.y];
workflowsStore.setNodePositionById(id, newPosition);
if (trackHistory) {
historyStore.pushCommandToUndo(new MoveNodeCommand(node.name, oldPosition, newPosition));
if (trackBulk) {
historyStore.stopRecordingUndo();
}
}
}
function deleteNode(id: string, { trackHistory = false, trackBulk = true } = {}) {
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
}
if (trackHistory && trackBulk) {
historyStore.startRecordingUndo();
}
workflowsStore.removeNodeById(id);
workflowsStore.removeNodeConnectionsById(id);
workflowsStore.removeNodeExecutionDataById(id);
if (trackHistory) {
historyStore.pushCommandToUndo(new RemoveNodeCommand(node));
if (trackBulk) {
historyStore.stopRecordingUndo();
}
}
trackDeleteNode(id);
}
function revertDeleteNode(node: INodeUi) {
workflowsStore.addNode(node);
}
function trackDeleteNode(id: string) {
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
}
if (node.type === STICKY_NODE_TYPE) {
telemetry.track('User deleted workflow note', {
workflow_id: workflowsStore.workflowId,
is_welcome_note: node.name === QUICKSTART_NOTE_NAME,
});
} else {
void externalHooks.run('node.deleteNode', { node });
telemetry.track('User deleted node', {
node_type: node.type,
workflow_id: workflowsStore.workflowId,
});
}
}
/**
* Connection operations
*/
function createConnection(connection: Connection) {
const sourceNode = workflowsStore.getNodeById(connection.source);
const targetNode = workflowsStore.getNodeById(connection.target);
if (!sourceNode || !targetNode || !isConnectionAllowed(sourceNode, targetNode)) {
return;
}
const mappedConnection = mapCanvasConnectionToLegacyConnection(
sourceNode,
targetNode,
connection,
);
workflowsStore.addConnection({
connection: mappedConnection,
});
uiStore.stateIsDirty = true;
}
function deleteConnection(
connection: Connection,
{ trackHistory = false, trackBulk = true } = {},
) {
const sourceNode = workflowsStore.getNodeById(connection.source);
const targetNode = workflowsStore.getNodeById(connection.target);
if (!sourceNode || !targetNode) {
return;
}
const mappedConnection = mapCanvasConnectionToLegacyConnection(
sourceNode,
targetNode,
connection,
);
if (trackHistory && trackBulk) {
historyStore.startRecordingUndo();
}
workflowsStore.removeConnection({
connection: mappedConnection,
});
if (trackHistory) {
historyStore.pushCommandToUndo(new RemoveConnectionCommand(mappedConnection));
if (trackBulk) {
historyStore.stopRecordingUndo();
}
}
}
function revertDeleteConnection(connection: [IConnection, IConnection]) {
workflowsStore.addConnection({
connection,
});
}
// @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;
// }
// }
// }
// }
return sourceNode.id !== targetNode.id;
}
return {
updateNodePosition,
deleteNode,
revertDeleteNode,
trackDeleteNode,
createConnection,
deleteConnection,
revertDeleteConnection,
isConnectionAllowed,
};
}

View file

@ -1258,6 +1258,8 @@
"nodeView.zoomToFit": "Zoom to Fit",
"nodeView.replaceMe": "Replace Me",
"nodeView.setupTemplate": "Set up template",
"nodeViewV2.showError.editingNotAllowed": "Editing is not allowed",
"nodeViewV2.showError.failedToCreateNode": "Failed to create node",
"contextMenu.node": "node | nodes",
"contextMenu.sticky": "sticky note | sticky notes",
"contextMenu.selectAll": "Select all",

View file

@ -373,6 +373,7 @@ export const routes = [
return !!localStorage.getItem('features.NodeViewV2');
},
},
nodeView: true,
},
},
{

View file

@ -308,7 +308,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return workflow.value.nodes.map((node) => ({ ...node }));
}
function setNodePosition(id: string, position: INodeUi['position']): void {
function setNodePositionById(id: string, position: INodeUi['position']): void {
const node = workflow.value.nodes.find((n) => n.id === id);
if (!node) return;
@ -1489,6 +1489,43 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return !!matchedChatNode;
}
//
// Start Canvas V2 Functions
//
function removeNodeById(nodeId: string): void {
const node = getNodeById(nodeId);
if (!node) {
return;
}
removeNode(node);
// @TODO When removing node connected between two nodes, create a connection between them
}
function removeNodeConnectionsById(nodeId: string): void {
const node = getNodeById(nodeId);
if (!node) {
return;
}
removeAllNodeConnection(node);
}
function removeNodeExecutionDataById(nodeId: string): void {
const node = getNodeById(nodeId);
if (!node) {
return;
}
clearNodeExecutionData(node.name);
}
//
// End Canvas V2 Functions
//
return {
workflow,
usedCredentials,
@ -1620,6 +1657,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
resetChatMessages,
appendChatMessage,
checkIfNodeHasChatParent,
setNodePosition,
setNodePositionById,
removeNodeById,
removeNodeConnectionsById,
removeNodeExecutionDataById,
};
});

View file

@ -2,10 +2,14 @@ import {
mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort,
getUniqueNodeName,
mapCanvasConnectionToLegacyConnection,
parseCanvasConnectionHandleString,
} 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';
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-uuid'),
@ -421,6 +425,78 @@ describe('mapLegacyConnectionsToCanvasConnections', () => {
});
});
describe('parseCanvasConnectionHandleString', () => {
it('should parse valid handle string', () => {
const handle = 'outputs/main/1';
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
type: 'main',
index: 1,
});
});
it('should handle null handle', () => {
const handle = null;
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
type: 'main',
index: 0,
});
});
it('should handle undefined handle', () => {
const handle = undefined;
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
type: 'main',
index: 0,
});
});
it('should handle invalid type in handle', () => {
const handle = 'outputs/invalid/1';
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
type: 'main',
index: 1,
});
});
it('should handle invalid index in handle', () => {
const handle = 'outputs/main/invalid';
const result = parseCanvasConnectionHandleString(handle);
expect(result).toEqual({
type: 'main',
index: 0,
});
});
});
describe('mapCanvasConnectionToLegacyConnection', () => {
it('should map canvas connection to legacy connection', () => {
const sourceNode = createTestNode({ name: 'sourceNode', type: 'main' });
const targetNode = createTestNode({ name: 'targetNode', type: 'main' });
const connection: Connection = {
target: '1',
source: '2',
sourceHandle: 'outputs/main/1',
targetHandle: 'inputs/main/2',
};
const result = mapCanvasConnectionToLegacyConnection(sourceNode, targetNode, connection);
expect(result).toEqual([
{ node: sourceNode.name, type: 'main', index: 1 },
{ node: targetNode.name, type: 'main', index: 2 },
]);
});
});
describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
it('should return an empty array and log a warning when inputs is a string', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

View file

@ -1,7 +1,10 @@
import type { IConnections, INodeTypeDescription } from 'n8n-workflow';
import type { IConnection, IConnections, INodeTypeDescription } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { CanvasConnection, CanvasConnectionPortType, CanvasConnectionPort } from '@/types';
import type { Connection } from '@vue-flow/core';
import { v4 as uuid } from 'uuid';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
import { NodeConnectionType } from 'n8n-workflow';
export function mapLegacyConnectionsToCanvasConnections(
legacyConnections: IConnections,
@ -49,6 +52,53 @@ export function mapLegacyConnectionsToCanvasConnections(
return mappedConnections;
}
export function parseCanvasConnectionHandleString(handle: string | null | undefined) {
const [, type, index] = (handle ?? '').split('/');
const resolvedType = isValidNodeConnectionType(type) ? type : NodeConnectionType.Main;
let resolvedIndex = parseInt(index, 10);
if (isNaN(resolvedIndex)) {
resolvedIndex = 0;
}
return {
type: resolvedType,
index: resolvedIndex,
};
}
export function mapCanvasConnectionToLegacyConnection(
sourceNode: INodeUi,
targetNode: INodeUi,
connection: Connection,
): [IConnection, IConnection] {
// Output
const sourceNodeName = sourceNode?.name ?? '';
const { type: sourceType, index: sourceIndex } = parseCanvasConnectionHandleString(
connection.sourceHandle,
);
// Input
const targetNodeName = targetNode?.name ?? '';
const { type: targetType, index: targetIndex } = parseCanvasConnectionHandleString(
connection.targetHandle,
);
return [
{
node: sourceNodeName,
type: sourceType,
index: sourceIndex,
},
{
node: targetNodeName,
type: targetType,
index: targetIndex,
},
];
}
export function mapLegacyEndpointsToCanvasConnectionPort(
endpoints: INodeTypeDescription['inputs'],
): CanvasConnectionPort[] {

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref, useCssModule } from 'vue';
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, useCssModule } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -31,7 +31,7 @@ import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { useExternalHooks } from '@/composables/useExternalHooks';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { IConnection, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { v4 as uuid } from 'uuid';
@ -42,7 +42,8 @@ import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useCollaborationStore } from '@/stores/collaboration.store';
import { getUniqueNodeName } from '@/utils/canvasUtilsV2';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
import { historyBus } from '@/models/history';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'),
@ -72,6 +73,14 @@ const rootStore = useRootStore();
const collaborationStore = useCollaborationStore();
const { runWorkflow } = useRunWorkflow({ router });
const {
updateNodePosition,
deleteNode,
revertDeleteNode,
createConnection,
deleteConnection,
revertDeleteConnection,
} = useCanvasOperations();
const isLoading = ref(true);
const readOnlyNotification = ref<null | { visible: boolean }>(null);
@ -141,6 +150,8 @@ async function initialize() {
initializeEditableWorkflow(workflowId.value);
addUndoRedoEventBindings();
if (window.parent) {
window.parent.postMessage(
JSON.stringify({ command: 'n8nReady', version: rootStore.versionCli }),
@ -151,6 +162,30 @@ async function initialize() {
isLoading.value = false;
}
onBeforeUnmount(() => {
removeUndoRedoEventBindings();
});
function addUndoRedoEventBindings() {
// historyBus.on('nodeMove', onMoveNode);
// historyBus.on('revertAddNode', onRevertAddNode);
historyBus.on('revertRemoveNode', onRevertDeleteNode);
// historyBus.on('revertAddConnection', onRevertAddConnection);
historyBus.on('revertRemoveConnection', onRevertDeleteConnection);
// historyBus.on('revertRenameNode', onRevertNameChange);
// historyBus.on('enableNodeToggle', onRevertEnableToggle);
}
function removeUndoRedoEventBindings() {
// historyBus.off('nodeMove', onMoveNode);
// historyBus.off('revertAddNode', onRevertAddNode);
historyBus.off('revertRemoveNode', onRevertDeleteNode);
// historyBus.off('revertAddConnection', onRevertAddConnection);
historyBus.off('revertRemoveConnection', onRevertDeleteConnection);
// historyBus.off('revertRenameNode', onRevertNameChange);
// historyBus.off('enableNodeToggle', onRevertEnableToggle);
}
// @TODO Maybe move this to the store
function initializeEditableWorkflow(id: string) {
const targetWorkflow = workflowsStore.workflowsById[id];
@ -198,14 +233,16 @@ async function onRunWorkflow() {
await runWorkflow({});
}
/**
* Map new node position format to the old one and update the store
*
* @param id
* @param position
*/
function onNodePositionUpdate(id: string, position: CanvasElement['position']) {
workflowsStore.setNodePosition(id, [position.x, position.y]);
function onUpdateNodePosition(id: string, position: CanvasElement['position']) {
updateNodePosition(id, position, { trackHistory: true });
}
function onDeleteNode(id: string) {
deleteNode(id, { trackHistory: true });
}
function onRevertDeleteNode({ node }: { node: INodeUi }) {
revertDeleteNode(node);
}
/**
@ -213,83 +250,16 @@ function onNodePositionUpdate(id: string, position: CanvasElement['position']) {
*
* @param connection
*/
function onCreateNodeConnection(connection: Connection) {
// Output
const sourceNodeId = connection.source;
const sourceNode = workflowsStore.getNodeById(sourceNodeId);
const sourceNodeName = sourceNode?.name ?? '';
const [, sourceType, sourceIndex] = (connection.sourceHandle ?? '')
.split('/')
.filter(isValidNodeConnectionType);
// Input
const targetNodeId = connection.target;
const targetNode = workflowsStore.getNodeById(targetNodeId);
const targetNodeName = targetNode?.name ?? '';
const [, targetType, targetIndex] = (connection.targetHandle ?? '')
.split('/')
.filter(isValidNodeConnectionType);
if (sourceNode && targetNode && !checkIfNodeConnectionIsAllowed(sourceNode, targetNode)) {
return;
}
workflowsStore.addConnection({
connection: [
{
node: sourceNodeName,
type: sourceType,
index: parseInt(sourceIndex, 10),
},
{
node: targetNodeName,
type: targetType,
index: parseInt(targetIndex, 10),
},
],
});
uiStore.stateIsDirty = true;
function onCreateConnection(connection: Connection) {
createConnection(connection);
}
// @TODO Figure out a way to improve this
function checkIfNodeConnectionIsAllowed(_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;
// }
// }
// }
// }
return true;
function onDeleteConnection(connection: Connection) {
deleteConnection(connection, { trackHistory: true });
}
function onRevertDeleteConnection({ connection }: { connection: [IConnection, IConnection] }) {
revertDeleteConnection(connection);
}
function onToggleNodeCreator({
@ -346,19 +316,24 @@ async function onAddNodes(
) {
let currentPosition = position;
for (const { type, name, position: nodePosition, isAutoAdd, openDetail } of nodes) {
const _node = await addNode(
{
name,
type,
position: nodePosition ?? currentPosition,
},
{
dragAndDrop,
openNDV: openDetail ?? false,
trackHistory: true,
isAutoAdd,
},
);
try {
await onNodeCreate(
{
name,
type,
position: nodePosition ?? currentPosition,
},
{
dragAndDrop,
openNDV: openDetail ?? false,
trackHistory: true,
isAutoAdd,
},
);
} catch (error) {
toast.showError(error, i18n.baseText('error'));
continue;
}
const lastAddedNode = editableWorkflow.value.nodes[editableWorkflow.value.nodes.length - 1];
currentPosition = [
@ -372,7 +347,7 @@ async function onAddNodes(
const fromNode = editableWorkflow.value.nodes[newNodesOffset + from.nodeIndex];
const toNode = editableWorkflow.value.nodes[newNodesOffset + to.nodeIndex];
onCreateNodeConnection({
onCreateConnection({
source: fromNode.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/${from.outputIndex ?? 0}`,
target: toNode.id,
@ -412,14 +387,14 @@ type AddNodeOptions = {
isAutoAdd?: boolean;
};
async function addNode(node: AddNodeData, _options: AddNodeOptions): Promise<INodeUi | undefined> {
async function onNodeCreate(node: AddNodeData, _options: AddNodeOptions = {}): Promise<INodeUi> {
if (!checkIfEditingIsAllowed()) {
return;
throw new Error(i18n.baseText('nodeViewV2.showError.editingNotAllowed'));
}
const newNodeData = await createNodeWithDefaultCredentials(node);
if (!newNodeData) {
return;
throw new Error(i18n.baseText('nodeViewV2.showError.failedToCreateNode'));
}
/**
@ -929,8 +904,10 @@ function checkIfEditingIsAllowed(): boolean {
v-if="editableWorkflow && editableWorkflowObject"
:workflow="editableWorkflow"
:workflow-object="editableWorkflowObject"
@update:node:position="onNodePositionUpdate"
@create:connection="onCreateNodeConnection"
@update:node:position="onUpdateNodePosition"
@delete:node="onDeleteNode"
@create:connection="onCreateConnection"
@delete:connection="onDeleteConnection"
>
<div :class="$style.executionButtons">
<CanvasExecuteWorkflowButton @click="onRunWorkflow" />