feat(editor): Add remove node and connections functionality to canvas v2 (#9602)
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user