mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat(editor): Add remove node and connections functionality to canvas v2 (#9602)
This commit is contained in:
parent
202c91e7ed
commit
f6a466cd87
|
@ -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: {},
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
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.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",
|
||||
|
|
|
@ -373,6 +373,7 @@ export const routes = [
|
|||
return !!localStorage.getItem('features.NodeViewV2');
|
||||
},
|
||||
},
|
||||
nodeView: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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(() => {});
|
||||
|
|
|
@ -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[] {
|
||||
|
|
|
@ -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" />
|
||||
|
|
Loading…
Reference in a new issue