From 8d12c1ad8d9283764647836bdd50224259d506e9 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 20 Nov 2023 14:37:12 +0100 Subject: [PATCH] feat(editor): Add node context menu (#7620) ![image](https://github.com/n8n-io/n8n/assets/8850410/5a601fae-cb8e-41bb-beca-ac9ab7065b75) --- cypress/e2e/10-undo-redo.cy.ts | 23 +- cypress/e2e/12-canvas-actions.cy.ts | 27 +- cypress/e2e/12-canvas.cy.ts | 100 ++++-- cypress/e2e/13-pinning.cy.ts | 34 +- cypress/e2e/25-stickies.cy.ts | 5 + cypress/pages/ndv.ts | 1 + cypress/pages/workflow.ts | 78 +++- .../ActionDropdown.stories.ts | 59 ++++ .../N8nActionDropdown/ActionDropdown.vue | 101 ++++-- .../__snapshots__/ActionDropdown.spec.ts.snap | 4 +- .../KeyboardShortcut.stories.ts | 24 ++ .../N8nKeyboardShortcut.vue | 62 ++++ .../components/N8nKeyboardShortcut/index.ts | 1 + .../design-system/src/components/index.ts | 1 + .../design-system/src/composables/index.ts | 1 + .../src/composables/useDeviceSupport.ts | 2 +- .../design-system/src/css/_tokens.dark.scss | 7 + packages/design-system/src/css/_tokens.scss | 6 + .../src/css/common/transition.scss | 4 + .../design-system/src/css/common/var.scss | 18 +- packages/design-system/src/css/dropdown.scss | 9 +- packages/design-system/src/css/popper.scss | 8 +- packages/design-system/src/main.ts | 1 + packages/design-system/src/plugin.ts | 2 + packages/design-system/src/types/index.ts | 1 + .../src/types/keyboardshortcut.ts | 6 + packages/editor-ui/src/Interface.ts | 1 + .../src/components/CanvasControls.vue | 83 +++-- .../components/ContextMenu/ContextMenu.vue | 63 ++++ .../components/KeyboardShortcutTooltip.vue | 36 ++ packages/editor-ui/src/components/Node.vue | 140 ++++---- .../src/components/Node/NodeCreation.vue | 34 +- .../src/components/NodeDetailsView.vue | 2 +- .../editor-ui/src/components/SaveButton.vue | 24 +- packages/editor-ui/src/components/Sticky.vue | 12 +- .../__snapshots__/useContextMenu.test.ts.snap | 332 ++++++++++++++++++ .../__tests__/useContextMenu.test.ts | 92 +++++ packages/editor-ui/src/composables/index.ts | 2 +- .../src/composables/useCanvasMouseSelect.ts | 9 +- .../src/composables/useContextMenu.ts | 242 +++++++++++++ .../src/composables/useHistoryHelper.ts | 4 +- packages/editor-ui/src/constants.ts | 3 +- packages/editor-ui/src/mixins/pinData.ts | 6 +- .../src/plugins/i18n/locales/en.json | 28 +- packages/editor-ui/src/stores/ui.store.ts | 5 +- packages/editor-ui/src/views/NodeView.vue | 280 +++++++++------ 46 files changed, 1611 insertions(+), 372 deletions(-) create mode 100644 packages/design-system/src/components/N8nKeyboardShortcut/KeyboardShortcut.stories.ts create mode 100644 packages/design-system/src/components/N8nKeyboardShortcut/N8nKeyboardShortcut.vue create mode 100644 packages/design-system/src/components/N8nKeyboardShortcut/index.ts rename packages/{editor-ui => design-system}/src/composables/useDeviceSupport.ts (93%) create mode 100644 packages/design-system/src/types/keyboardshortcut.ts create mode 100644 packages/editor-ui/src/components/ContextMenu/ContextMenu.vue create mode 100644 packages/editor-ui/src/components/KeyboardShortcutTooltip.vue create mode 100644 packages/editor-ui/src/composables/__tests__/__snapshots__/useContextMenu.test.ts.snap create mode 100644 packages/editor-ui/src/composables/__tests__/useContextMenu.test.ts create mode 100644 packages/editor-ui/src/composables/useContextMenu.ts diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index 35a43c5c84b47..6d3337456d0c8 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -65,13 +65,10 @@ describe('Undo/Redo', () => { .should('have.css', 'top', '220px'); }); - it('should undo/redo deleting node using delete button', () => { + it('should undo/redo deleting node using context menu', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters - .canvasNodeByName(CODE_NODE_NAME) - .find('[data-test-id=delete-node-button]') - .click({ force: true }); + WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME); WorkflowPage.getters.canvasNodes().should('have.have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 0); WorkflowPage.actions.hitUndo(); @@ -151,7 +148,7 @@ describe('Undo/Redo', () => { .should('have.css', 'top', '320px'); }); - it('should undo/redo deleting a connection by pressing delete button', () => { + it('should undo/redo deleting a connection using context menu', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.getters.nodeConnections().realHover(); @@ -177,14 +174,10 @@ describe('Undo/Redo', () => { WorkflowPage.getters.nodeConnections().should('have.length', 0); }); - it('should undo/redo disabling a node using disable button', () => { + it('should undo/redo disabling a node using context menu', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters - .canvasNodes() - .last() - .find('[data-test-id="disable-node-button"]') - .click({ force: true }); + WorkflowPage.actions.disableNode(CODE_NODE_NAME); WorkflowPage.getters.disabledNodes().should('have.length', 1); WorkflowPage.actions.hitUndo(); WorkflowPage.getters.disabledNodes().should('have.length', 0); @@ -252,11 +245,7 @@ describe('Undo/Redo', () => { it('should undo/redo duplicating a node', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters - .canvasNodes() - .last() - .find('[data-test-id="duplicate-node-button"]') - .click({ force: true }); + WorkflowPage.actions.duplicateNode(CODE_NODE_NAME); WorkflowPage.actions.hitUndo(); WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.actions.hitRedo(); diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index c34a9189702a8..91f6b65884138 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -134,7 +134,7 @@ describe('Canvas Actions', () => { .canvasNodes() .last() .should('have.css', 'left', '860px') - .should('have.css', 'top', '220px') + .should('have.css', 'top', '220px'); }); it('should delete connections by pressing the delete button', () => { @@ -163,21 +163,29 @@ describe('Canvas Actions', () => { .find('[data-test-id="execute-node-button"]') .click({ force: true }); WorkflowPage.getters.successToast().should('contain', 'Node executed successfully'); + WorkflowPage.actions.executeNode(CODE_NODE_NAME); + WorkflowPage.getters.successToast().should('contain', 'Node executed successfully'); }); it('should copy selected nodes', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.selectAll(); + WorkflowPage.actions.hitCopy(); WorkflowPage.getters.successToast().should('contain', 'Copied!'); + + WorkflowPage.actions.copyNode(CODE_NODE_NAME); + WorkflowPage.getters.successToast().should('contain', 'Copied!'); }); - it('should select all nodes', () => { + it('should select/deselect all nodes', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.selectAll(); WorkflowPage.getters.selectedNodes().should('have.length', 2); + WorkflowPage.actions.deselectAll(); + WorkflowPage.getters.selectedNodes().should('have.length', 0); }); it('should select nodes using arrow keys', () => { @@ -205,22 +213,21 @@ describe('Canvas Actions', () => { WorkflowPage.getters .canvasNodes() .last() - .findChildByTestId('disable-node-button').as('disableNodeButton'); - cy.drag('@disableNodeButton', [200, 200]); + .findChildByTestId('execute-node-button') + .as('executeNodeButton'); + cy.drag('@executeNodeButton', [200, 200]); WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]); }); it('should not break lasso selection with multiple clicks on node action buttons', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]); - WorkflowPage.getters - .canvasNodes() - .last().as('lastNode'); - cy.get('@lastNode').findChildByTestId('disable-node-button').as('disableNodeButton'); + WorkflowPage.getters.canvasNodes().last().as('lastNode'); + cy.get('@lastNode').findChildByTestId('execute-node-button').as('executeNodeButton'); for (let i = 0; i < 20; i++) { cy.get('@lastNode').realHover(); - cy.get('@disableNodeButton').should('be.visible'); - cy.get('@disableNodeButton').realTouch(); + cy.get('@executeNodeButton').should('be.visible'); + cy.get('@executeNodeButton').realTouch(); cy.getByTestId('execute-workflow-button').realHover(); WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]); } diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index c7bea8e8c1a49..359f0cf85140c 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -22,6 +22,7 @@ const ZOOM_OUT_X2_FACTOR = 0.64; const PINCH_ZOOM_IN_FACTOR = 1.05702; const PINCH_ZOOM_OUT_FACTOR = 0.946058; const RENAME_NODE_NAME = 'Something else'; +const RENAME_NODE_NAME2 = 'Something different'; describe('Canvas Node Manipulation and Navigation', () => { beforeEach(() => { @@ -129,13 +130,10 @@ describe('Canvas Node Manipulation and Navigation', () => { cy.get('.jtk-connector').should('have.length', 4); }); - it('should delete node using node action button', () => { + it('should delete node using context menu', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters - .canvasNodeByName(CODE_NODE_NAME) - .find('[data-test-id=delete-node-button]') - .click({ force: true }); + WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME); WorkflowPage.getters.canvasNodes().should('have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 0); }); @@ -162,13 +160,38 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.getters.nodeConnections().should('have.length', 1); }); - it('should delete multiple nodes', () => { + it('should delete multiple nodes (context menu or shortcut)', () => { + WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + cy.wait(500); + WorkflowPage.actions.selectAll(); + cy.get('body').type('{backspace}'); + WorkflowPage.getters.canvasNodes().should('have.length', 0); + + WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + cy.wait(500); + WorkflowPage.actions.selectAllFromContextMenu(); + WorkflowPage.actions.openContextMenu(); + WorkflowPage.actions.contextMenuAction('delete'); + WorkflowPage.getters.canvasNodes().should('have.length', 0); + }); + + it('should delete multiple nodes (context menu or shortcut)', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); cy.wait(500); WorkflowPage.actions.selectAll(); cy.get('body').type('{backspace}'); WorkflowPage.getters.canvasNodes().should('have.length', 0); + + WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + cy.wait(500); + WorkflowPage.actions.selectAllFromContextMenu(); + WorkflowPage.actions.openContextMenu(); + WorkflowPage.actions.contextMenuAction('delete'); + WorkflowPage.getters.canvasNodes().should('have.length', 0); }); it('should move node', () => { @@ -272,39 +295,42 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.getters.canvasNodes().last().should('be.visible'); }); - it('should disable node by pressing the disable button', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters - .canvasNodes() - .last() - .find('[data-test-id="disable-node-button"]') - .click({ force: true }); - WorkflowPage.getters.disabledNodes().should('have.length', 1); - }); - - it('should disable node using keyboard shortcut', () => { + it('should disable node (context menu or shortcut)', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.getters.canvasNodes().last().click(); WorkflowPage.actions.hitDisableNodeShortcut(); WorkflowPage.getters.disabledNodes().should('have.length', 1); + + WorkflowPage.actions.disableNode(CODE_NODE_NAME); + WorkflowPage.getters.disabledNodes().should('have.length', 0); }); - it('should disable multiple nodes', () => { + it('should disable multiple nodes (context menu or shortcut)', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); cy.get('body').type('{esc}'); cy.get('body').type('{esc}'); WorkflowPage.actions.selectAll(); + + // Keyboard shortcut WorkflowPage.actions.hitDisableNodeShortcut(); WorkflowPage.getters.disabledNodes().should('have.length', 2); + WorkflowPage.actions.hitDisableNodeShortcut(); + WorkflowPage.getters.disabledNodes().should('have.length', 0); + + // Context menu + WorkflowPage.actions.openContextMenu(); + WorkflowPage.actions.contextMenuAction('toggle_activation'); + WorkflowPage.getters.disabledNodes().should('have.length', 2); + WorkflowPage.actions.openContextMenu(); + WorkflowPage.actions.contextMenuAction('toggle_activation'); + WorkflowPage.getters.disabledNodes().should('have.length', 0); }); - it('should rename node using keyboard shortcut', () => { + it('should rename node (context menu or shortcut)', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.getters.canvasNodes().last().click(); @@ -313,19 +339,25 @@ describe('Canvas Node Manipulation and Navigation', () => { cy.get('body').type(RENAME_NODE_NAME); cy.get('body').type('{enter}'); WorkflowPage.getters.canvasNodeByName(RENAME_NODE_NAME).should('exist'); + + WorkflowPage.actions.renameNode(RENAME_NODE_NAME); + cy.get('.rename-prompt').should('be.visible'); + cy.get('body').type(RENAME_NODE_NAME2); + cy.get('body').type('{enter}'); + WorkflowPage.getters.canvasNodeByName(RENAME_NODE_NAME2).should('exist'); }); - it('should duplicate node', () => { + it('should duplicate nodes (context menu or shortcut)', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters - .canvasNodes() - .last() - .find('[data-test-id="duplicate-node-button"]') - .click({ force: true }); + WorkflowPage.actions.duplicateNode(CODE_NODE_NAME); WorkflowPage.getters.canvasNodes().should('have.length', 3); WorkflowPage.getters.nodeConnections().should('have.length', 1); + + WorkflowPage.actions.selectAll(); + WorkflowPage.actions.hitDuplicateNodeShortcut(); + WorkflowPage.getters.canvasNodes().should('have.length', 5); }); // ADO-1240: Connections would get deleted after activating and deactivating NodeView @@ -365,7 +397,7 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.getters.canvasNodes().should('have.have.length', 2); - WorkflowPage.actions.openNode('n8n'); + WorkflowPage.actions.openNodeFromContextMenu('n8n'); cy.get('[class*=hasIssues]').should('have.length', 1); NDVDialog.actions.close(); }); @@ -392,15 +424,9 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.actions.executeWorkflow(); cy.contains('Unrecognized node type').should('be.visible'); - WorkflowPage.getters - .canvasNodeByName(`${unknownNodeName} 1`) - .find('[data-test-id=delete-node-button]') - .click({ force: true }); - - WorkflowPage.getters - .canvasNodeByName(`${unknownNodeName} 2`) - .find('[data-test-id=delete-node-button]') - .click({ force: true }); + WorkflowPage.actions.deselectAll(); + WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 1`); + WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 2`); WorkflowPage.actions.executeWorkflow(); diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts index fd6ce5b1805ab..605f5f081b49d 100644 --- a/cypress/e2e/13-pinning.cy.ts +++ b/cypress/e2e/13-pinning.cy.ts @@ -70,7 +70,7 @@ describe('Data pinning', () => { it('Should be duplicating pin data when duplicating node', () => { workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger'); - workflowPage.actions.addNodeToCanvas('Edit Fields', true, true); + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true); ndv.getters.container().should('be.visible'); ndv.getters.pinDataButton().should('not.exist'); ndv.getters.editPinnedDataButton().should('be.visible'); @@ -78,7 +78,7 @@ describe('Data pinning', () => { ndv.actions.setPinnedData([{ test: 1 }]); ndv.actions.close(); - workflowPage.actions.duplicateNode(workflowPage.getters.canvasNodes().last()); + workflowPage.actions.duplicateNode(EDIT_FIELDS_SET_NODE_NAME); workflowPage.actions.saveWorkflowOnButtonClick(); @@ -88,9 +88,37 @@ describe('Data pinning', () => { ndv.getters.outputTbodyCell(1, 0).should('include.text', 1); }); + it('Should be able to pin data from canvas (context menu or shortcut)', () => { + workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger'); + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + workflowPage.actions.openContextMenu(EDIT_FIELDS_SET_NODE_NAME, 'overflow-button'); + workflowPage.getters + .contextMenuAction('toggle_pin') + .parent() + .should('have.class', 'is-disabled'); + + // Unpin using context menu + workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME); + ndv.actions.setPinnedData([{ test: 1 }]); + ndv.actions.close(); + workflowPage.actions.pinNode(EDIT_FIELDS_SET_NODE_NAME); + workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME); + ndv.getters.nodeOutputHint().should('exist'); + ndv.actions.close(); + + // Unpin using shortcut + workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME); + ndv.actions.setPinnedData([{ test: 1 }]); + ndv.actions.close(); + workflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click(); + workflowPage.actions.hitPinNodeShortcut(); + workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME); + ndv.getters.nodeOutputHint().should('exist'); + }); + it('Should show an error when maximum pin data size is exceeded', () => { workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger'); - workflowPage.actions.addNodeToCanvas('Edit Fields', true, true); + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true); ndv.getters.container().should('be.visible'); ndv.getters.pinDataButton().should('not.exist'); ndv.getters.editPinnedDataButton().should('be.visible'); diff --git a/cypress/e2e/25-stickies.cy.ts b/cypress/e2e/25-stickies.cy.ts index 2b3ec401c3aa9..0bf52d5581e7f 100644 --- a/cypress/e2e/25-stickies.cy.ts +++ b/cypress/e2e/25-stickies.cy.ts @@ -31,6 +31,11 @@ describe('Canvas Actions', () => { workflowPage.getters.addStickyButton().should('not.be.visible'); addDefaultSticky(); + workflowPage.actions.deselectAll(); + workflowPage.actions.addStickyFromContextMenu(); + workflowPage.actions.hitAddStickyShortcut(); + + workflowPage.getters.stickies().should('have.length', 3); workflowPage.getters .stickies() .eq(0) diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index b0744465d32b4..1c950be08c3e0 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -24,6 +24,7 @@ export class NDV extends BasePage { editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'), pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller'), runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'), + nodeOutputHint: () => cy.getByTestId('ndv-output-run-node-hint'), savePinnedDataButton: () => this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'), outputTableRows: () => this.getters.outputDataContainer().find('table tr'), diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 47d9406bf9b60..309915d398d98 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -127,6 +127,7 @@ export class WorkflowPage extends BasePage { editorTabButton: () => cy.getByTestId('radio-button-workflow'), workflowHistoryButton: () => cy.getByTestId('workflow-history-button'), colors: () => cy.getByTestId('color'), + contextMenuAction: (action: string) => cy.getByTestId(`context-menu-item-${action}`), }; actions = { visit: (preventNodeViewUnload = true) => { @@ -185,11 +186,70 @@ export class WorkflowPage extends BasePage { if (!preventNdvClose) cy.get('body').type('{esc}'); }, + openContextMenu: ( + nodeTypeName?: string, + method: 'right-click' | 'overflow-button' = 'right-click', + ) => { + const target = nodeTypeName + ? this.getters.canvasNodeByName(nodeTypeName) + : this.getters.nodeViewBackground(); + + if (method === 'right-click') { + target.rightclick(nodeTypeName ? 'center' : 'topLeft', { force: true }); + } else { + target.realHover(); + target.find('[data-test-id="overflow-node-button"]').click({ force: true }); + } + }, openNode: (nodeTypeName: string) => { this.getters.canvasNodeByName(nodeTypeName).first().dblclick(); }, - duplicateNode: (node: Chainable>) => { - node.find('[data-test-id="duplicate-node-button"]').click({ force: true }); + duplicateNode: (nodeTypeName: string) => { + this.actions.openContextMenu(nodeTypeName); + this.actions.contextMenuAction('duplicate'); + }, + deleteNodeFromContextMenu: (nodeTypeName: string) => { + this.actions.openContextMenu(nodeTypeName); + this.actions.contextMenuAction('delete'); + }, + executeNode: (nodeTypeName: string) => { + this.actions.openContextMenu(nodeTypeName); + this.actions.contextMenuAction('execute'); + }, + addStickyFromContextMenu: () => { + this.actions.openContextMenu(); + this.actions.contextMenuAction('add_sticky'); + }, + renameNode: (nodeTypeName: string) => { + this.actions.openContextMenu(nodeTypeName); + this.actions.contextMenuAction('rename'); + }, + copyNode: (nodeTypeName: string) => { + this.actions.openContextMenu(nodeTypeName); + this.actions.contextMenuAction('copy'); + }, + contextMenuAction: (action: string) => { + this.getters.contextMenuAction(action).click(); + }, + disableNode: (nodeTypeName: string) => { + this.actions.openContextMenu(nodeTypeName); + this.actions.contextMenuAction('toggle_activation'); + }, + pinNode: (nodeTypeName: string) => { + this.actions.openContextMenu(nodeTypeName); + this.actions.contextMenuAction('toggle_pin'); + }, + openNodeFromContextMenu: (nodeTypeName: string) => { + this.actions.openContextMenu(nodeTypeName, 'overflow-button'); + this.actions.contextMenuAction('open'); + }, + selectAllFromContextMenu: () => { + this.actions.openContextMenu(); + this.actions.contextMenuAction('select_all'); + }, + deselectAll: () => { + this.actions.openContextMenu(); + this.actions.contextMenuAction('deselect_all'); }, openExpressionEditorModal: () => { cy.contains('Expression').invoke('show').click(); @@ -284,7 +344,7 @@ export class WorkflowPage extends BasePage { cy.get('body').type(META_KEY, { delay: 500, release: false }).type('a'); }, hitDisableNodeShortcut: () => { - cy.get('body').type(META_KEY, { delay: 500, release: false }).type('d'); + cy.get('body').type('d'); }, hitCopy: () => { cy.get('body').type(META_KEY, { delay: 500, release: false }).type('c'); @@ -292,6 +352,18 @@ export class WorkflowPage extends BasePage { hitPaste: () => { cy.get('body').type(META_KEY, { delay: 500, release: false }).type('P'); }, + hitPinNodeShortcut: () => { + cy.get('body').type('p'); + }, + hitExecuteWorkflowShortcut: () => { + cy.get('body').type(META_KEY, { delay: 500, release: false }).type('{enter}'); + }, + hitDuplicateNodeShortcut: () => { + cy.get('body').type(META_KEY, { delay: 500, release: false }).type('d'); + }, + hitAddStickyShortcut: () => { + cy.get('body').type('{shift}', { delay: 500, release: false }).type('S'); + }, executeWorkflow: () => { this.getters.executeWorkflowButton().click(); }, diff --git a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.stories.ts b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.stories.ts index 8e229231df646..30148d3e13106 100644 --- a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.stories.ts +++ b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.stories.ts @@ -71,3 +71,62 @@ customStyling.args = { }, ], }; + +export const keyboardShortcuts = template.bind({}); +keyboardShortcuts.args = { + items: [ + { + id: 'open', + label: 'Open node...', + shortcut: { keys: ['↵'] }, + }, + { + id: 'execute', + label: 'Execute node', + }, + { + id: 'rename', + label: 'Rename node', + shortcut: { keys: ['F2'] }, + }, + { + id: 'toggle_activation', + label: 'Deactivate node', + shortcut: { keys: ['D'] }, + }, + { + id: 'toggle_pin', + label: 'Pin node', + shortcut: { keys: ['p'] }, + disabled: true, + }, + { + id: 'copy', + label: 'Copy node', + shortcut: { metaKey: true, keys: ['C'] }, + }, + { + id: 'duplicate', + label: 'Duplicate node', + shortcut: { metaKey: true, keys: ['D'] }, + }, + { + id: 'select_all', + divided: true, + // always plural + label: 'Select all nodes', + shortcut: { metaKey: true, keys: ['A'] }, + }, + { + id: 'deselect_all', + label: 'Clear selection', + disabled: true, + }, + { + id: 'delete', + divided: true, + label: 'Delete node', + shortcut: { keys: ['Del'] }, + }, + ], +}; diff --git a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index a9c9612a7eb32..77a212c20ca13 100644 --- a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -4,11 +4,21 @@ :placement="placement" :trigger="trigger" @command="onSelect" + :popper-class="{ [$style.shadow]: true, [$style.hideArrow]: hideArrow }" + @visible-change="onVisibleChange" ref="elementDropdown" > -
- -
+ + + + + + + diff --git a/packages/editor-ui/src/components/KeyboardShortcutTooltip.vue b/packages/editor-ui/src/components/KeyboardShortcutTooltip.vue new file mode 100644 index 0000000000000..66beca4d160f5 --- /dev/null +++ b/packages/editor-ui/src/components/KeyboardShortcutTooltip.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 0878e6dec48bb..c1b59b7059a0c 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -6,6 +6,7 @@ data-test-id="canvas-node" :ref="data.name" :data-name="data.name" + @contextmenu="(e: MouseEvent) => openContextMenu(e, 'node-right-click')" >
- + @@ -70,6 +72,7 @@
@@ -102,48 +105,22 @@
-
- -
-
- -
-
- -
-
- -
-
- -
+ type="tertiary" + text + icon="play" + :disabled="workflowRunning || isConfigNode" + :title="$locale.baseText('node.executeNode')" + @click="executeNode" + /> +
+.context-menu { + position: absolute; +} + .node-wrapper { --node-width: 100px; /* @@ -792,13 +769,11 @@ export default defineComponent({ } &.touch-active, - &:hover { - .node-execute { - display: initial; - } - + &:hover, + &.menu-open { .node-options { - display: initial; + pointer-events: all; + opacity: 1; } } @@ -860,19 +835,27 @@ export default defineComponent({ } .node-options { - display: none; + --node-options-height: 26px; + :deep(.button) { + --button-font-color: var(--color-text-light); + } position: absolute; - top: -25px; - left: -10px; - width: calc(var(--node-width) + 20px); - height: 26px; - font-size: 0.9em; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-2xs); + transition: opacity 100ms ease-in; + opacity: 0; + pointer-events: none; + top: calc(-1 * (var(--node-options-height) + var(--spacing-4xs))); + left: 0; + width: var(--node-width); + height: var(--node-options-height); + font-size: var(--font-size-s); z-index: 10; - color: #aaa; text-align: center; .option { - width: 28px; display: inline-block; &.touch { @@ -885,8 +868,7 @@ export default defineComponent({ .execute-icon { position: relative; - top: 2px; - font-size: 1.2em; + font-size: var(----font-size-xl); } } diff --git a/packages/editor-ui/src/components/Node/NodeCreation.vue b/packages/editor-ui/src/components/Node/NodeCreation.vue index 04dea988f7fd3..889c1886367c1 100644 --- a/packages/editor-ui/src/components/Node/NodeCreation.vue +++ b/packages/editor-ui/src/components/Node/NodeCreation.vue @@ -10,6 +10,7 @@ import { import { useUIStore } from '@/stores/ui.store'; import type { AddedNodesAndConnections, ToggleNodeCreatorOptions } from '@/Interface'; import { useActions } from './NodeCreator/composables/useActions'; +import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue'; type Props = { nodeViewScale: number; @@ -105,24 +106,31 @@ function nodeTypeSelected(nodeTypes: string[]) { @mouseenter="onCreateMenuHoverIn" >
- + + +
- + + +
diff --git a/packages/editor-ui/src/components/NodeDetailsView.vue b/packages/editor-ui/src/components/NodeDetailsView.vue index 565cdb3d50f74..d84cf1ce122d9 100644 --- a/packages/editor-ui/src/components/NodeDetailsView.vue +++ b/packages/editor-ui/src/components/NodeDetailsView.vue @@ -170,7 +170,7 @@ import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useUIStore } from '@/stores/ui.store'; import { useSettingsStore } from '@/stores/settings.store'; -import useDeviceSupport from '@/composables/useDeviceSupport'; +import { useDeviceSupport } from 'n8n-design-system'; import { useMessage } from '@/composables'; export default defineComponent({ diff --git a/packages/editor-ui/src/components/SaveButton.vue b/packages/editor-ui/src/components/SaveButton.vue index 0a9d5f932745c..0eef3358af56f 100644 --- a/packages/editor-ui/src/components/SaveButton.vue +++ b/packages/editor-ui/src/components/SaveButton.vue @@ -1,22 +1,32 @@ diff --git a/packages/editor-ui/src/composables/__tests__/__snapshots__/useContextMenu.test.ts.snap b/packages/editor-ui/src/composables/__tests__/__snapshots__/useContextMenu.test.ts.snap new file mode 100644 index 0000000000000..9fe52e6c6c102 --- /dev/null +++ b/packages/editor-ui/src/composables/__tests__/__snapshots__/useContextMenu.test.ts.snap @@ -0,0 +1,332 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`useContextMenu > should return the correct actions opening the menu from the button 1`] = ` +[ + { + "id": "open", + "label": "Open node...", + "shortcut": { + "keys": [ + "↵", + ], + }, + }, + { + "id": "execute", + "label": "Execute node", + }, + { + "disabled": true, + "id": "rename", + "label": "Rename node", + "shortcut": { + "keys": [ + "F2", + ], + }, + }, + { + "disabled": true, + "id": "toggle_activation", + "label": "Deactivate node", + "shortcut": { + "keys": [ + "D", + ], + }, + }, + { + "disabled": true, + "id": "toggle_pin", + "label": "Pin node", + "shortcut": { + "keys": [ + "p", + ], + }, + }, + { + "id": "copy", + "label": "Copy node", + "shortcut": { + "keys": [ + "C", + ], + "metaKey": true, + }, + }, + { + "disabled": true, + "id": "duplicate", + "label": "Duplicate node", + "shortcut": { + "keys": [ + "D", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "divided": true, + "id": "select_all", + "label": "Select all", + "shortcut": { + "keys": [ + "A", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "id": "deselect_all", + "label": "Clear selection", + }, + { + "disabled": true, + "divided": true, + "id": "delete", + "label": "Delete node", + "shortcut": { + "keys": [ + "Del", + ], + }, + }, +] +`; + +exports[`useContextMenu > should return the correct actions when right clicking a Node 1`] = ` +[ + { + "id": "open", + "label": "Open node...", + "shortcut": { + "keys": [ + "↵", + ], + }, + }, + { + "id": "execute", + "label": "Execute node", + }, + { + "disabled": true, + "id": "rename", + "label": "Rename node", + "shortcut": { + "keys": [ + "F2", + ], + }, + }, + { + "disabled": true, + "id": "toggle_activation", + "label": "Deactivate node", + "shortcut": { + "keys": [ + "D", + ], + }, + }, + { + "disabled": true, + "id": "toggle_pin", + "label": "Pin node", + "shortcut": { + "keys": [ + "p", + ], + }, + }, + { + "id": "copy", + "label": "Copy node", + "shortcut": { + "keys": [ + "C", + ], + "metaKey": true, + }, + }, + { + "disabled": true, + "id": "duplicate", + "label": "Duplicate node", + "shortcut": { + "keys": [ + "D", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "divided": true, + "id": "select_all", + "label": "Select all", + "shortcut": { + "keys": [ + "A", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "id": "deselect_all", + "label": "Clear selection", + }, + { + "disabled": true, + "divided": true, + "id": "delete", + "label": "Delete node", + "shortcut": { + "keys": [ + "Del", + ], + }, + }, +] +`; + +exports[`useContextMenu > should return the correct actions when right clicking a sticky 1`] = ` +[ + { + "id": "open", + "label": "Edit sticky note", + "shortcut": { + "keys": [ + "↵", + ], + }, + }, + { + "id": "copy", + "label": "Copy sticky note", + "shortcut": { + "keys": [ + "C", + ], + "metaKey": true, + }, + }, + { + "disabled": true, + "id": "duplicate", + "label": "Duplicate sticky note", + "shortcut": { + "keys": [ + "D", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "divided": true, + "id": "select_all", + "label": "Select all", + "shortcut": { + "keys": [ + "A", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "id": "deselect_all", + "label": "Clear selection", + }, + { + "disabled": true, + "divided": true, + "id": "delete", + "label": "Delete sticky note", + "shortcut": { + "keys": [ + "Del", + ], + }, + }, +] +`; + +exports[`useContextMenu > should support opening and closing (default = right click on canvas) 1`] = ` +[ + { + "disabled": true, + "id": "toggle_activation", + "label": "Deactivate 2 nodes", + "shortcut": { + "keys": [ + "D", + ], + }, + }, + { + "disabled": true, + "id": "toggle_pin", + "label": "Pin 2 nodes", + "shortcut": { + "keys": [ + "p", + ], + }, + }, + { + "id": "copy", + "label": "Copy 2 nodes", + "shortcut": { + "keys": [ + "C", + ], + "metaKey": true, + }, + }, + { + "disabled": true, + "id": "duplicate", + "label": "Duplicate 2 nodes", + "shortcut": { + "keys": [ + "D", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "divided": true, + "id": "select_all", + "label": "Select all", + "shortcut": { + "keys": [ + "A", + ], + "metaKey": true, + }, + }, + { + "disabled": false, + "id": "deselect_all", + "label": "Clear selection", + }, + { + "disabled": true, + "divided": true, + "id": "delete", + "label": "Delete 2 nodes", + "shortcut": { + "keys": [ + "Del", + ], + }, + }, +] +`; diff --git a/packages/editor-ui/src/composables/__tests__/useContextMenu.test.ts b/packages/editor-ui/src/composables/__tests__/useContextMenu.test.ts new file mode 100644 index 0000000000000..2ae2e225e8d4e --- /dev/null +++ b/packages/editor-ui/src/composables/__tests__/useContextMenu.test.ts @@ -0,0 +1,92 @@ +import type { INodeUi } from '@/Interface'; +import { useContextMenu } from '@/composables/useContextMenu'; +import { NO_OP_NODE_TYPE, STICKY_NODE_TYPE, STORES } from '@/constants'; +import { faker } from '@faker-js/faker'; +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; + +const nodeFactory = (data: Partial = {}): INodeUi => ({ + id: faker.string.uuid(), + name: faker.word.words(3), + parameters: {}, + position: [faker.number.int(), faker.number.int()], + type: NO_OP_NODE_TYPE, + typeVersion: 1, + ...data, +}); + +describe('useContextMenu', () => { + const nodes = [nodeFactory(), nodeFactory(), nodeFactory()]; + const selectedNodes = nodes.slice(0, 2); + + beforeAll(() => { + setActivePinia( + createTestingPinia({ + initialState: { + [STORES.UI]: { selectedNodes }, + [STORES.WORKFLOWS]: { workflow: { nodes } }, + }, + }), + ); + }); + + afterEach(() => { + useContextMenu().close(); + vi.clearAllMocks(); + }); + + const mockEvent = new MouseEvent('contextmenu', { clientX: 500, clientY: 300 }); + + it('should support opening and closing (default = right click on canvas)', () => { + const { open, close, isOpen, actions, position, target, targetNodes } = useContextMenu(); + expect(isOpen.value).toBe(false); + expect(actions.value).toEqual([]); + expect(position.value).toEqual([0, 0]); + expect(targetNodes.value).toEqual([]); + + open(mockEvent); + expect(isOpen.value).toBe(true); + expect(useContextMenu().isOpen.value).toEqual(true); + expect(actions.value).toMatchSnapshot(); + expect(position.value).toEqual([500, 300]); + expect(target.value).toEqual({ source: 'canvas' }); + expect(targetNodes.value).toEqual(selectedNodes); + + close(); + expect(isOpen.value).toBe(false); + expect(useContextMenu().isOpen.value).toEqual(false); + expect(actions.value).toEqual([]); + expect(position.value).toEqual([0, 0]); + expect(targetNodes.value).toEqual([]); + }); + + it('should return the correct actions when right clicking a sticky', () => { + const { open, isOpen, actions, targetNodes } = useContextMenu(); + const sticky = nodeFactory({ type: STICKY_NODE_TYPE }); + open(mockEvent, { source: 'node-right-click', node: sticky }); + + expect(isOpen.value).toBe(true); + expect(actions.value).toMatchSnapshot(); + expect(targetNodes.value).toEqual([sticky]); + }); + + it('should return the correct actions when right clicking a Node', () => { + const { open, isOpen, actions, targetNodes } = useContextMenu(); + const node = nodeFactory(); + open(mockEvent, { source: 'node-right-click', node }); + + expect(isOpen.value).toBe(true); + expect(actions.value).toMatchSnapshot(); + expect(targetNodes.value).toEqual([node]); + }); + + it('should return the correct actions opening the menu from the button', () => { + const { open, isOpen, actions, targetNodes } = useContextMenu(); + const node = nodeFactory(); + open(mockEvent, { source: 'node-button', node }); + + expect(isOpen.value).toBe(true); + expect(actions.value).toMatchSnapshot(); + expect(targetNodes.value).toEqual([node]); + }); +}); diff --git a/packages/editor-ui/src/composables/index.ts b/packages/editor-ui/src/composables/index.ts index 2561f7936c81c..748875a7c3b4c 100644 --- a/packages/editor-ui/src/composables/index.ts +++ b/packages/editor-ui/src/composables/index.ts @@ -1,7 +1,6 @@ export { default as useCanvasMouseSelect } from './useCanvasMouseSelect'; export * from './useCopyToClipboard'; export * from './useDebounce'; -export { default as useDeviceSupport } from './useDeviceSupport'; export * from './useExternalHooks'; export * from './useExternalSecretsProvider'; export { default as useGlobalLinkActions } from './useGlobalLinkActions'; @@ -15,3 +14,4 @@ export * from './useToast'; export * from './useNodeSpecificationValues'; export * from './useDataSchema'; export * from './useExecutionDebugging'; +export * from './useContextMenu'; diff --git a/packages/editor-ui/src/composables/useCanvasMouseSelect.ts b/packages/editor-ui/src/composables/useCanvasMouseSelect.ts index fdcfb21c9b98b..a04342db2319b 100644 --- a/packages/editor-ui/src/composables/useCanvasMouseSelect.ts +++ b/packages/editor-ui/src/composables/useCanvasMouseSelect.ts @@ -1,11 +1,12 @@ import type { INodeUi, XYPosition } from '@/Interface'; -import useDeviceSupport from './useDeviceSupport'; +import { useDeviceSupport } from 'n8n-design-system'; import { useUIStore } from '@/stores/ui.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils'; import { ref, onMounted, computed } from 'vue'; import { useCanvasStore } from '@/stores/canvas.store'; +import { useContextMenu } from './useContextMenu'; interface ExtendedHTMLSpanElement extends HTMLSpanElement { x: number; @@ -20,6 +21,7 @@ export default function useCanvasMouseSelect() { const uiStore = useUIStore(); const canvasStore = useCanvasStore(); const workflowsStore = useWorkflowsStore(); + const { isOpen: isContextMenuOpen } = useContextMenu(); function _setSelectBoxStyle(styles: Record) { Object.assign(selectBox.value.style, styles); @@ -127,6 +129,9 @@ export default function useCanvasMouseSelect() { } function mouseUpMouseSelect(e: MouseEvent) { + // Ignore right-click + if (e.button === 2 || isContextMenuOpen.value) return; + if (!selectActive.value) { if (isTouchDevice && e.target instanceof HTMLElement) { if (e.target && e.target.id.includes('node-view')) { @@ -156,7 +161,7 @@ export default function useCanvasMouseSelect() { _hideSelectBox(); } function mouseDownMouseSelect(e: MouseEvent, moveButtonPressed: boolean) { - if (isCtrlKeyPressed(e) || moveButtonPressed) { + if (isCtrlKeyPressed(e) || moveButtonPressed || e.button === 2) { // We only care about it when the ctrl key is not pressed at the same time. // So we exit when it is pressed. return; diff --git a/packages/editor-ui/src/composables/useContextMenu.ts b/packages/editor-ui/src/composables/useContextMenu.ts new file mode 100644 index 0000000000000..f4ffdd8e00748 --- /dev/null +++ b/packages/editor-ui/src/composables/useContextMenu.ts @@ -0,0 +1,242 @@ +import type { XYPosition } from '@/Interface'; +import { + NOT_DUPLICATABE_NODE_TYPES, + PIN_DATA_NODE_TYPES_DENYLIST, + STICKY_NODE_TYPE, +} from '@/constants'; +import { useNodeTypesStore, useSourceControlStore, useUIStore, useWorkflowsStore } from '@/stores'; +import type { IActionDropdownItem } from 'n8n-design-system/src/components/N8nActionDropdown/ActionDropdown.vue'; +import type { INode, INodeTypeDescription } from 'n8n-workflow'; +import { computed, ref, watch } from 'vue'; +import { getMousePosition } from '../utils/nodeViewUtils'; +import { useI18n } from './useI18n'; +import { useDataSchema } from './useDataSchema'; + +export type ContextMenuTarget = + | { source: 'canvas' } + | { source: 'node-right-click'; node: INode } + | { source: 'node-button'; node: INode }; +export type ContextMenuAction = + | 'open' + | 'copy' + | 'toggle_activation' + | 'duplicate' + | 'execute' + | 'rename' + | 'toggle_pin' + | 'delete' + | 'select_all' + | 'deselect_all' + | 'add_node' + | 'add_sticky'; + +const position = ref([0, 0]); +const isOpen = ref(false); +const target = ref({ source: 'canvas' }); +const actions = ref([]); + +export const useContextMenu = () => { + const uiStore = useUIStore(); + const nodeTypesStore = useNodeTypesStore(); + const workflowsStore = useWorkflowsStore(); + const sourceControlStore = useSourceControlStore(); + const { getInputDataWithPinned } = useDataSchema(); + const i18n = useI18n(); + + const isReadOnly = computed( + () => sourceControlStore.preferences.branchReadOnly || uiStore.isReadOnlyView, + ); + + const targetNodes = computed(() => { + if (!isOpen.value) return []; + const selectedNodes = uiStore.selectedNodes.map((node) => + workflowsStore.getNodeByName(node.name), + ) as INode[]; + const currentTarget = target.value; + if (currentTarget.source === 'canvas') { + return selectedNodes; + } else if (currentTarget.source === 'node-right-click') { + const isNodeInSelection = selectedNodes.some((node) => node.name === currentTarget.node.name); + return isNodeInSelection ? selectedNodes : [currentTarget.node]; + } + + return [currentTarget.node]; + }); + + const canAddNodeOfType = (nodeType: INodeTypeDescription) => { + const sameTypeNodes = workflowsStore.allNodes.filter((n) => n.type === nodeType.name); + return nodeType.maxNodes === undefined || sameTypeNodes.length < nodeType.maxNodes; + }; + + const canDuplicateNode = (node: INode): boolean => { + const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion); + if (!nodeType) return false; + if (NOT_DUPLICATABE_NODE_TYPES.includes(nodeType.name)) return false; + + return canAddNodeOfType(nodeType); + }; + + const canPinNode = (node: INode): boolean => { + const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion); + const dataToPin = getInputDataWithPinned(node); + if (!nodeType || dataToPin.length === 0) return false; + return nodeType.outputs.length === 1 && !PIN_DATA_NODE_TYPES_DENYLIST.includes(node.type); + }; + + const hasPinData = (node: INode): boolean => { + return !!workflowsStore.pinDataByNodeName(node.name); + }; + const close = () => { + target.value = { source: 'canvas' }; + isOpen.value = false; + actions.value = []; + position.value = [0, 0]; + }; + + const open = (event: MouseEvent, menuTarget: ContextMenuTarget = { source: 'canvas' }) => { + event.stopPropagation(); + + if (isOpen.value && menuTarget.source === target.value.source) { + // Close context menu, let browser open native context menu + close(); + return; + } + + event.preventDefault(); + + target.value = menuTarget; + position.value = getMousePosition(event); + isOpen.value = true; + + const nodes = targetNodes.value; + const onlyStickies = nodes.every((node) => node.type === STICKY_NODE_TYPE); + const i18nOptions = { + adjustToNumber: nodes.length, + interpolate: { + subject: onlyStickies + ? i18n.baseText('contextMenu.sticky', { adjustToNumber: nodes.length }) + : i18n.baseText('contextMenu.node', { adjustToNumber: nodes.length }), + }, + }; + + const selectionActions = [ + { + id: 'select_all', + divided: true, + label: i18n.baseText('contextMenu.selectAll'), + shortcut: { metaKey: true, keys: ['A'] }, + disabled: nodes.length === workflowsStore.allNodes.length, + }, + { + id: 'deselect_all', + label: i18n.baseText('contextMenu.deselectAll'), + disabled: nodes.length === 0, + }, + ]; + + if (nodes.length === 0) { + actions.value = [ + { + id: 'add_node', + shortcut: { keys: ['Tab'] }, + label: i18n.baseText('contextMenu.addNode'), + disabled: isReadOnly.value, + }, + { + id: 'add_sticky', + shortcut: { shiftKey: true, keys: ['s'] }, + label: i18n.baseText('contextMenu.addSticky'), + disabled: isReadOnly.value, + }, + ...selectionActions, + ]; + } else { + const menuActions: IActionDropdownItem[] = [ + !onlyStickies && { + id: 'toggle_activation', + label: nodes.every((node) => node.disabled) + ? i18n.baseText('contextMenu.activate', i18nOptions) + : i18n.baseText('contextMenu.deactivate', i18nOptions), + shortcut: { keys: ['D'] }, + disabled: isReadOnly.value, + }, + !onlyStickies && { + id: 'toggle_pin', + label: nodes.every((node) => hasPinData(node)) + ? i18n.baseText('contextMenu.unpin', i18nOptions) + : i18n.baseText('contextMenu.pin', i18nOptions), + shortcut: { keys: ['p'] }, + disabled: isReadOnly.value || !nodes.every(canPinNode), + }, + { + id: 'copy', + label: i18n.baseText('contextMenu.copy', i18nOptions), + shortcut: { metaKey: true, keys: ['C'] }, + }, + { + id: 'duplicate', + label: i18n.baseText('contextMenu.duplicate', i18nOptions), + shortcut: { metaKey: true, keys: ['D'] }, + disabled: isReadOnly.value || !nodes.every(canDuplicateNode), + }, + ...selectionActions, + { + id: 'delete', + divided: true, + label: i18n.baseText('contextMenu.delete', i18nOptions), + shortcut: { keys: ['Del'] }, + disabled: isReadOnly.value, + }, + ].filter(Boolean) as IActionDropdownItem[]; + + if (nodes.length === 1) { + const singleNodeActions = onlyStickies + ? [ + { + id: 'open', + label: i18n.baseText('contextMenu.editSticky'), + shortcut: { keys: ['↵'] }, + }, + ] + : [ + { + id: 'open', + label: i18n.baseText('contextMenu.open'), + shortcut: { keys: ['↵'] }, + }, + { + id: 'execute', + label: i18n.baseText('contextMenu.execute'), + }, + { + id: 'rename', + label: i18n.baseText('contextMenu.rename'), + shortcut: { keys: ['F2'] }, + disabled: isReadOnly.value, + }, + ]; + // Add actions only available for a single node + menuActions.unshift(...singleNodeActions); + } + + actions.value = menuActions; + } + }; + + watch( + () => uiStore.nodeViewOffsetPosition, + () => { + close(); + }, + ); + + return { + isOpen, + position, + target, + actions, + targetNodes, + open, + close, + }; +}; diff --git a/packages/editor-ui/src/composables/useHistoryHelper.ts b/packages/editor-ui/src/composables/useHistoryHelper.ts index e680ca91adaf8..f349bddfd6d8d 100644 --- a/packages/editor-ui/src/composables/useHistoryHelper.ts +++ b/packages/editor-ui/src/composables/useHistoryHelper.ts @@ -7,7 +7,7 @@ import { useUIStore } from '@/stores/ui.store'; import { ref, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue'; import { useDebounceHelper } from './useDebounce'; -import useDeviceSupportHelpers from './useDeviceSupport'; +import { useDeviceSupport } from 'n8n-design-system'; import { getNodeViewTab } from '@/utils'; import type { Route } from 'vue-router'; @@ -22,7 +22,7 @@ export function useHistoryHelper(activeRoute: Route) { const uiStore = useUIStore(); const { callDebounced } = useDebounceHelper(); - const { isCtrlKeyPressed } = useDeviceSupportHelpers(); + const { isCtrlKeyPressed } = useDeviceSupport(); const isNDVOpen = ref(ndvStore.activeNodeName !== null); diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 76c7433fbc1e4..b12caf387979c 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -177,7 +177,7 @@ export const NON_ACTIVATABLE_TRIGGER_NODE_TYPES = [ export const NODES_USING_CODE_NODE_EDITOR = [CODE_NODE_TYPE, AI_CODE_NODE_TYPE]; -export const PIN_DATA_NODE_TYPES_DENYLIST = [SPLIT_IN_BATCHES_NODE_TYPE]; +export const PIN_DATA_NODE_TYPES_DENYLIST = [SPLIT_IN_BATCHES_NODE_TYPE, STICKY_NODE_TYPE]; export const OPEN_URL_PANEL_TRIGGER_NODE_TYPES = [WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE]; @@ -194,6 +194,7 @@ export const NODE_CREATOR_OPEN_SOURCES: Record< TAB: 'tab', NODE_CONNECTION_ACTION: 'node_connection_action', NODE_CONNECTION_DROP: 'node_connection_drop', + CONTEXT_MENU: 'context_menu', '': '', }; export const CORE_NODES_CATEGORY = 'Core Nodes'; diff --git a/packages/editor-ui/src/mixins/pinData.ts b/packages/editor-ui/src/mixins/pinData.ts index 19c4a7efc31e7..fa491ff9da9d0 100644 --- a/packages/editor-ui/src/mixins/pinData.ts +++ b/packages/editor-ui/src/mixins/pinData.ts @@ -19,9 +19,11 @@ export type PinDataSource = | 'save-edit' | 'on-ndv-close-modal' | 'duplicate-node' - | 'add-nodes'; + | 'add-nodes' + | 'context-menu' + | 'keyboard-shortcut'; -export type UnpinDataSource = 'unpin-and-execute-modal'; +export type UnpinDataSource = 'unpin-and-execute-modal' | 'context-menu' | 'keyboard-shortcut'; export const pinData = defineComponent({ setup() { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index dafe27f38d905..102627a3244a3 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -820,12 +820,10 @@ "noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows", "node.thisIsATriggerNode": "This is a Trigger node. Learn more", "node.activateDeactivateNode": "Activate/Deactivate Node", - "node.deleteNode": "Delete Node", "node.changeColor": "Change Color", "node.disabled": "Disabled", - "node.duplicateNode": "Duplicate Node", - "node.editNode": "Edit Node", - "node.executeNode": "Execute Node", + "node.executeNode": "Execute node", + "node.deleteNode": "Delete node", "node.issues": "Issues", "node.nodeIsExecuting": "Node is executing", "node.nodeIsWaitingTill": "Node is waiting until {date} {time}", @@ -997,10 +995,11 @@ "nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)", "nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)", "nodeSettings.hasForeignCredential": "To edit this node, either:
a) Ask {owner} to share the credential with you, or
b) Duplicate the node and add your own credential", - "nodeView.addNode": "Add node", + "nodeView.openNodesPanel": "Open nodes panel", "nodeView.addATriggerNodeFirst": "Add a Trigger Node first", "nodeView.addOrEnableTriggerNode": "Add or enable a Trigger node to execute the workflow", "nodeView.addSticky": "Click to add sticky note", + "nodeView.addStickyHint": "Add sticky note", "nodeView.cantExecuteNoTrigger": "Cannot execute workflow", "nodeView.canvasAddButton.addATriggerNodeBeforeExecuting": "Add a Trigger Node before executing the workflow", "nodeView.canvasAddButton.addFirstStep": "Add first step…", @@ -1014,7 +1013,6 @@ "nodeView.confirmMessage.debug.message": "Loading this execution will unpin the data currently pinned in these nodes", "nodeView.couldntImportWorkflow": "Could not import workflow", "nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data", - "nodeView.executesTheWorkflowFromATriggerNode": "Runs the workflow, starting from a Trigger node", "nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.", "nodeView.loadingTemplate": "Loading template", "nodeView.moreInfo": "More info", @@ -1069,6 +1067,23 @@ "nodeView.zoomOut": "Zoom Out", "nodeView.zoomToFit": "Zoom to Fit", "nodeView.replaceMe": "Replace Me", + "contextMenu.node": "node | nodes", + "contextMenu.sticky": "sticky note | sticky notes", + "contextMenu.selectAll": "Select all", + "contextMenu.deselectAll": "Clear selection", + "contextMenu.duplicate": "Duplicate {subject} | Duplicate {count} {subject}", + "contextMenu.open": "Open node...", + "contextMenu.execute": "Execute node", + "contextMenu.rename": "Rename node", + "contextMenu.copy": "Copy {subject} | Copy {count} {subject}", + "contextMenu.deactivate": "Deactivate {subject} | Deactivate {count} {subject}", + "contextMenu.activate": "Activate node | Activate {count} nodes", + "contextMenu.pin": "Pin node | Pin {count} nodes", + "contextMenu.unpin": "Unpin node | Unpin {count} nodes", + "contextMenu.delete": "Delete {subject} | Delete {count} {subject}", + "contextMenu.addNode": "Add node", + "contextMenu.addSticky": "Add sticky note", + "contextMenu.editSticky": "Edit sticky note", "nodeWebhooks.clickToCopyWebhookUrls": "Click to copy webhook URLs", "nodeWebhooks.clickToCopyWebhookUrls.formTrigger": "Click to copy Form URL", "nodeWebhooks.clickToDisplayWebhookUrls": "Click to display webhook URLs", @@ -1317,6 +1332,7 @@ "runData.aiContentBlock.tokens.completion": "Completion:", "saveButton.save": "@:_reusableBaseText.save", "saveButton.saved": "Saved", + "saveButton.hint": "Save workflow", "saveButton.saving": "Saving", "settings": "Settings", "settings.communityNodes": "Community nodes", diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index 9c6abeaf84a40..90f54a27f0ad6 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -548,7 +548,10 @@ export const useUIStore = defineStore(STORES.UI, { } }, addSelectedNode(node: INodeUi): void { - this.selectedNodes.push(node); + const isAlreadySelected = this.selectedNodes.some((n) => n.name === node.name); + if (!isAlreadySelected) { + this.selectedNodes.push(node); + } }, removeNodeFromSelection(node: INodeUi): void { let index; diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 269a6a108c17c..bd0e8b3f5675f 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -17,6 +17,7 @@ @mousedown="mouseDown" v-touch:tap="touchTap" @mouseup="mouseUp" + @contextmenu="contextMenu.open" @wheel="canvasStore.wheelScroll" >
+ + +
- + :shortcut="{ metaKey: true, keys: ['↵'] }" + > + + node && this.workflowsStore.getNodeByName(node.name)) + .filter((node) => !!node) as INode[]; + + if (e.key === 'd' && !this.isCtrlKeyPressed(e)) { + void this.callDebounced('toggleActivationNodes', { debounceTime: 350 }, selectedNodes); + } else if (e.key === 'd' && this.isCtrlKeyPressed(e)) { + if (selectedNodes.length > 0) { + e.preventDefault(); + void this.duplicateNodes(selectedNodes); + } + } else if (e.key === 'p' && !this.isCtrlKeyPressed(e)) { + if (selectedNodes.length > 0) { + e.preventDefault(); + this.togglePinNodes(selectedNodes, 'keyboard-shortcut'); + } } else if (e.key === 'Delete' || e.key === 'Backspace') { e.stopPropagation(); e.preventDefault(); - void this.callDebounced('deleteSelectedNodes', { debounceTime: 500 }); + void this.callDebounced('deleteNodes', { debounceTime: 500 }, selectedNodes); } else if (e.key === 'Tab') { this.onToggleNodeCreator({ source: NODE_CREATOR_OPEN_SOURCES.TAB, createNodeActive: !this.createNodeActive && !this.isReadOnlyRoute && !this.readOnlyEnv, }); + } else if ( + e.key === 'Enter' && + this.isCtrlKeyPressed(e) && + !this.isReadOnlyRoute && + !this.readOnlyEnv + ) { + void this.onRunWorkflow(); + } else if (e.key === 'S' && e.shiftKey && !this.isReadOnlyRoute && !this.readOnlyEnv) { + void this.onAddNodes({ nodes: [{ type: STICKY_NODE_TYPE }], connections: [] }); } else if (e.key === this.controlKeyCode) { this.ctrlKeyPressed = true; } else if (e.key === ' ') { @@ -1159,13 +1203,13 @@ export default defineComponent({ void this.callDebounced('selectAllNodes', { debounceTime: 1000 }); } else if (e.key === 'c' && this.isCtrlKeyPressed(e)) { - void this.callDebounced('copySelectedNodes', { debounceTime: 1000 }); + void this.callDebounced('copyNodes', { debounceTime: 1000 }, selectedNodes); } else if (e.key === 'x' && this.isCtrlKeyPressed(e)) { // Cut nodes e.stopPropagation(); e.preventDefault(); - void this.callDebounced('cutSelectedNodes', { debounceTime: 1000 }); + void this.callDebounced('cutNodes', { debounceTime: 1000 }, selectedNodes); } else if (e.key === 'n' && this.isCtrlKeyPressed(e) && e.altKey) { // Create a new workflow e.stopPropagation(); @@ -1333,23 +1377,46 @@ export default defineComponent({ } }, - deactivateSelectedNode() { + toggleActivationNodes(nodes: INode[]) { if (!this.editAllowedCheck()) { return; } - this.disableNodes(this.uiStore.getSelectedNodes, true); + + this.disableNodes(nodes, true); + }, + + togglePinNodes(nodes: INode[], source: PinDataSource) { + if (!this.editAllowedCheck()) { + return; + } + + this.historyStore.startRecordingUndo(); + + const nextStatePinned = nodes.some( + (node) => !this.workflowsStore.pinDataByNodeName(node.name), + ); + + for (const node of nodes) { + if (nextStatePinned) { + const dataToPin = this.dataSchema.getInputDataWithPinned(node); + if (dataToPin.length !== 0) { + this.setPinData(node, dataToPin, source); + } + } else { + this.unsetPinData(node, source); + } + } + + this.historyStore.stopRecordingUndo(); }, - deleteSelectedNodes() { + deleteNodes(nodes: INode[]) { // Copy "selectedNodes" as the nodes get deleted out of selection // when they get deleted and if we would use original it would mess // with the index and would so not delete all nodes - const nodesToDelete: string[] = this.uiStore.getSelectedNodes.map((node: INodeUi) => { - return node.name; - }); this.historyStore.startRecordingUndo(); - nodesToDelete.forEach((nodeName: string) => { - this.removeNode(nodeName, true, false); + nodes.forEach((node) => { + this.removeNode(node.name, true, false); }); setTimeout(() => { this.historyStore.stopRecordingUndo(); @@ -1437,16 +1504,16 @@ export default defineComponent({ } }, - cutSelectedNodes() { + cutNodes(nodes: INode[]) { const deleteCopiedNodes = !this.isReadOnlyRoute && !this.readOnlyEnv; - this.copySelectedNodes(deleteCopiedNodes); + this.copyNodes(nodes, deleteCopiedNodes); if (deleteCopiedNodes) { - this.deleteSelectedNodes(); + this.deleteNodes(nodes); } }, - copySelectedNodes(isCut: boolean) { - void this.getSelectedNodesToSave().then((data) => { + copyNodes(nodes: INode[], isCut = false) { + void this.getNodesToSave(nodes).then((data) => { const workflowToCopy: IWorkflowToShare = { meta: { instanceId: this.rootStore.instanceId, @@ -1708,6 +1775,11 @@ export default defineComponent({ workflow_id: this.workflowsStore.workflowId, node_graph_string: nodeGraph, }); + } else if (source === 'duplicate') { + this.$telemetry.track('User duplicated nodes', { + workflow_id: this.workflowsStore.workflowId, + node_graph_string: nodeGraph, + }); } else { this.$telemetry.track('User imported workflow', { source, @@ -3211,84 +3283,13 @@ export default defineComponent({ this.workflowsStore.removeConnection({ connection: connectionInfo }); } }, - async duplicateNode(nodeName: string) { + async duplicateNodes(nodes: INode[]): Promise { if (!this.editAllowedCheck()) { return; } - const node = this.workflowsStore.getNodeByName(nodeName); - - if (node) { - const nodeTypeData = this.nodeTypesStore.getNodeType(node.type, node.typeVersion); - - if ( - nodeTypeData?.maxNodes !== undefined && - this.getNodeTypeCount(node.type) >= nodeTypeData.maxNodes - ) { - this.showMaxNodeTypeError(nodeTypeData); - return; - } - - // Deep copy the data so that data on lower levels of the node-properties do - // not share objects - const newNodeData = deepCopy(this.getNodeDataToSave(node)); - newNodeData.id = uuid(); - - const localizedName = this.locale.localizeNodeName(newNodeData.name, newNodeData.type); - - newNodeData.name = this.uniqueNodeName(localizedName); - - newNodeData.position = NodeViewUtils.getNewNodePosition( - this.nodes, - [node.position[0], node.position[1] + 140], - [0, 140], - ); - - if (newNodeData.webhookId) { - // Make sure that the node gets a new unique webhook-ID - newNodeData.webhookId = uuid(); - } - - if ( - newNodeData.credentials && - this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) - ) { - const usedCredentials = this.workflowsStore.usedCredentials; - newNodeData.credentials = Object.fromEntries( - Object.entries(newNodeData.credentials).filter(([_, credential]) => { - return ( - credential.id && - (!usedCredentials[credential.id] || - usedCredentials[credential.id]?.currentUserHasAccess) - ); - }), - ); - } - - await this.addNodes([newNodeData], [], true); - - const pinDataForNode = this.workflowsStore.pinDataByNodeName(nodeName); - if (pinDataForNode?.length) { - try { - this.setPinData(newNodeData, pinDataForNode, 'duplicate-node'); - } catch (error) { - console.error(error); - } - } - this.uiStore.stateIsDirty = true; - - // Automatically deselect all nodes and select the current one and also active - // current node - this.deselectAllNodes(); - setTimeout(() => { - this.nodeSelectedByName(newNodeData.name, false); - }); - - this.$telemetry.track('User duplicated node', { - node_type: node.type, - workflow_id: this.workflowsStore.workflowId, - }); - } + const workflowData = deepCopy(await this.getNodesToSave(nodes)); + await this.importWorkflowData(workflowData, 'duplicate', false); }, getJSPlumbConnection( sourceNodeName: string, @@ -4036,21 +4037,43 @@ export default defineComponent({ connections: tempWorkflow.connectionsBySourceNode, }; }, - async getSelectedNodesToSave(): Promise { + async getNodesToSave(nodes: INode[]): Promise { const data: IWorkflowData = { nodes: [], connections: {}, + pinData: {}, }; // Get data of all the selected noes let nodeData; const exportNodeNames: string[] = []; - for (const node of this.uiStore.getSelectedNodes) { + for (const node of nodes) { nodeData = this.getNodeDataToSave(node); exportNodeNames.push(node.name); data.nodes.push(nodeData); + + const pinDataForNode = this.workflowsStore.pinDataByNodeName(node.name); + if (pinDataForNode) { + data.pinData![node.name] = pinDataForNode; + } + + if ( + nodeData.credentials && + this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) + ) { + const usedCredentials = this.workflowsStore.usedCredentials; + nodeData.credentials = Object.fromEntries( + Object.entries(nodeData.credentials).filter(([_, credential]) => { + return ( + credential.id && + (!usedCredentials[credential.id] || + usedCredentials[credential.id]?.currentUserHasAccess) + ); + }), + ); + } } // Get only connections of exported nodes and ignore all other ones @@ -4418,6 +4441,49 @@ export default defineComponent({ } } }, + onContextMenuAction(action: ContextMenuAction, nodes: INode[]): void { + switch (action) { + case 'copy': + this.copyNodes(nodes); + break; + case 'delete': + this.deleteNodes(nodes); + break; + case 'duplicate': + void this.duplicateNodes(nodes); + break; + case 'execute': + this.onRunNode(nodes[0].name, 'NodeView.onContextMenuAction'); + break; + case 'open': + this.ndvStore.activeNodeName = nodes[0].name; + break; + case 'rename': + void this.renameNodePrompt(nodes[0].name); + break; + case 'toggle_activation': + this.toggleActivationNodes(nodes); + break; + case 'toggle_pin': + this.togglePinNodes(nodes, 'context-menu'); + break; + case 'add_node': + this.onToggleNodeCreator({ + source: NODE_CREATOR_OPEN_SOURCES.CONTEXT_MENU, + createNodeActive: !this.isReadOnlyRoute && !this.readOnlyEnv, + }); + break; + case 'add_sticky': + void this.onAddNodes({ nodes: [{ type: STICKY_NODE_TYPE }], connections: [] }); + break; + case 'select_all': + this.selectAllNodes(); + break; + case 'deselect_all': + this.deselectAllNodes(); + break; + } + }, }, async onSourceControlPull() { let workflowId = null as string | null;