From 32ce65c1af11b8e6ba4f3da51b48296221bae8c3 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Tue, 3 Sep 2024 15:11:44 +0300 Subject: [PATCH] feat(editor): Overhaul node insert position computation in new canvas (no-changelog) (#10637) --- packages/editor-ui/src/Interface.ts | 1 + packages/editor-ui/src/__tests__/mocks.ts | 6 +- .../src/components/Node/NodeCreation.vue | 10 +- .../Node/NodeCreator/NodeCreator.vue | 4 +- .../src/components/canvas/Canvas.vue | 25 +- .../nodes/render-types/CanvasNodeDefault.vue | 4 +- .../useCanvasOperations.spec.ts.snap | 8 +- .../__tests__/useCanvasOperations.spec.ts | 897 +++++++++++------- .../src/composables/useCanvasOperations.ts | 283 +++--- .../editor-ui/src/stores/credentials.store.ts | 1 + .../editor-ui/src/stores/nodeCreator.store.ts | 11 +- packages/editor-ui/src/stores/ui.store.ts | 3 + packages/editor-ui/src/utils/canvasUtilsV2.ts | 3 +- packages/editor-ui/src/utils/nodeViewUtils.ts | 3 + packages/editor-ui/src/views/NodeView.v2.vue | 29 +- 15 files changed, 805 insertions(+), 483 deletions(-) diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index e49b0491b1..f7ad20f9ea 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1805,6 +1805,7 @@ export type ToggleNodeCreatorOptions = { createNodeActive: boolean; source?: NodeCreatorOpenSource; nodeCreatorView?: NodeFilterType; + hasAddedNodes?: boolean; }; export type AppliedThemeOption = 'light' | 'dark'; diff --git a/packages/editor-ui/src/__tests__/mocks.ts b/packages/editor-ui/src/__tests__/mocks.ts index cb15a9bf68..993d5870a5 100644 --- a/packages/editor-ui/src/__tests__/mocks.ts +++ b/packages/editor-ui/src/__tests__/mocks.ts @@ -49,18 +49,18 @@ export const mockNode = ({ }) => mock({ id, name, type, position, disabled, issues, typeVersion, parameters }); export const mockNodeTypeDescription = ({ - name, + name = SET_NODE_TYPE, version = 1, credentials = [], inputs = [NodeConnectionType.Main], outputs = [NodeConnectionType.Main], }: { - name: INodeTypeDescription['name']; + name?: INodeTypeDescription['name']; version?: INodeTypeDescription['version']; credentials?: INodeTypeDescription['credentials']; inputs?: INodeTypeDescription['inputs']; outputs?: INodeTypeDescription['outputs']; -}) => +} = {}) => mock({ name, displayName: name, diff --git a/packages/editor-ui/src/components/Node/NodeCreation.vue b/packages/editor-ui/src/components/Node/NodeCreation.vue index dd6f876d30..38c89d135a 100644 --- a/packages/editor-ui/src/components/Node/NodeCreation.vue +++ b/packages/editor-ui/src/components/Node/NodeCreation.vue @@ -23,7 +23,7 @@ const LazyNodeCreator = defineAsyncComponent( ); const props = withDefaults(defineProps(), { - createNodeActive: false, + createNodeActive: false, // Determines if the node creator is open }); const emit = defineEmits<{ @@ -88,13 +88,15 @@ function addStickyNote() { emit('addNodes', getAddedNodesAndConnections([{ type: STICKY_NODE_TYPE, position }])); } -function closeNodeCreator() { - emit('toggleNodeCreator', { createNodeActive: false }); +function closeNodeCreator(hasAddedNodes = false) { + if (props.createNodeActive) { + emit('toggleNodeCreator', { createNodeActive: false, hasAddedNodes }); + } } function nodeTypeSelected(nodeTypes: string[]) { emit('addNodes', getAddedNodesAndConnections(nodeTypes.map((type) => ({ type })))); - closeNodeCreator(); + closeNodeCreator(true); } diff --git a/packages/editor-ui/src/components/Node/NodeCreator/NodeCreator.vue b/packages/editor-ui/src/components/Node/NodeCreator/NodeCreator.vue index b446ade75e..d93e83ebfa 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/NodeCreator.vue +++ b/packages/editor-ui/src/components/Node/NodeCreator/NodeCreator.vue @@ -101,8 +101,8 @@ watch( ); // Close node creator when the last view stacks is closed -watch(viewStacksLength, (viewStacksLength) => { - if (viewStacksLength === 0) { +watch(viewStacksLength, (value) => { + if (value === 0) { emit('closeNodeCreator'); setShowScrim(false); } diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue index b456ec0df1..841028fa05 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -57,7 +57,11 @@ const emit = defineEmits<{ 'create:connection:start': [handle: ConnectStartEvent]; 'create:connection': [connection: Connection]; 'create:connection:end': [connection: Connection, event?: MouseEvent]; - 'create:connection:cancelled': [handle: ConnectStartEvent, event?: MouseEvent]; + 'create:connection:cancelled': [ + handle: ConnectStartEvent, + position: XYPosition, + event?: MouseEvent, + ]; 'click:connection:add': [connection: Connection]; 'click:pane': [position: XYPosition]; 'run:workflow': []; @@ -227,7 +231,7 @@ function onConnectEnd(event?: MouseEvent) { if (connectedHandle.value) { emit('create:connection:end', connectedHandle.value, event); } else if (connectingHandle.value) { - emit('create:connection:cancelled', connectingHandle.value, event); + emit('create:connection:cancelled', connectingHandle.value, getProjectedPosition(event), event); } connectedHandle.value = undefined; @@ -291,14 +295,19 @@ function emitWithLastSelectedNode(emitFn: (id: string) => void) { const defaultZoom = 1; const zoom = ref(defaultZoom); -function onClickPane(event: MouseEvent) { +function getProjectedPosition(event?: MouseEvent) { const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 }; - const position = project({ - x: event.offsetX - bounds.left, - y: event.offsetY - bounds.top, - }); + const offsetX = event?.clientX ?? 0; + const offsetY = event?.clientY ?? 0; - emit('click:pane', position); + return project({ + x: offsetX - bounds.left, + y: offsetY - bounds.top, + }); +} + +function onClickPane(event: MouseEvent) { + emit('click:pane', getProjectedPosition(event)); } async function onFitView() { diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue index 2f50454baa..2bbb2421d1 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -145,8 +145,8 @@ function openContextMenu(event: MouseEvent) { */ &.configuration { - --canvas-node--width: 76px; - --canvas-node--height: 76px; + --canvas-node--width: 80px; + --canvas-node--height: 80px; background: var(--canvas-node--background, var(--node-type-supplemental-background)); border: var(--canvas-node-border-width) solid diff --git a/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap b/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap index 875db00093..079a5d2f51 100644 --- a/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap +++ b/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap @@ -9,7 +9,7 @@ exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = ` "parameters": {}, "id": "1", "name": "Node 1", - "type": "type", + "type": "n8n-nodes-base.set", "position": [ 40, 40 @@ -20,7 +20,7 @@ exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = ` "parameters": {}, "id": "2", "name": "Node 2", - "type": "type", + "type": "n8n-nodes-base.set", "position": [ 40, 40 @@ -44,7 +44,7 @@ exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = ` "parameters": {}, "id": "1", "name": "Node 1", - "type": "type", + "type": "n8n-nodes-base.set", "position": [ 40, 40 @@ -55,7 +55,7 @@ exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = ` "parameters": {}, "id": "2", "name": "Node 2", - "type": "type", + "type": "n8n-nodes-base.set", "position": [ 40, 40 diff --git a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts index 7e6c9a0921..0a6b75d1ef 100644 --- a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts +++ b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts @@ -1,7 +1,6 @@ -import { createPinia, setActivePinia } from 'pinia'; -import type { Connection } from '@vue-flow/core'; +import { setActivePinia } from 'pinia'; import type { IConnection, Workflow } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionType, NodeHelpers } from 'n8n-workflow'; import { useCanvasOperations } from '@/composables/useCanvasOperations'; import type { CanvasNode } from '@/types'; import type { ICredentialsResponse, INodeUi, IWorkflowDb } from '@/Interface'; @@ -20,10 +19,12 @@ import { useRouter } from 'vue-router'; import { mock } from 'vitest-mock-extended'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useCredentialsStore } from '@/stores/credentials.store'; -import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; -import { telemetry } from '@/plugins/telemetry'; -import { useClipboard } from '@/composables/useClipboard'; import { waitFor } from '@testing-library/vue'; +import { createTestingPinia } from '@pinia/testing'; +import { mockedStore } from '@/__tests__/utils'; +import { SET_NODE_TYPE, STORES } from '@/constants'; +import type { Connection } from '@vue-flow/core'; +import { useClipboard } from '@/composables/useClipboard'; vi.mock('vue-router', async (importOriginal) => { const actual = await importOriginal<{}>(); @@ -33,78 +34,92 @@ vi.mock('vue-router', async (importOriginal) => { }; }); +vi.mock('n8n-workflow', async (importOriginal) => { + const actual = await importOriginal<{}>(); + return { + ...actual, + TelemetryHelpers: { + generateNodesGraph: vi.fn().mockReturnValue({ + nodeGraph: { + nodes: [], + }, + }), + }, + }; +}); + vi.mock('@/composables/useClipboard', async () => { const copySpy = vi.fn(); return { useClipboard: vi.fn(() => ({ copy: copySpy })) }; }); -describe('useCanvasOperations', () => { - let workflowsStore: ReturnType; - let uiStore: ReturnType; - let ndvStore: ReturnType; - let historyStore: ReturnType; - let nodeTypesStore: ReturnType; - let credentialsStore: ReturnType; - let canvasOperations: ReturnType; - let workflowHelpers: ReturnType; +vi.mock('@/composables/useTelemetry', () => ({ + useTelemetry: () => ({ track: vi.fn() }), +})); +describe('useCanvasOperations', () => { const router = useRouter(); + const workflowId = 'test'; + const initialState = { + [STORES.NODE_TYPES]: {}, + [STORES.NDV]: {}, + [STORES.WORKFLOWS]: { + workflowId, + workflow: mock({ + id: workflowId, + nodes: [], + connections: {}, + tags: [], + usedCredentials: [], + }), + }, + [STORES.SETTINGS]: { + settings: { + enterprise: {}, + }, + }, + }; + beforeEach(async () => { - const pinia = createPinia(); + const pinia = createTestingPinia({ initialState }); setActivePinia(pinia); - - workflowsStore = useWorkflowsStore(); - uiStore = useUIStore(); - ndvStore = useNDVStore(); - historyStore = useHistoryStore(); - nodeTypesStore = useNodeTypesStore(); - credentialsStore = useCredentialsStore(); - workflowHelpers = useWorkflowHelpers({ router }); - - const workflowId = 'test'; - const workflow = mock({ - id: workflowId, - nodes: [], - connections: {}, - tags: [], - usedCredentials: [], - }); - - workflowsStore.resetWorkflow(); - workflowsStore.resetState(); - workflowHelpers.initState(workflow); - - canvasOperations = useCanvasOperations({ router }); vi.clearAllMocks(); }); describe('requireNodeTypeDescription', () => { it('should return node type description when type and version match', () => { + const nodeTypesStore = useNodeTypesStore(); const type = 'testType'; const version = 1; const expectedDescription = mockNodeTypeDescription({ name: type, version }); - nodeTypesStore.setNodeTypes([expectedDescription]); - const result = canvasOperations.requireNodeTypeDescription(type, version); + nodeTypesStore.nodeTypes = { [type]: { [version]: expectedDescription } }; + + const { requireNodeTypeDescription } = useCanvasOperations({ router }); + const result = requireNodeTypeDescription(type, version); expect(result).toBe(expectedDescription); }); it('should throw an error when node type does not exist', () => { const type = 'nonexistentType'; + const { requireNodeTypeDescription } = useCanvasOperations({ router }); expect(() => { - canvasOperations.requireNodeTypeDescription(type); + requireNodeTypeDescription(type); }).toThrow(); }); it('should return node type description when only type is provided and it exists', () => { + const nodeTypesStore = useNodeTypesStore(); const type = 'testTypeWithoutVersion'; const expectedDescription = mockNodeTypeDescription({ name: type }); - nodeTypesStore.setNodeTypes([expectedDescription]); - const result = canvasOperations.requireNodeTypeDescription(type); + nodeTypesStore.nodeTypes = { [type]: { 2: expectedDescription } }; + + const { requireNodeTypeDescription } = useCanvasOperations({ router }); + const result = requireNodeTypeDescription(type); expect(result).toBe(expectedDescription); }); @@ -112,7 +127,8 @@ describe('useCanvasOperations', () => { describe('addNode', () => { it('should create node with default version when version is undefined', () => { - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { name: 'example', type: 'type', @@ -125,7 +141,8 @@ describe('useCanvasOperations', () => { }); it('should create node with default position when position is not provided', () => { - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { type: 'type', typeVersion: 1, @@ -137,7 +154,8 @@ describe('useCanvasOperations', () => { }); it('should create node with provided position when position is provided', () => { - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { type: 'type', typeVersion: 1, @@ -150,6 +168,7 @@ describe('useCanvasOperations', () => { }); it('should create node with default credentials when only one credential is available', () => { + const credentialsStore = useCredentialsStore(); const credential = mock({ id: '1', name: 'cred', type: 'cred' }); const nodeTypeName = 'type'; const nodeTypeDescription = mockNodeTypeDescription({ @@ -157,14 +176,17 @@ describe('useCanvasOperations', () => { credentials: [{ name: credential.name }], }); - credentialsStore.addCredentials([credential]); + credentialsStore.state.credentials = { + [credential.id]: credential, + }; // @ts-expect-error Known pinia issue when spying on store getters vi.spyOn(credentialsStore, 'getUsableCredentialByType', 'get').mockReturnValue(() => [ credential, ]); - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { type: nodeTypeName, typeVersion: 1, @@ -176,6 +198,7 @@ describe('useCanvasOperations', () => { }); it('should not assign credentials when multiple credentials are available', () => { + const credentialsStore = useCredentialsStore(); const credentialA = mock({ id: '1', name: 'credA', type: 'cred' }); const credentialB = mock({ id: '1', name: 'credB', type: 'cred' }); const nodeTypeName = 'type'; @@ -190,7 +213,8 @@ describe('useCanvasOperations', () => { credentialB, ]); - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { type: 'type', typeVersion: 1, @@ -201,9 +225,11 @@ describe('useCanvasOperations', () => { }); it('should open NDV when specified', async () => { + const ndvStore = useNDVStore(); const nodeTypeDescription = mockNodeTypeDescription({ name: 'type' }); - canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + addNode( { type: 'type', typeVersion: 1, @@ -213,12 +239,123 @@ describe('useCanvasOperations', () => { { openNDV: true }, ); - await waitFor(() => expect(ndvStore.activeNodeName).toBe('Test Name')); + await waitFor(() => expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith('Test Name')); + }); + }); + + describe('resolveNodePosition', () => { + it('should return the node position if it is already set', () => { + const node = createTestNode({ position: [100, 100] }); + const nodeTypeDescription = mockNodeTypeDescription(); + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition(node, nodeTypeDescription); + + expect(position).toEqual([100, 100]); + }); + + it('should place the node at the last cancelled connection position', () => { + const uiStore = mockedStore(useUIStore); + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + vi.spyOn(uiStore, 'lastInteractedWithNode', 'get').mockReturnValue(node); + + uiStore.lastInteractedWithNodeHandle = 'inputs/main/0'; + uiStore.lastCancelledConnectionPosition = [200, 200]; + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([200, 160]); + expect(uiStore.lastCancelledConnectionPosition).toBeNull(); + }); + + it('should place the node to the right of the last interacted with node', () => { + const uiStore = mockedStore(useUIStore); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + uiStore.lastInteractedWithNode = createTestNode({ + position: [100, 100], + type: 'test', + typeVersion: 1, + }); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + workflowsStore.getCurrentWorkflow.mockReturnValue( + createTestWorkflowObject(workflowsStore.workflow), + ); + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([320, 100]); + }); + + it('should place the node below the last interacted with node if it has non-main outputs', () => { + const uiStore = mockedStore(useUIStore); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + uiStore.lastInteractedWithNode = createTestNode({ + position: [100, 100], + type: 'test', + typeVersion: 1, + }); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + workflowsStore.getCurrentWorkflow.mockReturnValue( + createTestWorkflowObject(workflowsStore.workflow), + ); + + vi.spyOn(NodeHelpers, 'getNodeOutputs').mockReturnValue([ + { type: NodeConnectionType.AiTool }, + ]); + vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionType.AiTool]); + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([460, 100]); + }); + + it('should place the node at the last clicked position if no other position is set', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + workflowsStore.workflowTriggerNodes = [ + createTestNode({ id: 'trigger', position: [100, 100] }), + ]; + + const { resolveNodePosition, lastClickPosition } = useCanvasOperations({ router }); + lastClickPosition.value = [300, 300]; + + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([300, 300]); + }); + + it('should place the trigger node at the root if it is the first trigger node', () => { + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([0, 0]); }); }); describe('updateNodesPosition', () => { it('records history for multiple node position updates when tracking is enabled', () => { + const historyStore = useHistoryStore(); const events = [ { id: 'node1', position: { x: 100, y: 100 } }, { id: 'node2', position: { x: 200, y: 200 } }, @@ -226,19 +363,21 @@ describe('useCanvasOperations', () => { const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo'); const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo'); - canvasOperations.updateNodesPosition(events, { trackHistory: true, trackBulk: true }); + const { updateNodesPosition } = useCanvasOperations({ router }); + updateNodesPosition(events, { trackHistory: true, trackBulk: true }); expect(startRecordingUndoSpy).toHaveBeenCalled(); expect(stopRecordingUndoSpy).toHaveBeenCalled(); }); it('updates positions for multiple nodes', () => { + const workflowsStore = mockedStore(useWorkflowsStore); const events = [ { id: 'node1', position: { x: 100, y: 100 } }, { id: 'node2', position: { x: 200, y: 200 } }, ]; const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); - vi.spyOn(workflowsStore, 'getNodeById') + workflowsStore.getNodeById .mockReturnValueOnce( createTestNode({ id: events[0].id, @@ -252,7 +391,8 @@ describe('useCanvasOperations', () => { }), ); - canvasOperations.updateNodesPosition(events); + const { updateNodesPosition } = useCanvasOperations({ router }); + updateNodesPosition(events); expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2); expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]); @@ -260,11 +400,13 @@ describe('useCanvasOperations', () => { }); it('does not record history when trackHistory is false', () => { + const historyStore = useHistoryStore(); const events = [{ id: 'node1', position: { x: 100, y: 100 } }]; const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo'); const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo'); - canvasOperations.updateNodesPosition(events, { trackHistory: false, trackBulk: false }); + const { updateNodesPosition } = useCanvasOperations({ router }); + updateNodesPosition(events, { trackHistory: false, trackBulk: false }); expect(startRecordingUndoSpy).not.toHaveBeenCalled(); expect(stopRecordingUndoSpy).not.toHaveBeenCalled(); @@ -273,9 +415,7 @@ describe('useCanvasOperations', () => { describe('updateNodePosition', () => { it('should update node position', () => { - const setNodePositionByIdSpy = vi - .spyOn(workflowsStore, 'setNodePositionById') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const id = 'node1'; const position: CanvasNode['position'] = { x: 10, y: 20 }; const node = createTestNode({ @@ -285,40 +425,49 @@ describe('useCanvasOperations', () => { name: 'Node 1', }); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(node); + workflowsStore.getNodeById.mockReturnValueOnce(node); - canvasOperations.updateNodePosition(id, position); + const { updateNodePosition } = useCanvasOperations({ router }); + updateNodePosition(id, position); - expect(setNodePositionByIdSpy).toHaveBeenCalledWith(id, [position.x, position.y]); + expect(workflowsStore.setNodePositionById).toHaveBeenCalledWith(id, [position.x, position.y]); }); }); describe('setNodeSelected', () => { it('should set last selected node when node id is provided and node exists', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = useUIStore(); const nodeId = 'node1'; const nodeName = 'Node 1'; workflowsStore.getNodeById = vi.fn().mockReturnValue({ name: nodeName }); uiStore.lastSelectedNode = ''; - canvasOperations.setNodeSelected(nodeId); + const { setNodeSelected } = useCanvasOperations({ router }); + setNodeSelected(nodeId); expect(uiStore.lastSelectedNode).toBe(nodeName); }); it('should not change last selected node when node id is provided but node does not exist', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = useUIStore(); const nodeId = 'node1'; workflowsStore.getNodeById = vi.fn().mockReturnValue(undefined); uiStore.lastSelectedNode = 'Existing Node'; - canvasOperations.setNodeSelected(nodeId); + const { setNodeSelected } = useCanvasOperations({ router }); + setNodeSelected(nodeId); expect(uiStore.lastSelectedNode).toBe('Existing Node'); }); it('should clear last selected node when node id is not provided', () => { + const uiStore = useUIStore(); uiStore.lastSelectedNode = 'Existing Node'; - canvasOperations.setNodeSelected(); + const { setNodeSelected } = useCanvasOperations({ router }); + setNodeSelected(); expect(uiStore.lastSelectedNode).toBe(''); }); @@ -326,54 +475,74 @@ describe('useCanvasOperations', () => { describe('addNodes', () => { it('should add nodes at specified positions', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); const nodeTypeName = 'type'; const nodes = [ mockNode({ name: 'Node 1', type: nodeTypeName, position: [30, 40] }), mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }), ]; - nodeTypesStore.setNodeTypes([ - mockNodeTypeDescription({ - name: nodeTypeName, - }), - ]); + workflowsStore.getCurrentWorkflow.mockReturnValue( + createTestWorkflowObject(workflowsStore.workflow), + ); - await canvasOperations.addNodes(nodes, {}); + nodeTypesStore.nodeTypes = { + [nodeTypeName]: { 1: mockNodeTypeDescription({ name: nodeTypeName }) }, + }; - expect(workflowsStore.workflow.nodes).toHaveLength(2); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('name', nodes[0].name); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('parameters', {}); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('type', nodeTypeName); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('typeVersion', 1); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('position'); + const { addNodes } = useCanvasOperations({ router }); + await addNodes(nodes, {}); + + expect(workflowsStore.addNode).toHaveBeenCalledTimes(2); + expect(workflowsStore.addNode.mock.calls[0][0]).toMatchObject({ + name: nodes[0].name, + type: nodeTypeName, + typeVersion: 1, + position: [40, 40], + parameters: {}, + }); + expect(workflowsStore.addNode.mock.calls[1][0]).toMatchObject({ + name: nodes[1].name, + type: nodeTypeName, + typeVersion: 1, + position: [100, 240], + parameters: {}, + }); }); it('should add nodes at current position when position is not specified', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); const nodeTypeName = 'type'; const nodes = [ mockNode({ name: 'Node 1', type: nodeTypeName, position: [120, 120] }), mockNode({ name: 'Node 2', type: nodeTypeName, position: [180, 320] }), ]; - const workflowStoreAddNodeSpy = vi.spyOn(workflowsStore, 'addNode'); - nodeTypesStore.setNodeTypes([ - mockNodeTypeDescription({ - name: nodeTypeName, - }), - ]); + workflowsStore.getCurrentWorkflow.mockReturnValue( + createTestWorkflowObject(workflowsStore.workflow), + ); - await canvasOperations.addNodes(nodes, { position: [50, 60] }); + nodeTypesStore.nodeTypes = { + [nodeTypeName]: { 1: mockNodeTypeDescription({ name: nodeTypeName }) }, + }; - expect(workflowStoreAddNodeSpy).toHaveBeenCalledTimes(2); - expect(workflowStoreAddNodeSpy.mock.calls[0][0].position).toEqual( + const { addNodes } = useCanvasOperations({ router }); + await addNodes(nodes, { position: [50, 60] }); + + expect(workflowsStore.addNode).toHaveBeenCalledTimes(2); + expect(workflowsStore.addNode.mock.calls[0][0].position).toEqual( expect.arrayContaining(nodes[0].position), ); - expect(workflowStoreAddNodeSpy.mock.calls[1][0].position).toEqual( + expect(workflowsStore.addNode.mock.calls[1][0].position).toEqual( expect.arrayContaining(nodes[1].position), ); }); it('should adjust the position of nodes with multiple inputs', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); const nodeTypeName = 'type'; const nodes = [ mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }), @@ -382,18 +551,12 @@ describe('useCanvasOperations', () => { ]; const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); - vi.spyOn(workflowsStore, 'getNodeByName') - .mockReturnValueOnce(nodes[1]) - .mockReturnValueOnce(nodes[2]); - vi.spyOn(workflowsStore, 'getNodeById') - .mockReturnValueOnce(nodes[1]) - .mockReturnValueOnce(nodes[2]); + workflowsStore.getNodeByName.mockReturnValueOnce(nodes[1]).mockReturnValueOnce(nodes[2]); + workflowsStore.getNodeById.mockReturnValueOnce(nodes[1]).mockReturnValueOnce(nodes[2]); - nodeTypesStore.setNodeTypes([ - mockNodeTypeDescription({ - name: nodeTypeName, - }), - ]); + nodeTypesStore.nodeTypes = { + [nodeTypeName]: { 1: mockNodeTypeDescription({ name: nodeTypeName }) }, + }; vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockImplementation(() => mock({ @@ -406,7 +569,8 @@ describe('useCanvasOperations', () => { }), ); - await canvasOperations.addNodes(nodes, {}); + const { addNodes } = useCanvasOperations({ router }); + await addNodes(nodes, {}); expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2); expect(setNodePositionByIdSpy).toHaveBeenCalledWith(nodes[1].id, expect.any(Object)); @@ -416,12 +580,14 @@ describe('useCanvasOperations', () => { describe('revertAddNode', () => { it('deletes node if it exists', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); const node = createTestNode(); - vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValueOnce(node); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(node); + workflowsStore.getNodeByName.mockReturnValueOnce(node); + workflowsStore.getNodeById.mockReturnValueOnce(node); const removeNodeByIdSpy = vi.spyOn(workflowsStore, 'removeNodeById'); - await canvasOperations.revertAddNode(node.name); + const { revertAddNode } = useCanvasOperations({ router }); + await revertAddNode(node.name); expect(removeNodeByIdSpy).toHaveBeenCalledWith(node.id); }); @@ -429,18 +595,12 @@ describe('useCanvasOperations', () => { 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 workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.incomingConnectionsByNodeName.mockReturnValue({}); const id = 'node1'; const node: INodeUi = createTestNode({ @@ -450,29 +610,24 @@ describe('useCanvasOperations', () => { name: 'Node 1', }); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node); + workflowsStore.getNodeById.mockReturnValue(node); - canvasOperations.deleteNode(id, { trackHistory: true }); + const { deleteNode } = useCanvasOperations({ router }); + deleteNode(id, { trackHistory: true }); - expect(removeNodeByIdSpy).toHaveBeenCalledWith(id); - expect(removeNodeConnectionsByIdSpy).toHaveBeenCalledWith(id); - expect(removeNodeExecutionDataByIdSpy).toHaveBeenCalledWith(id); - expect(pushCommandToUndoSpy).toHaveBeenCalledWith(new RemoveNodeCommand(node)); + expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id); + expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(id); + expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id); + expect(historyStore.pushCommandToUndo).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 workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.incomingConnectionsByNodeName.mockReturnValue({}); const id = 'node1'; const node = createTestNode({ @@ -483,84 +638,91 @@ describe('useCanvasOperations', () => { parameters: {}, }); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node); + workflowsStore.getNodeById.mockReturnValue(node); - canvasOperations.deleteNode(id, { trackHistory: false }); + const { deleteNode } = useCanvasOperations({ router }); + deleteNode(id, { trackHistory: false }); - expect(removeNodeByIdSpy).toHaveBeenCalledWith(id); - expect(removeNodeConnectionsByIdSpy).toHaveBeenCalledWith(id); - expect(removeNodeExecutionDataByIdSpy).toHaveBeenCalledWith(id); - expect(pushCommandToUndoSpy).not.toHaveBeenCalled(); + expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id); + expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(id); + expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id); + expect(historyStore.pushCommandToUndo).not.toHaveBeenCalled(); }); it('should connect adjacent nodes when deleting a node surrounded by other nodes', () => { - nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'node' })]); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + nodeTypesStore.nodeTypes = { + [SET_NODE_TYPE]: { 1: mockNodeTypeDescription({ name: SET_NODE_TYPE }) }, + }; + const nodes = [ createTestNode({ id: 'input', - type: 'node', + type: SET_NODE_TYPE, position: [10, 20], name: 'Input Node', }), createTestNode({ id: 'middle', - type: 'node', + type: SET_NODE_TYPE, position: [10, 20], name: 'Middle Node', }), createTestNode({ id: 'output', - type: 'node', + type: SET_NODE_TYPE, position: [10, 20], name: 'Output Node', }), ]; - workflowsStore.setNodes(nodes); - workflowsStore.setConnections({ - 'Input Node': { - main: [ - [ - { - node: 'Middle Node', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - 'Middle Node': { - main: [ - [ - { - node: 'Output Node', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - }); - canvasOperations.deleteNode('middle'); - expect(workflowsStore.allConnections).toEqual({ - 'Input Node': { + workflowsStore.workflow.nodes = nodes; + workflowsStore.workflow.connections = { + [nodes[0].name]: { main: [ [ { - node: 'Output Node', + node: nodes[1].name, type: NodeConnectionType.Main, index: 0, }, ], ], }, - }); + [nodes[1].name]: { + main: [ + [ + { + node: nodes[2].name, + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }; + + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.incomingConnectionsByNodeName.mockReturnValue({}); + + workflowsStore.getNodeById.mockReturnValue(nodes[1]); + + const { deleteNode } = useCanvasOperations({ router }); + deleteNode(nodes[1].id); + + expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id); + expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(nodes[1].id); + expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(nodes[1].id); + expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id); }); }); describe('revertDeleteNode', () => { it('should revert delete node', () => { - const addNodeSpy = vi.spyOn(workflowsStore, 'addNode').mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const node = createTestNode({ id: 'node1', @@ -570,37 +732,42 @@ describe('useCanvasOperations', () => { parameters: {}, }); - canvasOperations.revertDeleteNode(node); + const { revertDeleteNode } = useCanvasOperations({ router }); + revertDeleteNode(node); - expect(addNodeSpy).toHaveBeenCalledWith(node); + expect(workflowsStore.addNode).toHaveBeenCalledWith(node); }); }); describe('renameNode', () => { it('should rename node', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const oldName = 'Old Node'; const newName = 'New Node'; const workflowObject = createTestWorkflowObject(); workflowObject.renameNode = vi.fn(); - - vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockReturnValue(workflowObject); - + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); workflowsStore.getNodeByName = vi.fn().mockReturnValue({ name: oldName }); ndvStore.activeNodeName = oldName; - await canvasOperations.renameNode(oldName, newName); + const { renameNode } = useCanvasOperations({ router }); + await renameNode(oldName, newName); expect(workflowObject.renameNode).toHaveBeenCalledWith(oldName, newName); expect(ndvStore.activeNodeName).toBe(newName); }); it('should not rename node when new name is same as old name', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const oldName = 'Old Node'; workflowsStore.getNodeByName = vi.fn().mockReturnValue({ name: oldName }); ndvStore.activeNodeName = oldName; - await canvasOperations.renameNode(oldName, oldName); + const { renameNode } = useCanvasOperations({ router }); + await renameNode(oldName, oldName); expect(ndvStore.activeNodeName).toBe(oldName); }); @@ -608,22 +775,32 @@ describe('useCanvasOperations', () => { describe('revertRenameNode', () => { it('should revert node renaming', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const oldName = 'Old Node'; const currentName = 'New Node'; + + const workflowObject = createTestWorkflowObject(); + workflowObject.renameNode = vi.fn(); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); workflowsStore.getNodeByName = vi.fn().mockReturnValue({ name: currentName }); ndvStore.activeNodeName = currentName; - await canvasOperations.revertRenameNode(currentName, oldName); + const { revertRenameNode } = useCanvasOperations({ router }); + await revertRenameNode(currentName, oldName); expect(ndvStore.activeNodeName).toBe(oldName); }); it('should not revert node renaming when old name is same as new name', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const oldName = 'Old Node'; workflowsStore.getNodeByName = vi.fn().mockReturnValue({ name: oldName }); ndvStore.activeNodeName = oldName; - await canvasOperations.revertRenameNode(oldName, oldName); + const { revertRenameNode } = useCanvasOperations({ router }); + await revertRenameNode(oldName, oldName); expect(ndvStore.activeNodeName).toBe(oldName); }); @@ -631,22 +808,28 @@ describe('useCanvasOperations', () => { describe('setNodeActive', () => { it('should set active node name when node exists', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const nodeId = 'node1'; const nodeName = 'Node 1'; workflowsStore.getNodeById = vi.fn().mockReturnValue({ name: nodeName }); ndvStore.activeNodeName = ''; - canvasOperations.setNodeActive(nodeId); + const { setNodeActive } = useCanvasOperations({ router }); + setNodeActive(nodeId); expect(ndvStore.activeNodeName).toBe(nodeName); }); it('should not change active node name when node does not exist', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const nodeId = 'node1'; workflowsStore.getNodeById = vi.fn().mockReturnValue(undefined); ndvStore.activeNodeName = 'Existing Node'; - canvasOperations.setNodeActive(nodeId); + const { setNodeActive } = useCanvasOperations({ router }); + setNodeActive(nodeId); expect(ndvStore.activeNodeName).toBe('Existing Node'); }); @@ -654,10 +837,12 @@ describe('useCanvasOperations', () => { describe('setNodeActiveByName', () => { it('should set active node name', () => { + const ndvStore = useNDVStore(); const nodeName = 'Node 1'; ndvStore.activeNodeName = ''; - canvasOperations.setNodeActiveByName(nodeName); + const { setNodeActiveByName } = useCanvasOperations({ router }); + setNodeActiveByName(nodeName); expect(ndvStore.activeNodeName).toBe(nodeName); }); @@ -665,19 +850,20 @@ describe('useCanvasOperations', () => { describe('toggleNodesDisabled', () => { it('disables nodes based on provided ids', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); const nodes = [ createTestNode({ id: '1', name: 'A' }), createTestNode({ id: '2', name: 'B' }), ]; - vi.spyOn(workflowsStore, 'getNodesByIds').mockReturnValue(nodes); - const updateNodePropertiesSpy = vi.spyOn(workflowsStore, 'updateNodeProperties'); + workflowsStore.getNodesByIds.mockReturnValue(nodes); - canvasOperations.toggleNodesDisabled([nodes[0].id, nodes[1].id], { + const { toggleNodesDisabled } = useCanvasOperations({ router }); + toggleNodesDisabled([nodes[0].id, nodes[1].id], { trackHistory: true, trackBulk: true, }); - expect(updateNodePropertiesSpy).toHaveBeenCalledWith({ + expect(workflowsStore.updateNodeProperties).toHaveBeenCalledWith({ name: nodes[0].name, properties: { disabled: true, @@ -688,12 +874,14 @@ describe('useCanvasOperations', () => { describe('revertToggleNodeDisabled', () => { it('re-enables a previously disabled node', () => { + const workflowsStore = mockedStore(useWorkflowsStore); const nodeName = 'testNode'; const node = createTestNode({ name: nodeName }); - vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node); + workflowsStore.getNodeByName.mockReturnValue(node); const updateNodePropertiesSpy = vi.spyOn(workflowsStore, 'updateNodeProperties'); - canvasOperations.revertToggleNodeDisabled(nodeName); + const { revertToggleNodeDisabled } = useCanvasOperations({ router }); + revertToggleNodeDisabled(nodeName); expect(updateNodePropertiesSpy).toHaveBeenCalledWith({ name: nodeName, @@ -706,25 +894,19 @@ describe('useCanvasOperations', () => { describe('addConnections', () => { it('should create connections between nodes', async () => { - const nodeTypeName = 'type'; + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + const nodeTypeName = SET_NODE_TYPE; + const nodeType = mockNodeTypeDescription({ + name: nodeTypeName, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + }); const nodes = [ mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }), mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [40, 40] }), mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [40, 40] }), ]; - - nodeTypesStore.setNodeTypes([ - mockNodeTypeDescription({ - name: nodeTypeName, - }), - ]); - - await canvasOperations.addNodes(nodes, {}); - - vi.spyOn(workflowsStore, 'getNodeById') - .mockReturnValueOnce(nodes[0]) - .mockReturnValueOnce(nodes[1]); - const connections = [ { source: nodes[0].id, @@ -744,11 +926,20 @@ describe('useCanvasOperations', () => { }, ]; - const addConnectionSpy = vi.spyOn(workflowsStore, 'addConnection'); + workflowsStore.workflow.nodes = nodes; + nodeTypesStore.nodeTypes = { + [nodeTypeName]: { 1: nodeType }, + }; - canvasOperations.addConnections(connections); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.getNodeById.mockReturnValueOnce(nodes[0]).mockReturnValueOnce(nodes[1]); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeType); - expect(addConnectionSpy).toHaveBeenCalledWith({ + const { addConnections } = useCanvasOperations({ router }); + addConnections(connections); + + expect(workflowsStore.addConnection).toHaveBeenCalledWith({ connection: [ { index: 0, @@ -767,54 +958,58 @@ describe('useCanvasOperations', () => { describe('createConnection', () => { it('should not create a connection if source node does not exist', () => { - const addConnectionSpy = vi - .spyOn(workflowsStore, 'addConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = mockedStore(useUIStore); const connection: Connection = { source: 'nonexistent', target: 'targetNode' }; - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(undefined); + workflowsStore.getNodeById.mockReturnValueOnce(undefined); - canvasOperations.createConnection(connection); + const { createConnection } = useCanvasOperations({ router }); + createConnection(connection); - expect(addConnectionSpy).not.toHaveBeenCalled(); + expect(workflowsStore.addConnection).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 workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = mockedStore(useUIStore); const connection: Connection = { source: 'sourceNode', target: 'nonexistent' }; - vi.spyOn(workflowsStore, 'getNodeById') + workflowsStore.getNodeById .mockReturnValueOnce(createTestNode()) .mockReturnValueOnce(undefined); - canvasOperations.createConnection(connection); + const { createConnection } = useCanvasOperations({ router }); + createConnection(connection); - expect(addConnectionSpy).not.toHaveBeenCalled(); + expect(workflowsStore.addConnection).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 workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = mockedStore(useUIStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const nodeTypeDescription = mockNodeTypeDescription({ + name: SET_NODE_TYPE, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + }); const nodeA = createTestNode({ id: 'a', - type: 'node', + type: nodeTypeDescription.name, name: 'Node A', }); const nodeB = createTestNode({ id: 'b', - type: 'node', + type: nodeTypeDescription.name, name: 'Node B', }); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); - const connection: Connection = { source: nodeA.id, sourceHandle: `outputs/${NodeConnectionType.Main}/0`, @@ -822,18 +1017,25 @@ describe('useCanvasOperations', () => { targetHandle: `inputs/${NodeConnectionType.Main}/0`, }; - const nodeTypeDescription = mockNodeTypeDescription({ - name: 'node', - inputs: [NodeConnectionType.Main], - }); + nodeTypesStore.nodeTypes = { + node: { 1: nodeTypeDescription }, + }; - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[nodeA.name] = nodeA; - canvasOperations.editableWorkflowObject.value.nodes[nodeB.name] = nodeB; + workflowsStore.workflow.nodes = [nodeA, nodeB]; + workflowsStore.getNodeById.mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); - canvasOperations.createConnection(connection); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); - expect(addConnectionSpy).toHaveBeenCalledWith({ + const { createConnection, editableWorkflowObject } = useCanvasOperations({ router }); + + editableWorkflowObject.value.nodes[nodeA.name] = nodeA; + editableWorkflowObject.value.nodes[nodeB.name] = nodeB; + + createConnection(connection); + + expect(workflowsStore.addConnection).toHaveBeenCalledWith({ connection: [ { index: 0, node: nodeA.name, type: NodeConnectionType.Main }, { index: 0, node: nodeB.name, type: NodeConnectionType.Main }, @@ -845,29 +1047,32 @@ describe('useCanvasOperations', () => { describe('revertCreateConnection', () => { it('deletes connection if both source and target nodes exist', () => { + const workflowsStore = mockedStore(useWorkflowsStore); const connection: [IConnection, IConnection] = [ { node: 'sourceNode', type: NodeConnectionType.Main, index: 0 }, { node: 'targetNode', type: NodeConnectionType.Main, index: 0 }, ]; const testNode = createTestNode(); - const removeConnectionSpy = vi.spyOn(workflowsStore, 'removeConnection'); - vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(testNode); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(testNode); + workflowsStore.getNodeByName.mockReturnValue(testNode); + workflowsStore.getNodeById.mockReturnValue(testNode); - canvasOperations.revertCreateConnection(connection); + const { revertCreateConnection } = useCanvasOperations({ router }); + revertCreateConnection(connection); - expect(removeConnectionSpy).toHaveBeenCalled(); + expect(workflowsStore.removeConnection).toHaveBeenCalled(); }); }); describe('isConnectionAllowed', () => { it('should return false if source and target nodes are the same', () => { const node = mockNode({ id: '1', type: 'testType', name: 'Test Node' }); - expect(canvasOperations.isConnectionAllowed(node, node, NodeConnectionType.Main)).toBe(false); + const { isConnectionAllowed } = useCanvasOperations({ router }); + expect(isConnectionAllowed(node, node, NodeConnectionType.Main)).toBe(false); }); it('should return false if target node type does not have inputs', () => { + const nodeTypesStore = mockedStore(useNodeTypesStore); const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -882,14 +1087,16 @@ describe('useCanvasOperations', () => { name: 'targetType', inputs: [], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(false); + const { isConnectionAllowed } = useCanvasOperations({ router }); + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(false); }); it('should return false if target node does not exist in the workflow', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -904,14 +1111,18 @@ describe('useCanvasOperations', () => { name: 'targetType', inputs: [NodeConnectionType.Main], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(false); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + + const { isConnectionAllowed } = useCanvasOperations({ router }); + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(false); }); it('should return false if input type does not match connection type', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -929,16 +1140,21 @@ describe('useCanvasOperations', () => { inputs: [NodeConnectionType.AiTool], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; - canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(false); + const { isConnectionAllowed, editableWorkflowObject } = useCanvasOperations({ router }); + + editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; + editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(false); }); it('should return false if source node type is not allowed by target node input filter', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -965,16 +1181,22 @@ describe('useCanvasOperations', () => { ], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; - canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(false); + const { isConnectionAllowed, editableWorkflowObject } = useCanvasOperations({ router }); + + editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; + editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(false); }); it('should return true if all conditions including filter are met', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -1001,16 +1223,22 @@ describe('useCanvasOperations', () => { ], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; - canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(true); + const { isConnectionAllowed, editableWorkflowObject } = useCanvasOperations({ router }); + + editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; + editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(true); }); it('should return true if all conditions are met and no filter is set', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -1034,51 +1262,50 @@ describe('useCanvasOperations', () => { ], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; - canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(true); + const { isConnectionAllowed, editableWorkflowObject } = useCanvasOperations({ router }); + + editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; + editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(true); }); }); describe('deleteConnection', () => { it('should not delete a connection if source node does not exist', () => { - const removeConnectionSpy = vi - .spyOn(workflowsStore, 'removeConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const connection: Connection = { source: 'nonexistent', target: 'targetNode' }; - vi.spyOn(workflowsStore, 'getNodeById') + workflowsStore.getNodeById .mockReturnValueOnce(undefined) .mockReturnValueOnce(createTestNode()); - canvasOperations.deleteConnection(connection); + const { deleteConnection } = useCanvasOperations({ router }); + deleteConnection(connection); - expect(removeConnectionSpy).not.toHaveBeenCalled(); + expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); }); it('should not delete a connection if target node does not exist', () => { - const removeConnectionSpy = vi - .spyOn(workflowsStore, 'removeConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const connection: Connection = { source: 'sourceNode', target: 'nonexistent' }; - vi.spyOn(workflowsStore, 'getNodeById') + workflowsStore.getNodeById .mockReturnValueOnce(createTestNode()) .mockReturnValueOnce(undefined); - canvasOperations.deleteConnection(connection); + const { deleteConnection } = useCanvasOperations({ router }); + deleteConnection(connection); - expect(removeConnectionSpy).not.toHaveBeenCalled(); + expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); }); it('should delete a connection if source and target nodes exist', () => { - const removeConnectionSpy = vi - .spyOn(workflowsStore, 'removeConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const nodeA = createTestNode({ id: 'a', @@ -1099,11 +1326,12 @@ describe('useCanvasOperations', () => { targetHandle: `inputs/${NodeConnectionType.Main}/0`, }; - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); + workflowsStore.getNodeById.mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); - canvasOperations.deleteConnection(connection); + const { deleteConnection } = useCanvasOperations({ router }); + deleteConnection(connection); - expect(removeConnectionSpy).toHaveBeenCalledWith({ + expect(workflowsStore.removeConnection).toHaveBeenCalledWith({ connection: [ { index: 0, node: nodeA.name, type: NodeConnectionType.Main }, { index: 0, node: nodeB.name, type: NodeConnectionType.Main }, @@ -1114,81 +1342,98 @@ describe('useCanvasOperations', () => { describe('revertDeleteConnection', () => { it('should revert delete connection', () => { - const addConnectionSpy = vi - .spyOn(workflowsStore, 'addConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const connection: [IConnection, IConnection] = [ { node: 'sourceNode', type: NodeConnectionType.Main, index: 1 }, { node: 'targetNode', type: NodeConnectionType.Main, index: 2 }, ]; - canvasOperations.revertDeleteConnection(connection); + const { revertDeleteConnection } = useCanvasOperations({ router }); + revertDeleteConnection(connection); - expect(addConnectionSpy).toHaveBeenCalledWith({ connection }); + expect(workflowsStore.addConnection).toHaveBeenCalledWith({ connection }); }); }); describe('duplicateNodes', () => { it('should duplicate nodes', async () => { - nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); - const telemetrySpy = vi.spyOn(telemetry, 'track'); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); + const nodeTypeDescription = mockNodeTypeDescription({ name: SET_NODE_TYPE }); + + nodeTypesStore.nodeTypes = { + [SET_NODE_TYPE]: { 1: nodeTypeDescription }, + }; const nodes = buildImportNodes(); - workflowsStore.setNodes(nodes); + workflowsStore.workflow.nodes = nodes; + workflowsStore.getNodesByIds.mockReturnValue(nodes); + workflowsStore.outgoingConnectionsByNodeName.mockReturnValue({}); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.getWorkflow.mockReturnValue(workflowObject); + + const canvasOperations = useCanvasOperations({ router }); const duplicatedNodeIds = await canvasOperations.duplicateNodes(['1', '2']); + expect(duplicatedNodeIds.length).toBe(2); expect(duplicatedNodeIds).not.toContain('1'); expect(duplicatedNodeIds).not.toContain('2'); - expect(workflowsStore.workflow.nodes.length).toEqual(4); - expect(telemetrySpy).toHaveBeenCalledWith( - 'User duplicated nodes', - expect.objectContaining({ node_graph_string: expect.any(String), workflow_id: 'test' }), - ); }); }); describe('copyNodes', () => { it('should copy nodes', async () => { - nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); - const telemetrySpy = vi.spyOn(telemetry, 'track'); - const nodes = buildImportNodes(); - workflowsStore.setNodes(nodes); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); + const nodeTypeDescription = mockNodeTypeDescription({ name: SET_NODE_TYPE }); + + nodeTypesStore.nodeTypes = { + [SET_NODE_TYPE]: { 1: nodeTypeDescription }, + }; + + const nodes = buildImportNodes(); + workflowsStore.workflow.nodes = nodes; + workflowsStore.getNodesByIds.mockReturnValue(nodes); + workflowsStore.outgoingConnectionsByNodeName.mockReturnValue({}); + + const { copyNodes } = useCanvasOperations({ router }); + await copyNodes(['1', '2']); - await canvasOperations.copyNodes(['1', '2']); expect(useClipboard().copy).toHaveBeenCalledTimes(1); expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot(); - expect(telemetrySpy).toHaveBeenCalledWith( - 'User copied nodes', - expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }), - ); }); }); describe('cutNodes', () => { it('should copy and delete nodes', async () => { - nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); - const telemetrySpy = vi.spyOn(telemetry, 'track'); - const nodes = buildImportNodes(); - workflowsStore.setNodes(nodes); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); + const nodeTypeDescription = mockNodeTypeDescription({ name: SET_NODE_TYPE }); - await canvasOperations.cutNodes(['1', '2']); + nodeTypesStore.nodeTypes = { + [SET_NODE_TYPE]: { 1: nodeTypeDescription }, + }; + + const nodes = buildImportNodes(); + workflowsStore.workflow.nodes = nodes; + workflowsStore.getNodesByIds.mockReturnValue(nodes); + workflowsStore.outgoingConnectionsByNodeName.mockReturnValue({}); + + const { cutNodes } = useCanvasOperations({ router }); + await cutNodes(['1', '2']); expect(useClipboard().copy).toHaveBeenCalledTimes(1); expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot(); - expect(telemetrySpy).toHaveBeenCalledWith( - 'User copied nodes', - expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }), - ); - expect(workflowsStore.getNodes().length).toBe(0); }); }); }); function buildImportNodes() { return [ - mockNode({ id: '1', name: 'Node 1', type: 'type' }), - mockNode({ id: '2', name: 'Node 2', type: 'type' }), + mockNode({ id: '1', name: 'Node 1', type: SET_NODE_TYPE }), + mockNode({ id: '2', name: 'Node 2', type: SET_NODE_TYPE }), ].map((node) => { // Setting position in mockNode will wrap it in a Proxy // This causes deepCopy to remove position -> set position after instead diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts index caaed00196..c5ddbd4b90 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.ts @@ -17,7 +17,7 @@ import { useDataSchema } from '@/composables/useDataSchema'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useI18n } from '@/composables/useI18n'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; -import { usePinnedData, type PinDataSource } from '@/composables/usePinnedData'; +import { type PinDataSource, usePinnedData } from '@/composables/usePinnedData'; import { useTelemetry } from '@/composables/useTelemetry'; import { useToast } from '@/composables/useToast'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; @@ -65,6 +65,13 @@ import { parseCanvasConnectionHandleString, } from '@/utils/canvasUtilsV2'; import * as NodeViewUtils from '@/utils/nodeViewUtils'; +import { + CONFIGURABLE_NODE_SIZE, + CONFIGURATION_NODE_SIZE, + DEFAULT_NODE_SIZE, + GRID_SIZE, + PUSH_NODES_OFFSET, +} from '@/utils/nodeViewUtils'; import { isValidNodeConnectionType } from '@/utils/typeGuards'; import type { Connection } from '@vue-flow/core'; import type { @@ -358,6 +365,7 @@ export function useCanvasOperations({ router }: { router: ReturnType & { position?: INodeUi['position'] }, + nodeTypeDescription: INodeTypeDescription, + ) { + let position: XYPosition | undefined = node.position; + let pushOffsets: XYPosition = [40, 40]; + // Available when + // - clicking the plus button of a node handle + // - dragging an edge / connection of a node handle + // - selecting a node, adding a node via the node creator const lastInteractedWithNode = uiStore.lastInteractedWithNode; + // Available when clicking the plus button of a node edge / connection const lastInteractedWithNodeConnection = uiStore.lastInteractedWithNodeConnection; + // Available when dragging an edge / connection from a node + const lastInteractedWithNodeHandle = uiStore.lastInteractedWithNodeHandle; + + const { type: connectionType, index: connectionIndex } = parseCanvasConnectionHandleString( + lastInteractedWithNodeHandle ?? lastInteractedWithNodeConnection?.sourceHandle ?? '', + ); + + const nodeSize = + connectionType === NodeConnectionType.Main ? DEFAULT_NODE_SIZE : CONFIGURATION_NODE_SIZE; + if (lastInteractedWithNode) { - const lastSelectedNodeTypeDescription = nodeTypesStore.getNodeType( + const lastInteractedWithNodeTypeDescription = nodeTypesStore.getNodeType( lastInteractedWithNode.type, lastInteractedWithNode.typeVersion, ); - const lastInteractedWithNodeObject = editableWorkflowObject.value.getNode( - lastInteractedWithNode.name, - ); - if (lastInteractedWithNodeConnection) { - shiftDownstreamNodesPosition(lastInteractedWithNode.name, NodeViewUtils.PUSH_NODES_OFFSET, { - trackHistory: true, - }); - } - - // This position is set in `onMouseUp` when pulling connections - const newNodeInsertPosition = canvasStore.newNodeInsertPosition; + const newNodeInsertPosition = uiStore.lastCancelledConnectionPosition; if (newNodeInsertPosition) { - canvasStore.newNodeInsertPosition = null; - return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, [ - newNodeInsertPosition[0] + NodeViewUtils.GRID_SIZE, - newNodeInsertPosition[1] - NodeViewUtils.NODE_SIZE / 2, - ]); - } else { - let yOffset = 0; + // When pulling / cancelling a connection. + // The new node should be placed at the same position as the mouse up event, + // designated by the `newNodeInsertPosition` value. - // Compute the y offset for the new node based on the number of main outputs of the source node - if (uiStore.lastInteractedWithNodeConnection) { - const sourceNodeType = nodeTypesStore.getNodeType( - lastInteractedWithNode.type, - lastInteractedWithNode.typeVersion, + const xOffset = connectionType === NodeConnectionType.Main ? 0 : -nodeSize[0] / 2; + const yOffset = connectionType === NodeConnectionType.Main ? -nodeSize[1] / 2 : 0; + + position = [newNodeInsertPosition[0] + xOffset, newNodeInsertPosition[1] + yOffset]; + + uiStore.lastCancelledConnectionPosition = null; + } else if (lastInteractedWithNodeTypeDescription) { + // When + // - clicking the plus button of a node handle + // - clicking the plus button of a node edge / connection + // - selecting a node, adding a node via the node creator + + let yOffset = 0; + if (lastInteractedWithNodeConnection) { + // When clicking the plus button of a node edge / connection + // Compute the y offset for the new node based on the number of main outputs of the source node + // and shift the downstream nodes accordingly + + shiftDownstreamNodesPosition(lastInteractedWithNode.name, PUSH_NODES_OFFSET, { + trackHistory: true, + }); + + const yOffsetValuesByOutputCount = [ + [-nodeSize[1], nodeSize[1]], + [-nodeSize[1] - 2 * GRID_SIZE, 0, nodeSize[1] - 2 * GRID_SIZE], + [ + -2 * nodeSize[1] - 2 * GRID_SIZE, + -nodeSize[1], + nodeSize[1], + 2 * nodeSize[1] - 2 * GRID_SIZE, + ], + ]; + + const lastInteractedWithNodeOutputs = NodeHelpers.getNodeOutputs( + editableWorkflowObject.value, + lastInteractedWithNode, + lastInteractedWithNodeTypeDescription, + ); + const lastInteractedWithNodeOutputTypes = NodeHelpers.getConnectionTypes( + lastInteractedWithNodeOutputs, + ); + const lastInteractedWithNodeMainOutputs = lastInteractedWithNodeOutputTypes.filter( + (output) => output === NodeConnectionType.Main, ); - if (sourceNodeType) { - const offsets = [ - [-100, 100], - [-140, 0, 140], - [-240, -100, 100, 240], - ]; - - const sourceNodeOutputs = NodeHelpers.getNodeOutputs( - editableWorkflowObject.value, - lastInteractedWithNode, - sourceNodeType, - ); - const sourceNodeOutputTypes = NodeHelpers.getConnectionTypes(sourceNodeOutputs); - const sourceNodeOutputMainOutputs = sourceNodeOutputTypes.filter( - (output) => output === NodeConnectionType.Main, - ); - - if (sourceNodeOutputMainOutputs.length > 1) { - const { index: sourceOutputIndex } = parseCanvasConnectionHandleString( - uiStore.lastInteractedWithNodeConnection.sourceHandle, - ); - const offset = offsets[sourceNodeOutputMainOutputs.length - 2]; - yOffset = offset[sourceOutputIndex]; - } + if (lastInteractedWithNodeMainOutputs.length > 1) { + const yOffsetValues = + yOffsetValuesByOutputCount[lastInteractedWithNodeMainOutputs.length - 2]; + yOffset = yOffsetValues[connectionIndex]; } } @@ -913,80 +940,96 @@ export function useCanvasOperations({ router }: { router: ReturnType 0 && + outputTypes.every((outputName) => outputName !== NodeConnectionType.Main) && + lastInteractedWithNodeObject + ) { + // When the added node has only non-main outputs (configuration nodes) + // We want to place the new node directly below the last interacted with node. + + const lastInteractedWithNodeInputs = NodeHelpers.getNodeInputs( + editableWorkflowObject.value, + lastInteractedWithNodeObject, + lastInteractedWithNodeTypeDescription, + ); + const lastInteractedWithNodeInputTypes = NodeHelpers.getConnectionTypes( + lastInteractedWithNodeInputs, + ); + const lastInteractedWithNodeScopedInputTypes = ( + lastInteractedWithNodeInputTypes || [] + ).filter((input) => input !== NodeConnectionType.Main); + const scopedConnectionIndex = lastInteractedWithNodeScopedInputTypes.findIndex( + (inputType) => outputs[0] === inputType, + ); + + const lastInteractedWithNodeWidthDivisions = Math.max( + lastInteractedWithNodeScopedInputTypes.length + 1, + 1, + ); + + position = [ + lastInteractedWithNode.position[0] + + (CONFIGURABLE_NODE_SIZE[0] / lastInteractedWithNodeWidthDivisions) * + (scopedConnectionIndex + 1) - + nodeSize[0] / 2, + lastInteractedWithNode.position[1] + PUSH_NODES_OFFSET, + ]; + } else { + // When the node has only main outputs, mixed outputs, or no outputs at all + // We want to place the new node directly to the right of the last interacted with node. + + const lastInteractedWithNodeInputs = NodeHelpers.getNodeInputs( + editableWorkflowObject.value, + lastInteractedWithNode, + lastInteractedWithNodeTypeDescription, + ); + const lastInteractedWithNodeInputTypes = NodeHelpers.getConnectionTypes( + lastInteractedWithNodeInputs, + ); + + let pushOffset = PUSH_NODES_OFFSET; if ( - lastInteractedWithNodeObject && - outputTypes.length > 0 && - outputTypes.every((outputName) => outputName !== NodeConnectionType.Main) + !!lastInteractedWithNodeInputTypes.find((input) => input !== NodeConnectionType.Main) ) { - const lastSelectedInputs = NodeHelpers.getNodeInputs( - editableWorkflowObject.value, - lastInteractedWithNodeObject, - lastSelectedNodeTypeDescription, - ); - const lastSelectedInputTypes = NodeHelpers.getConnectionTypes(lastSelectedInputs); - - const scopedConnectionIndex = (lastSelectedInputTypes || []) - .filter((input) => input !== NodeConnectionType.Main) - .findIndex((inputType) => outputs[0] === inputType); - - return NodeViewUtils.getNewNodePosition( - workflowsStore.allNodes, - [ - lastInteractedWithNode.position[0] + - (NodeViewUtils.NODE_SIZE / - (Math.max(lastSelectedNodeTypeDescription?.inputs?.length ?? 1), 1)) * - scopedConnectionIndex, - lastInteractedWithNode.position[1] + NodeViewUtils.PUSH_NODES_OFFSET, - ], - [100, 0], - ); - } else { - // Has only main outputs or no outputs at all - const inputs = NodeHelpers.getNodeInputs( - editableWorkflowObject.value, - lastInteractedWithNode, - lastSelectedNodeTypeDescription, - ); - const inputsTypes = NodeHelpers.getConnectionTypes(inputs); - - let pushOffset = NodeViewUtils.PUSH_NODES_OFFSET; - if (!!inputsTypes.find((input) => input !== NodeConnectionType.Main)) { - // If the node has scoped inputs, push it down a bit more - pushOffset += 150; - } - - // If a node is active then add the new node directly after the current one - return NodeViewUtils.getNewNodePosition( - workflowsStore.allNodes, - [ - lastInteractedWithNode.position[0] + pushOffset, - lastInteractedWithNode.position[1] + yOffset, - ], - [100, 0], - ); + // If the node has scoped inputs, push it down a bit more + pushOffset += 140; } + + // If a node is active then add the new node directly after the current one + position = [ + lastInteractedWithNode.position[0] + pushOffset, + lastInteractedWithNode.position[1] + yOffset, + ]; } } } - // If added node is a trigger and it's the first one added to the canvas - // we place it at canvasAddButtonPosition to replace the canvas add button - const position = ( - nodeTypesStore.isTriggerNode(node.type) && triggerNodes.value.length === 0 - ? [0, 0] - : // If no node is active find a free spot - lastClickPosition.value - ) as XYPosition; + if (!position) { + if (nodeTypesStore.isTriggerNode(node.type) && triggerNodes.value.length === 0) { + // When added node is a trigger, and it's the first one added to the canvas + // we place it at root to replace the canvas add button - return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position); + position = [0, 0]; + } else { + // When no position is set, we place the node at the last clicked position + + position = lastClickPosition.value; + } + } + + return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position, pushOffsets); } function resolveNodeName(node: INodeUi) { @@ -1219,7 +1262,7 @@ export function useCanvasOperations({ router }: { router: ReturnType { // #endregion return { + state, getCredentialOwnerName, getCredentialsByType, getCredentialById, diff --git a/packages/editor-ui/src/stores/nodeCreator.store.ts b/packages/editor-ui/src/stores/nodeCreator.store.ts index 6668b78237..db6ce26eaa 100644 --- a/packages/editor-ui/src/stores/nodeCreator.store.ts +++ b/packages/editor-ui/src/stores/nodeCreator.store.ts @@ -90,7 +90,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { setTimeout(() => { if (creatorView) { - openNodeCreator({ + setNodeCreatorState({ createNodeActive: true, nodeCreatorView: creatorView, }); @@ -110,7 +110,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { }); } - function openNodeCreator({ + function setNodeCreatorState({ source, createNodeActive, nodeCreatorView, @@ -200,7 +200,6 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { uiStore.lastSelectedNode = sourceNode.name; uiStore.lastSelectedNodeEndpointUuid = connection.sourceHandle ?? null; uiStore.lastSelectedNodeOutputIndex = index; - // canvasStore.newNodeInsertPosition = null; if (isVueFlowConnection(connection)) { uiStore.lastInteractedWithNodeConnection = connection; @@ -208,7 +207,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { uiStore.lastInteractedWithNodeHandle = connection.sourceHandle ?? null; uiStore.lastInteractedWithNodeId = sourceNode.id; - openNodeCreator({ + setNodeCreatorState({ source: eventSource, createNodeActive: true, nodeCreatorView, @@ -231,7 +230,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { ndvStore.activeNodeName = null; setSelectedView(TRIGGER_NODE_CREATOR_VIEW); setShowScrim(true); - openNodeCreator({ + setNodeCreatorState({ source, createNodeActive: true, nodeCreatorView: TRIGGER_NODE_CREATOR_VIEW, @@ -276,7 +275,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { setOpenSource, setActions, setMergeNodes, - openNodeCreator, + setNodeCreatorState, openSelectiveNodeCreator, openNodeCreatorForConnectingNode, openNodeCreatorForTriggerNodes, diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index 18cb3fbe37..e854ac656c 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -200,6 +200,7 @@ export const useUIStore = defineStore(STORES.UI, () => { const lastInteractedWithNodeConnection = ref(null); const lastInteractedWithNodeHandle = ref(null); const lastInteractedWithNodeId = ref(null); + const lastCancelledConnectionPosition = ref(null); const settingsStore = useSettingsStore(); const workflowsStore = useWorkflowsStore(); @@ -624,6 +625,7 @@ export const useUIStore = defineStore(STORES.UI, () => { lastInteractedWithNodeConnection.value = null; lastInteractedWithNodeHandle.value = null; lastInteractedWithNodeId.value = null; + lastCancelledConnectionPosition.value = null; } return { @@ -652,6 +654,7 @@ export const useUIStore = defineStore(STORES.UI, () => { lastInteractedWithNodeHandle, lastInteractedWithNodeId, lastInteractedWithNode, + lastCancelledConnectionPosition, nodeViewOffsetPosition, nodeViewMoveInProgress, nodeViewInitialized, diff --git a/packages/editor-ui/src/utils/canvasUtilsV2.ts b/packages/editor-ui/src/utils/canvasUtilsV2.ts index 948c24f1e0..e73890fe9b 100644 --- a/packages/editor-ui/src/utils/canvasUtilsV2.ts +++ b/packages/editor-ui/src/utils/canvasUtilsV2.ts @@ -103,9 +103,8 @@ export function mapLegacyConnectionToCanvasConnection( export function parseCanvasConnectionHandleString(handle: string | null | undefined) { const [mode, type, index] = (handle ?? '').split('/'); - const resolvedType = isValidNodeConnectionType(type) ? type : NodeConnectionType.Main; const resolvedMode = isValidCanvasConnectionMode(mode) ? mode : CanvasConnectionMode.Output; - + const resolvedType = isValidNodeConnectionType(type) ? type : NodeConnectionType.Main; let resolvedIndex = parseInt(index, 10); if (isNaN(resolvedIndex)) { resolvedIndex = 0; diff --git a/packages/editor-ui/src/utils/nodeViewUtils.ts b/packages/editor-ui/src/utils/nodeViewUtils.ts index a0e87c96de..d28d8f1c97 100644 --- a/packages/editor-ui/src/utils/nodeViewUtils.ts +++ b/packages/editor-ui/src/utils/nodeViewUtils.ts @@ -38,6 +38,9 @@ const MIN_X_TO_SHOW_OUTPUT_LABEL = 90; const MIN_Y_TO_SHOW_OUTPUT_LABEL = 100; export const NODE_SIZE = 100; +export const DEFAULT_NODE_SIZE = [100, 100]; +export const CONFIGURATION_NODE_SIZE = [80, 80]; +export const CONFIGURABLE_NODE_SIZE = [256, 100]; export const PLACEHOLDER_TRIGGER_NODE_SIZE = 100; export const DEFAULT_START_POSITION_X = 180; export const DEFAULT_START_POSITION_Y = 240; diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index 04cd656e8d..5ce133bca9 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -34,7 +34,11 @@ import type { ToggleNodeCreatorOptions, XYPosition, } from '@/Interface'; -import type { Connection, ViewportTransform } from '@vue-flow/core'; +import type { + Connection, + ViewportTransform, + XYPosition as VueFlowXYPosition, +} from '@vue-flow/core'; import type { CanvasConnectionCreateData, CanvasEventBusEvents, @@ -738,12 +742,20 @@ function onRevertCreateConnection({ connection }: { connection: [IConnection, IC revertCreateConnection(connection); } -function onCreateConnectionCancelled(event: ConnectStartEvent, mouseEvent?: MouseEvent) { +function onCreateConnectionCancelled( + event: ConnectStartEvent, + position: VueFlowXYPosition, + mouseEvent?: MouseEvent, +) { const preventDefault = (mouseEvent?.target as HTMLElement).classList?.contains('clickable'); if (preventDefault) { return; } + uiStore.lastInteractedWithNodeId = event.nodeId; + uiStore.lastInteractedWithNodeHandle = event.handleId; + uiStore.lastCancelledConnectionPosition = [position.x, position.y]; + setTimeout(() => { nodeCreatorStore.openNodeCreatorForConnectingNode({ connection: { @@ -874,11 +886,15 @@ async function onOpenNodeCreatorForTriggerNodes(source: NodeCreatorOpenSource) { } function onOpenNodeCreatorFromCanvas(source: NodeCreatorOpenSource) { - onOpenNodeCreator({ createNodeActive: true, source }); + onToggleNodeCreator({ createNodeActive: true, source }); } -function onOpenNodeCreator(options: ToggleNodeCreatorOptions) { - nodeCreatorStore.openNodeCreator(options); +function onToggleNodeCreator(options: ToggleNodeCreatorOptions) { + nodeCreatorStore.setNodeCreatorState(options); + + if (!options.createNodeActive && !options.hasAddedNodes) { + uiStore.resetLastInteractedWith(); + } } function onCreateSticky() { @@ -1378,7 +1394,6 @@ function selectNodes(ids: string[]) { function onClickPane(position: CanvasNode['position']) { lastClickPosition.value = [position.x, position.y]; - canvasStore.newNodeInsertPosition = [position.x, position.y]; uiStore.isCreateNodeActive = false; } @@ -1563,7 +1578,7 @@ onDeactivated(() => { v-if="!isCanvasReadOnly" :create-node-active="uiStore.isCreateNodeActive" :node-view-scale="viewportTransform.zoom" - @toggle-node-creator="onOpenNodeCreator" + @toggle-node-creator="onToggleNodeCreator" @add-nodes="onAddNodesAndConnections" />