mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Add remove node and connections functionality to canvas v2 (#9602)
This commit is contained in:
@@ -93,11 +93,11 @@ export function createTestWorkflow(options: {
|
|||||||
} as IWorkflowDb;
|
} as IWorkflowDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTestNode(
|
export function createTestNode(node: Partial<INode> = {}): INode {
|
||||||
node: Partial<INode> & { name: INode['name']; type: INode['type'] },
|
|
||||||
): INode {
|
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
name: 'Node',
|
||||||
|
type: 'n8n-nodes-base.test',
|
||||||
typeVersion: 1,
|
typeVersion: 1,
|
||||||
position: [0, 0] as [number, number],
|
position: [0, 0] as [number, number],
|
||||||
parameters: {},
|
parameters: {},
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { CanvasConnection, CanvasElement } from '@/types';
|
import type { CanvasConnection, CanvasElement } from '@/types';
|
||||||
import type { NodeDragEvent, Connection } from '@vue-flow/core';
|
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 { Background } from '@vue-flow/background';
|
||||||
import { Controls } from '@vue-flow/controls';
|
import { Controls } from '@vue-flow/controls';
|
||||||
import { MiniMap } from '@vue-flow/minimap';
|
import { MiniMap } from '@vue-flow/minimap';
|
||||||
import CanvasNode from './elements/nodes/CanvasNode.vue';
|
import CanvasNode from './elements/nodes/CanvasNode.vue';
|
||||||
import CanvasEdge from './elements/edges/CanvasEdge.vue';
|
import CanvasEdge from './elements/edges/CanvasEdge.vue';
|
||||||
import { useCssModule } from 'vue';
|
import { onMounted, onUnmounted, useCssModule } from 'vue';
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [elements: CanvasElement[]];
|
'update:modelValue': [elements: CanvasElement[]];
|
||||||
'update:node:position': [id: string, position: { x: number; y: number }];
|
'update:node:position': [id: string, position: { x: number; y: number }];
|
||||||
|
'delete:node': [id: string];
|
||||||
|
'delete:connection': [connection: Connection];
|
||||||
'create:connection': [connection: Connection];
|
'create:connection': [connection: Connection];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string;
|
id?: string;
|
||||||
elements: CanvasElement[];
|
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) {
|
function onNodeDragStop(e: NodeDragEvent) {
|
||||||
e.nodes.forEach((node) => {
|
e.nodes.forEach((node) => {
|
||||||
emit('update:node:position', node.id, node.position);
|
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[]) {
|
function onConnect(...args: unknown[]) {
|
||||||
emit('create:connection', args[0] as Connection);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -58,11 +85,11 @@ function onConnect(...args: unknown[]) {
|
|||||||
@connect="onConnect"
|
@connect="onConnect"
|
||||||
>
|
>
|
||||||
<template #node-canvas-node="canvasNodeProps">
|
<template #node-canvas-node="canvasNodeProps">
|
||||||
<CanvasNode v-bind="canvasNodeProps" />
|
<CanvasNode v-bind="canvasNodeProps" @delete="onDeleteNode" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #edge-canvas-edge="canvasEdgeProps">
|
<template #edge-canvas-edge="canvasEdgeProps">
|
||||||
<CanvasEdge v-bind="canvasEdgeProps" />
|
<CanvasEdge v-bind="canvasEdgeProps" @delete="onDeleteConnection" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="16" />
|
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="16" />
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { EdgeProps } from '@vue-flow/core';
|
/* eslint-disable vue/no-multiple-template-root */
|
||||||
import { BaseEdge, getBezierPath } from '@vue-flow/core';
|
import type { Connection, EdgeProps } from '@vue-flow/core';
|
||||||
import { computed } from 'vue';
|
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 props = defineProps<EdgeProps>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const $style = useCssModule();
|
||||||
|
|
||||||
const edgeStyle = computed(() => ({
|
const edgeStyle = computed(() => ({
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
...props.style,
|
...props.style,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const edgeLabelStyle = computed(() => ({
|
||||||
|
transform: `translate(-50%, -50%) translate(${path.value[1]}px,${path.value[2]}px)`,
|
||||||
|
}));
|
||||||
|
|
||||||
const path = computed(() =>
|
const path = computed(() =>
|
||||||
getBezierPath({
|
getBezierPath({
|
||||||
sourceX: props.sourceX,
|
sourceX: props.sourceX,
|
||||||
@@ -20,6 +33,17 @@ const path = computed(() =>
|
|||||||
targetPosition: props.targetPosition,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -37,4 +61,23 @@ const path = computed(() =>
|
|||||||
:label-bg-padding="[2, 4]"
|
:label-bg-padding="[2, 4]"
|
||||||
:label-bg-border-radius="2"
|
: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>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.edgeToolbar {
|
||||||
|
pointer-events: all;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { useNodeConnections } from '@/composables/useNodeConnections';
|
|||||||
import { CanvasNodeKey } from '@/constants';
|
import { CanvasNodeKey } from '@/constants';
|
||||||
import type { NodeProps } from '@vue-flow/core';
|
import type { NodeProps } from '@vue-flow/core';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
delete: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
const props = defineProps<NodeProps<CanvasElementData>>();
|
const props = defineProps<NodeProps<CanvasElementData>>();
|
||||||
|
|
||||||
const inputs = computed(() => props.data.inputs);
|
const inputs = computed(() => props.data.inputs);
|
||||||
@@ -89,6 +93,10 @@ provide(CanvasNodeKey, {
|
|||||||
selected,
|
selected,
|
||||||
nodeType,
|
nodeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onDelete() {
|
||||||
|
emit('delete', props.id);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -121,6 +129,7 @@ provide(CanvasNodeKey, {
|
|||||||
v-if="nodeType"
|
v-if="nodeType"
|
||||||
data-test-id="canvas-node-toolbar"
|
data-test-id="canvas-node-toolbar"
|
||||||
:class="$style.canvasNodeToolbar"
|
:class="$style.canvasNodeToolbar"
|
||||||
|
@delete="onDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CanvasNodeRenderer v-if="nodeType">
|
<CanvasNodeRenderer v-if="nodeType">
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, useCssModule } from 'vue';
|
import { computed, inject, useCssModule } from 'vue';
|
||||||
import { CanvasNodeKey } from '@/constants';
|
import { CanvasNodeKey } from '@/constants';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
|
const emit = defineEmits(['delete']);
|
||||||
|
const $style = useCssModule();
|
||||||
|
|
||||||
const node = inject(CanvasNodeKey);
|
const node = inject(CanvasNodeKey);
|
||||||
|
|
||||||
const data = computed(() => node?.data.value);
|
const i18n = useI18n();
|
||||||
|
|
||||||
const $style = useCssModule();
|
const data = computed(() => node?.data.value);
|
||||||
|
|
||||||
// @TODO
|
// @TODO
|
||||||
const workflowRunning = false;
|
const workflowRunning = false;
|
||||||
@@ -20,8 +24,9 @@ function executeNode() {}
|
|||||||
// @TODO
|
// @TODO
|
||||||
function toggleDisableNode() {}
|
function toggleDisableNode() {}
|
||||||
|
|
||||||
// @TODO
|
function deleteNode() {
|
||||||
function deleteNode() {}
|
emit('delete');
|
||||||
|
}
|
||||||
|
|
||||||
// @TODO
|
// @TODO
|
||||||
function openContextMenu(_e: MouseEvent, _type: string) {}
|
function openContextMenu(_e: MouseEvent, _type: string) {}
|
||||||
@@ -38,7 +43,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
|
|||||||
size="small"
|
size="small"
|
||||||
icon="play"
|
icon="play"
|
||||||
:disabled="workflowRunning"
|
:disabled="workflowRunning"
|
||||||
:title="$locale.baseText('node.testStep')"
|
:title="i18n.baseText('node.testStep')"
|
||||||
@click="executeNode"
|
@click="executeNode"
|
||||||
/>
|
/>
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
@@ -56,7 +61,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
|
|||||||
size="small"
|
size="small"
|
||||||
text
|
text
|
||||||
icon="trash"
|
icon="trash"
|
||||||
:title="$locale.baseText('node.delete')"
|
:title="i18n.baseText('node.delete')"
|
||||||
@click="deleteNode"
|
@click="deleteNode"
|
||||||
/>
|
/>
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
|
|||||||
306
packages/editor-ui/src/composables/useCanvasOperations.spec.ts
Normal file
306
packages/editor-ui/src/composables/useCanvasOperations.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
215
packages/editor-ui/src/composables/useCanvasOperations.ts
Normal file
215
packages/editor-ui/src/composables/useCanvasOperations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1258,6 +1258,8 @@
|
|||||||
"nodeView.zoomToFit": "Zoom to Fit",
|
"nodeView.zoomToFit": "Zoom to Fit",
|
||||||
"nodeView.replaceMe": "Replace Me",
|
"nodeView.replaceMe": "Replace Me",
|
||||||
"nodeView.setupTemplate": "Set up template",
|
"nodeView.setupTemplate": "Set up template",
|
||||||
|
"nodeViewV2.showError.editingNotAllowed": "Editing is not allowed",
|
||||||
|
"nodeViewV2.showError.failedToCreateNode": "Failed to create node",
|
||||||
"contextMenu.node": "node | nodes",
|
"contextMenu.node": "node | nodes",
|
||||||
"contextMenu.sticky": "sticky note | sticky notes",
|
"contextMenu.sticky": "sticky note | sticky notes",
|
||||||
"contextMenu.selectAll": "Select all",
|
"contextMenu.selectAll": "Select all",
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ export const routes = [
|
|||||||
return !!localStorage.getItem('features.NodeViewV2');
|
return !!localStorage.getItem('features.NodeViewV2');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nodeView: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
return workflow.value.nodes.map((node) => ({ ...node }));
|
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);
|
const node = workflow.value.nodes.find((n) => n.id === id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
@@ -1489,6 +1489,43 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
return !!matchedChatNode;
|
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 {
|
return {
|
||||||
workflow,
|
workflow,
|
||||||
usedCredentials,
|
usedCredentials,
|
||||||
@@ -1620,6 +1657,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
resetChatMessages,
|
resetChatMessages,
|
||||||
appendChatMessage,
|
appendChatMessage,
|
||||||
checkIfNodeHasChatParent,
|
checkIfNodeHasChatParent,
|
||||||
setNodePosition,
|
setNodePositionById,
|
||||||
|
removeNodeById,
|
||||||
|
removeNodeConnectionsById,
|
||||||
|
removeNodeExecutionDataById,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import {
|
|||||||
mapLegacyConnectionsToCanvasConnections,
|
mapLegacyConnectionsToCanvasConnections,
|
||||||
mapLegacyEndpointsToCanvasConnectionPort,
|
mapLegacyEndpointsToCanvasConnectionPort,
|
||||||
getUniqueNodeName,
|
getUniqueNodeName,
|
||||||
|
mapCanvasConnectionToLegacyConnection,
|
||||||
|
parseCanvasConnectionHandleString,
|
||||||
} from '@/utils/canvasUtilsV2';
|
} from '@/utils/canvasUtilsV2';
|
||||||
import { NodeConnectionType, type IConnections, type INodeTypeDescription } from 'n8n-workflow';
|
import { NodeConnectionType, type IConnections, type INodeTypeDescription } from 'n8n-workflow';
|
||||||
import type { CanvasConnection } from '@/types';
|
import type { CanvasConnection } from '@/types';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
|
import type { Connection } from '@vue-flow/core';
|
||||||
|
import { createTestNode } from '@/__tests__/mocks';
|
||||||
|
|
||||||
vi.mock('uuid', () => ({
|
vi.mock('uuid', () => ({
|
||||||
v4: vi.fn(() => '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', () => {
|
describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
|
||||||
it('should return an empty array and log a warning when inputs is a string', () => {
|
it('should return an empty array and log a warning when inputs is a string', () => {
|
||||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|||||||
@@ -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 { INodeUi } from '@/Interface';
|
||||||
import type { CanvasConnection, CanvasConnectionPortType, CanvasConnectionPort } from '@/types';
|
import type { CanvasConnection, CanvasConnectionPortType, CanvasConnectionPort } from '@/types';
|
||||||
|
import type { Connection } from '@vue-flow/core';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||||
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
export function mapLegacyConnectionsToCanvasConnections(
|
export function mapLegacyConnectionsToCanvasConnections(
|
||||||
legacyConnections: IConnections,
|
legacyConnections: IConnections,
|
||||||
@@ -49,6 +52,53 @@ export function mapLegacyConnectionsToCanvasConnections(
|
|||||||
return mappedConnections;
|
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(
|
export function mapLegacyEndpointsToCanvasConnectionPort(
|
||||||
endpoints: INodeTypeDescription['inputs'],
|
endpoints: INodeTypeDescription['inputs'],
|
||||||
): CanvasConnectionPort[] {
|
): CanvasConnectionPort[] {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router';
|
||||||
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
|
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
@@ -31,7 +31,7 @@ import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
|||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
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 { NodeConnectionType } from 'n8n-workflow';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
@@ -42,7 +42,8 @@ import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
|||||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||||
import { useCollaborationStore } from '@/stores/collaboration.store';
|
import { useCollaborationStore } from '@/stores/collaboration.store';
|
||||||
import { getUniqueNodeName } from '@/utils/canvasUtilsV2';
|
import { getUniqueNodeName } from '@/utils/canvasUtilsV2';
|
||||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
import { historyBus } from '@/models/history';
|
||||||
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||||
|
|
||||||
const NodeCreation = defineAsyncComponent(
|
const NodeCreation = defineAsyncComponent(
|
||||||
async () => await import('@/components/Node/NodeCreation.vue'),
|
async () => await import('@/components/Node/NodeCreation.vue'),
|
||||||
@@ -72,6 +73,14 @@ const rootStore = useRootStore();
|
|||||||
const collaborationStore = useCollaborationStore();
|
const collaborationStore = useCollaborationStore();
|
||||||
|
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
const {
|
||||||
|
updateNodePosition,
|
||||||
|
deleteNode,
|
||||||
|
revertDeleteNode,
|
||||||
|
createConnection,
|
||||||
|
deleteConnection,
|
||||||
|
revertDeleteConnection,
|
||||||
|
} = useCanvasOperations();
|
||||||
|
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const readOnlyNotification = ref<null | { visible: boolean }>(null);
|
const readOnlyNotification = ref<null | { visible: boolean }>(null);
|
||||||
@@ -141,6 +150,8 @@ async function initialize() {
|
|||||||
|
|
||||||
initializeEditableWorkflow(workflowId.value);
|
initializeEditableWorkflow(workflowId.value);
|
||||||
|
|
||||||
|
addUndoRedoEventBindings();
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
JSON.stringify({ command: 'n8nReady', version: rootStore.versionCli }),
|
JSON.stringify({ command: 'n8nReady', version: rootStore.versionCli }),
|
||||||
@@ -151,6 +162,30 @@ async function initialize() {
|
|||||||
isLoading.value = false;
|
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
|
// @TODO Maybe move this to the store
|
||||||
function initializeEditableWorkflow(id: string) {
|
function initializeEditableWorkflow(id: string) {
|
||||||
const targetWorkflow = workflowsStore.workflowsById[id];
|
const targetWorkflow = workflowsStore.workflowsById[id];
|
||||||
@@ -198,14 +233,16 @@ async function onRunWorkflow() {
|
|||||||
await runWorkflow({});
|
await runWorkflow({});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function onUpdateNodePosition(id: string, position: CanvasElement['position']) {
|
||||||
* Map new node position format to the old one and update the store
|
updateNodePosition(id, position, { trackHistory: true });
|
||||||
*
|
}
|
||||||
* @param id
|
|
||||||
* @param position
|
function onDeleteNode(id: string) {
|
||||||
*/
|
deleteNode(id, { trackHistory: true });
|
||||||
function onNodePositionUpdate(id: string, position: CanvasElement['position']) {
|
}
|
||||||
workflowsStore.setNodePosition(id, [position.x, position.y]);
|
|
||||||
|
function onRevertDeleteNode({ node }: { node: INodeUi }) {
|
||||||
|
revertDeleteNode(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,83 +250,16 @@ function onNodePositionUpdate(id: string, position: CanvasElement['position']) {
|
|||||||
*
|
*
|
||||||
* @param connection
|
* @param connection
|
||||||
*/
|
*/
|
||||||
function onCreateNodeConnection(connection: Connection) {
|
function onCreateConnection(connection: Connection) {
|
||||||
// Output
|
createConnection(connection);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @TODO Figure out a way to improve this
|
function onDeleteConnection(connection: Connection) {
|
||||||
function checkIfNodeConnectionIsAllowed(_sourceNode: INodeUi, _targetNode: INodeUi): boolean {
|
deleteConnection(connection, { trackHistory: true });
|
||||||
// const targetNodeType = nodeTypesStore.getNodeType(
|
}
|
||||||
// targetNode.type,
|
|
||||||
// targetNode.typeVersion,
|
function onRevertDeleteConnection({ connection }: { connection: [IConnection, IConnection] }) {
|
||||||
// );
|
revertDeleteConnection(connection);
|
||||||
//
|
|
||||||
// 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 onToggleNodeCreator({
|
function onToggleNodeCreator({
|
||||||
@@ -346,19 +316,24 @@ async function onAddNodes(
|
|||||||
) {
|
) {
|
||||||
let currentPosition = position;
|
let currentPosition = position;
|
||||||
for (const { type, name, position: nodePosition, isAutoAdd, openDetail } of nodes) {
|
for (const { type, name, position: nodePosition, isAutoAdd, openDetail } of nodes) {
|
||||||
const _node = await addNode(
|
try {
|
||||||
{
|
await onNodeCreate(
|
||||||
name,
|
{
|
||||||
type,
|
name,
|
||||||
position: nodePosition ?? currentPosition,
|
type,
|
||||||
},
|
position: nodePosition ?? currentPosition,
|
||||||
{
|
},
|
||||||
dragAndDrop,
|
{
|
||||||
openNDV: openDetail ?? false,
|
dragAndDrop,
|
||||||
trackHistory: true,
|
openNDV: openDetail ?? false,
|
||||||
isAutoAdd,
|
trackHistory: true,
|
||||||
},
|
isAutoAdd,
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('error'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const lastAddedNode = editableWorkflow.value.nodes[editableWorkflow.value.nodes.length - 1];
|
const lastAddedNode = editableWorkflow.value.nodes[editableWorkflow.value.nodes.length - 1];
|
||||||
currentPosition = [
|
currentPosition = [
|
||||||
@@ -372,7 +347,7 @@ async function onAddNodes(
|
|||||||
const fromNode = editableWorkflow.value.nodes[newNodesOffset + from.nodeIndex];
|
const fromNode = editableWorkflow.value.nodes[newNodesOffset + from.nodeIndex];
|
||||||
const toNode = editableWorkflow.value.nodes[newNodesOffset + to.nodeIndex];
|
const toNode = editableWorkflow.value.nodes[newNodesOffset + to.nodeIndex];
|
||||||
|
|
||||||
onCreateNodeConnection({
|
onCreateConnection({
|
||||||
source: fromNode.id,
|
source: fromNode.id,
|
||||||
sourceHandle: `outputs/${NodeConnectionType.Main}/${from.outputIndex ?? 0}`,
|
sourceHandle: `outputs/${NodeConnectionType.Main}/${from.outputIndex ?? 0}`,
|
||||||
target: toNode.id,
|
target: toNode.id,
|
||||||
@@ -412,14 +387,14 @@ type AddNodeOptions = {
|
|||||||
isAutoAdd?: boolean;
|
isAutoAdd?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function addNode(node: AddNodeData, _options: AddNodeOptions): Promise<INodeUi | undefined> {
|
async function onNodeCreate(node: AddNodeData, _options: AddNodeOptions = {}): Promise<INodeUi> {
|
||||||
if (!checkIfEditingIsAllowed()) {
|
if (!checkIfEditingIsAllowed()) {
|
||||||
return;
|
throw new Error(i18n.baseText('nodeViewV2.showError.editingNotAllowed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNodeData = await createNodeWithDefaultCredentials(node);
|
const newNodeData = await createNodeWithDefaultCredentials(node);
|
||||||
if (!newNodeData) {
|
if (!newNodeData) {
|
||||||
return;
|
throw new Error(i18n.baseText('nodeViewV2.showError.failedToCreateNode'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -929,8 +904,10 @@ function checkIfEditingIsAllowed(): boolean {
|
|||||||
v-if="editableWorkflow && editableWorkflowObject"
|
v-if="editableWorkflow && editableWorkflowObject"
|
||||||
:workflow="editableWorkflow"
|
:workflow="editableWorkflow"
|
||||||
:workflow-object="editableWorkflowObject"
|
:workflow-object="editableWorkflowObject"
|
||||||
@update:node:position="onNodePositionUpdate"
|
@update:node:position="onUpdateNodePosition"
|
||||||
@create:connection="onCreateNodeConnection"
|
@delete:node="onDeleteNode"
|
||||||
|
@create:connection="onCreateConnection"
|
||||||
|
@delete:connection="onDeleteConnection"
|
||||||
>
|
>
|
||||||
<div :class="$style.executionButtons">
|
<div :class="$style.executionButtons">
|
||||||
<CanvasExecuteWorkflowButton @click="onRunWorkflow" />
|
<CanvasExecuteWorkflowButton @click="onRunWorkflow" />
|
||||||
|
|||||||
Reference in New Issue
Block a user