mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-02 07:01:30 -08:00
feat(editor): Add node context menu (#7620)

This commit is contained in:
parent
4dbae0e2e9
commit
8d12c1ad8d
|
@ -65,13 +65,10 @@ describe('Undo/Redo', () => {
|
||||||
.should('have.css', 'top', '220px');
|
.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(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters
|
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME);
|
||||||
.canvasNodeByName(CODE_NODE_NAME)
|
|
||||||
.find('[data-test-id=delete-node-button]')
|
|
||||||
.click({ force: true });
|
|
||||||
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
|
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||||
WorkflowPage.actions.hitUndo();
|
WorkflowPage.actions.hitUndo();
|
||||||
|
@ -151,7 +148,7 @@ describe('Undo/Redo', () => {
|
||||||
.should('have.css', 'top', '320px');
|
.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(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters.nodeConnections().realHover();
|
WorkflowPage.getters.nodeConnections().realHover();
|
||||||
|
@ -177,14 +174,10 @@ describe('Undo/Redo', () => {
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
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(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters
|
WorkflowPage.actions.disableNode(CODE_NODE_NAME);
|
||||||
.canvasNodes()
|
|
||||||
.last()
|
|
||||||
.find('[data-test-id="disable-node-button"]')
|
|
||||||
.click({ force: true });
|
|
||||||
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
||||||
WorkflowPage.actions.hitUndo();
|
WorkflowPage.actions.hitUndo();
|
||||||
WorkflowPage.getters.disabledNodes().should('have.length', 0);
|
WorkflowPage.getters.disabledNodes().should('have.length', 0);
|
||||||
|
@ -252,11 +245,7 @@ describe('Undo/Redo', () => {
|
||||||
it('should undo/redo duplicating a node', () => {
|
it('should undo/redo duplicating a node', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters
|
WorkflowPage.actions.duplicateNode(CODE_NODE_NAME);
|
||||||
.canvasNodes()
|
|
||||||
.last()
|
|
||||||
.find('[data-test-id="duplicate-node-button"]')
|
|
||||||
.click({ force: true });
|
|
||||||
WorkflowPage.actions.hitUndo();
|
WorkflowPage.actions.hitUndo();
|
||||||
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
||||||
WorkflowPage.actions.hitRedo();
|
WorkflowPage.actions.hitRedo();
|
||||||
|
|
|
@ -134,7 +134,7 @@ describe('Canvas Actions', () => {
|
||||||
.canvasNodes()
|
.canvasNodes()
|
||||||
.last()
|
.last()
|
||||||
.should('have.css', 'left', '860px')
|
.should('have.css', 'left', '860px')
|
||||||
.should('have.css', 'top', '220px')
|
.should('have.css', 'top', '220px');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete connections by pressing the delete button', () => {
|
it('should delete connections by pressing the delete button', () => {
|
||||||
|
@ -163,21 +163,29 @@ describe('Canvas Actions', () => {
|
||||||
.find('[data-test-id="execute-node-button"]')
|
.find('[data-test-id="execute-node-button"]')
|
||||||
.click({ force: true });
|
.click({ force: true });
|
||||||
WorkflowPage.getters.successToast().should('contain', 'Node executed successfully');
|
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', () => {
|
it('should copy selected nodes', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.actions.selectAll();
|
WorkflowPage.actions.selectAll();
|
||||||
|
|
||||||
WorkflowPage.actions.hitCopy();
|
WorkflowPage.actions.hitCopy();
|
||||||
WorkflowPage.getters.successToast().should('contain', 'Copied!');
|
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(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.actions.selectAll();
|
WorkflowPage.actions.selectAll();
|
||||||
WorkflowPage.getters.selectedNodes().should('have.length', 2);
|
WorkflowPage.getters.selectedNodes().should('have.length', 2);
|
||||||
|
WorkflowPage.actions.deselectAll();
|
||||||
|
WorkflowPage.getters.selectedNodes().should('have.length', 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should select nodes using arrow keys', () => {
|
it('should select nodes using arrow keys', () => {
|
||||||
|
@ -205,22 +213,21 @@ describe('Canvas Actions', () => {
|
||||||
WorkflowPage.getters
|
WorkflowPage.getters
|
||||||
.canvasNodes()
|
.canvasNodes()
|
||||||
.last()
|
.last()
|
||||||
.findChildByTestId('disable-node-button').as('disableNodeButton');
|
.findChildByTestId('execute-node-button')
|
||||||
cy.drag('@disableNodeButton', [200, 200]);
|
.as('executeNodeButton');
|
||||||
|
cy.drag('@executeNodeButton', [200, 200]);
|
||||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not break lasso selection with multiple clicks on node action buttons', () => {
|
it('should not break lasso selection with multiple clicks on node action buttons', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||||
WorkflowPage.getters
|
WorkflowPage.getters.canvasNodes().last().as('lastNode');
|
||||||
.canvasNodes()
|
cy.get('@lastNode').findChildByTestId('execute-node-button').as('executeNodeButton');
|
||||||
.last().as('lastNode');
|
|
||||||
cy.get('@lastNode').findChildByTestId('disable-node-button').as('disableNodeButton');
|
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
cy.get('@lastNode').realHover();
|
cy.get('@lastNode').realHover();
|
||||||
cy.get('@disableNodeButton').should('be.visible');
|
cy.get('@executeNodeButton').should('be.visible');
|
||||||
cy.get('@disableNodeButton').realTouch();
|
cy.get('@executeNodeButton').realTouch();
|
||||||
cy.getByTestId('execute-workflow-button').realHover();
|
cy.getByTestId('execute-workflow-button').realHover();
|
||||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ const ZOOM_OUT_X2_FACTOR = 0.64;
|
||||||
const PINCH_ZOOM_IN_FACTOR = 1.05702;
|
const PINCH_ZOOM_IN_FACTOR = 1.05702;
|
||||||
const PINCH_ZOOM_OUT_FACTOR = 0.946058;
|
const PINCH_ZOOM_OUT_FACTOR = 0.946058;
|
||||||
const RENAME_NODE_NAME = 'Something else';
|
const RENAME_NODE_NAME = 'Something else';
|
||||||
|
const RENAME_NODE_NAME2 = 'Something different';
|
||||||
|
|
||||||
describe('Canvas Node Manipulation and Navigation', () => {
|
describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -129,13 +130,10 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
cy.get('.jtk-connector').should('have.length', 4);
|
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(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters
|
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME);
|
||||||
.canvasNodeByName(CODE_NODE_NAME)
|
|
||||||
.find('[data-test-id=delete-node-button]')
|
|
||||||
.click({ force: true });
|
|
||||||
WorkflowPage.getters.canvasNodes().should('have.length', 1);
|
WorkflowPage.getters.canvasNodes().should('have.length', 1);
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||||
});
|
});
|
||||||
|
@ -162,13 +160,38 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
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(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
WorkflowPage.actions.selectAll();
|
WorkflowPage.actions.selectAll();
|
||||||
cy.get('body').type('{backspace}');
|
cy.get('body').type('{backspace}');
|
||||||
WorkflowPage.getters.canvasNodes().should('have.length', 0);
|
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', () => {
|
it('should move node', () => {
|
||||||
|
@ -272,39 +295,42 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
WorkflowPage.getters.canvasNodes().last().should('be.visible');
|
WorkflowPage.getters.canvasNodes().last().should('be.visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should disable node by pressing the disable button', () => {
|
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()
|
|
||||||
.find('[data-test-id="disable-node-button"]')
|
|
||||||
.click({ force: true });
|
|
||||||
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disable node using keyboard shortcut', () => {
|
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodes().last().click();
|
WorkflowPage.getters.canvasNodes().last().click();
|
||||||
WorkflowPage.actions.hitDisableNodeShortcut();
|
WorkflowPage.actions.hitDisableNodeShortcut();
|
||||||
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
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.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
cy.get('body').type('{esc}');
|
cy.get('body').type('{esc}');
|
||||||
cy.get('body').type('{esc}');
|
cy.get('body').type('{esc}');
|
||||||
WorkflowPage.actions.selectAll();
|
WorkflowPage.actions.selectAll();
|
||||||
|
|
||||||
|
// Keyboard shortcut
|
||||||
WorkflowPage.actions.hitDisableNodeShortcut();
|
WorkflowPage.actions.hitDisableNodeShortcut();
|
||||||
WorkflowPage.getters.disabledNodes().should('have.length', 2);
|
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(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodes().last().click();
|
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(RENAME_NODE_NAME);
|
||||||
cy.get('body').type('{enter}');
|
cy.get('body').type('{enter}');
|
||||||
WorkflowPage.getters.canvasNodeByName(RENAME_NODE_NAME).should('exist');
|
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.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters
|
WorkflowPage.actions.duplicateNode(CODE_NODE_NAME);
|
||||||
.canvasNodes()
|
|
||||||
.last()
|
|
||||||
.find('[data-test-id="duplicate-node-button"]')
|
|
||||||
.click({ force: true });
|
|
||||||
WorkflowPage.getters.canvasNodes().should('have.length', 3);
|
WorkflowPage.getters.canvasNodes().should('have.length', 3);
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
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
|
// 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.getters.canvasNodes().should('have.have.length', 2);
|
||||||
|
|
||||||
WorkflowPage.actions.openNode('n8n');
|
WorkflowPage.actions.openNodeFromContextMenu('n8n');
|
||||||
cy.get('[class*=hasIssues]').should('have.length', 1);
|
cy.get('[class*=hasIssues]').should('have.length', 1);
|
||||||
NDVDialog.actions.close();
|
NDVDialog.actions.close();
|
||||||
});
|
});
|
||||||
|
@ -392,15 +424,9 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
WorkflowPage.actions.executeWorkflow();
|
WorkflowPage.actions.executeWorkflow();
|
||||||
cy.contains('Unrecognized node type').should('be.visible');
|
cy.contains('Unrecognized node type').should('be.visible');
|
||||||
|
|
||||||
WorkflowPage.getters
|
WorkflowPage.actions.deselectAll();
|
||||||
.canvasNodeByName(`${unknownNodeName} 1`)
|
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 1`);
|
||||||
.find('[data-test-id=delete-node-button]')
|
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 2`);
|
||||||
.click({ force: true });
|
|
||||||
|
|
||||||
WorkflowPage.getters
|
|
||||||
.canvasNodeByName(`${unknownNodeName} 2`)
|
|
||||||
.find('[data-test-id=delete-node-button]')
|
|
||||||
.click({ force: true });
|
|
||||||
|
|
||||||
WorkflowPage.actions.executeWorkflow();
|
WorkflowPage.actions.executeWorkflow();
|
||||||
|
|
||||||
|
|
|
@ -70,7 +70,7 @@ describe('Data pinning', () => {
|
||||||
|
|
||||||
it('Should be duplicating pin data when duplicating node', () => {
|
it('Should be duplicating pin data when duplicating node', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
|
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.container().should('be.visible');
|
||||||
ndv.getters.pinDataButton().should('not.exist');
|
ndv.getters.pinDataButton().should('not.exist');
|
||||||
ndv.getters.editPinnedDataButton().should('be.visible');
|
ndv.getters.editPinnedDataButton().should('be.visible');
|
||||||
|
@ -78,7 +78,7 @@ describe('Data pinning', () => {
|
||||||
ndv.actions.setPinnedData([{ test: 1 }]);
|
ndv.actions.setPinnedData([{ test: 1 }]);
|
||||||
ndv.actions.close();
|
ndv.actions.close();
|
||||||
|
|
||||||
workflowPage.actions.duplicateNode(workflowPage.getters.canvasNodes().last());
|
workflowPage.actions.duplicateNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||||
|
|
||||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||||
|
|
||||||
|
@ -88,9 +88,37 @@ describe('Data pinning', () => {
|
||||||
ndv.getters.outputTbodyCell(1, 0).should('include.text', 1);
|
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', () => {
|
it('Should show an error when maximum pin data size is exceeded', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
|
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.container().should('be.visible');
|
||||||
ndv.getters.pinDataButton().should('not.exist');
|
ndv.getters.pinDataButton().should('not.exist');
|
||||||
ndv.getters.editPinnedDataButton().should('be.visible');
|
ndv.getters.editPinnedDataButton().should('be.visible');
|
||||||
|
|
|
@ -31,6 +31,11 @@ describe('Canvas Actions', () => {
|
||||||
workflowPage.getters.addStickyButton().should('not.be.visible');
|
workflowPage.getters.addStickyButton().should('not.be.visible');
|
||||||
|
|
||||||
addDefaultSticky();
|
addDefaultSticky();
|
||||||
|
workflowPage.actions.deselectAll();
|
||||||
|
workflowPage.actions.addStickyFromContextMenu();
|
||||||
|
workflowPage.actions.hitAddStickyShortcut();
|
||||||
|
|
||||||
|
workflowPage.getters.stickies().should('have.length', 3);
|
||||||
workflowPage.getters
|
workflowPage.getters
|
||||||
.stickies()
|
.stickies()
|
||||||
.eq(0)
|
.eq(0)
|
||||||
|
|
|
@ -24,6 +24,7 @@ export class NDV extends BasePage {
|
||||||
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
|
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
|
||||||
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller'),
|
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller'),
|
||||||
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
|
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
|
||||||
|
nodeOutputHint: () => cy.getByTestId('ndv-output-run-node-hint'),
|
||||||
savePinnedDataButton: () =>
|
savePinnedDataButton: () =>
|
||||||
this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'),
|
this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'),
|
||||||
outputTableRows: () => this.getters.outputDataContainer().find('table tr'),
|
outputTableRows: () => this.getters.outputDataContainer().find('table tr'),
|
||||||
|
|
|
@ -127,6 +127,7 @@ export class WorkflowPage extends BasePage {
|
||||||
editorTabButton: () => cy.getByTestId('radio-button-workflow'),
|
editorTabButton: () => cy.getByTestId('radio-button-workflow'),
|
||||||
workflowHistoryButton: () => cy.getByTestId('workflow-history-button'),
|
workflowHistoryButton: () => cy.getByTestId('workflow-history-button'),
|
||||||
colors: () => cy.getByTestId('color'),
|
colors: () => cy.getByTestId('color'),
|
||||||
|
contextMenuAction: (action: string) => cy.getByTestId(`context-menu-item-${action}`),
|
||||||
};
|
};
|
||||||
actions = {
|
actions = {
|
||||||
visit: (preventNodeViewUnload = true) => {
|
visit: (preventNodeViewUnload = true) => {
|
||||||
|
@ -185,11 +186,70 @@ export class WorkflowPage extends BasePage {
|
||||||
|
|
||||||
if (!preventNdvClose) cy.get('body').type('{esc}');
|
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) => {
|
openNode: (nodeTypeName: string) => {
|
||||||
this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
|
this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
|
||||||
},
|
},
|
||||||
duplicateNode: (node: Chainable<JQuery<HTMLElement>>) => {
|
duplicateNode: (nodeTypeName: string) => {
|
||||||
node.find('[data-test-id="duplicate-node-button"]').click({ force: true });
|
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: () => {
|
openExpressionEditorModal: () => {
|
||||||
cy.contains('Expression').invoke('show').click();
|
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');
|
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('a');
|
||||||
},
|
},
|
||||||
hitDisableNodeShortcut: () => {
|
hitDisableNodeShortcut: () => {
|
||||||
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('d');
|
cy.get('body').type('d');
|
||||||
},
|
},
|
||||||
hitCopy: () => {
|
hitCopy: () => {
|
||||||
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('c');
|
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('c');
|
||||||
|
@ -292,6 +352,18 @@ export class WorkflowPage extends BasePage {
|
||||||
hitPaste: () => {
|
hitPaste: () => {
|
||||||
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('P');
|
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: () => {
|
executeWorkflow: () => {
|
||||||
this.getters.executeWorkflowButton().click();
|
this.getters.executeWorkflowButton().click();
|
||||||
},
|
},
|
||||||
|
|
|
@ -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'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
|
@ -4,11 +4,21 @@
|
||||||
:placement="placement"
|
:placement="placement"
|
||||||
:trigger="trigger"
|
:trigger="trigger"
|
||||||
@command="onSelect"
|
@command="onSelect"
|
||||||
|
:popper-class="{ [$style.shadow]: true, [$style.hideArrow]: hideArrow }"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
ref="elementDropdown"
|
ref="elementDropdown"
|
||||||
>
|
>
|
||||||
<div :class="$style.activator" @click.stop.prevent @blur="onButtonBlur">
|
<slot v-if="$slots.activator" name="activator" />
|
||||||
<n8n-icon :icon="activatorIcon" />
|
<n8n-icon-button
|
||||||
</div>
|
v-else
|
||||||
|
@blur="onButtonBlur"
|
||||||
|
type="tertiary"
|
||||||
|
text
|
||||||
|
:class="$style.activator"
|
||||||
|
:size="activatorSize"
|
||||||
|
:icon="activatorIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu :class="$style.userActionsMenu">
|
<el-dropdown-menu :class="$style.userActionsMenu">
|
||||||
<el-dropdown-item
|
<el-dropdown-item
|
||||||
|
@ -17,6 +27,7 @@
|
||||||
:command="item.id"
|
:command="item.id"
|
||||||
:disabled="item.disabled"
|
:disabled="item.disabled"
|
||||||
:divided="item.divided"
|
:divided="item.divided"
|
||||||
|
:class="$style.elementItem"
|
||||||
>
|
>
|
||||||
<div :class="getItemClasses(item)" :data-test-id="`${testIdPrefix}-item-${item.id}`">
|
<div :class="getItemClasses(item)" :data-test-id="`${testIdPrefix}-item-${item.id}`">
|
||||||
<span v-if="item.icon" :class="$style.icon">
|
<span v-if="item.icon" :class="$style.icon">
|
||||||
|
@ -25,6 +36,12 @@
|
||||||
<span :class="$style.label">
|
<span :class="$style.label">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</span>
|
</span>
|
||||||
|
<n8n-keyboard-shortcut
|
||||||
|
v-if="item.shortcut"
|
||||||
|
v-bind="item.shortcut"
|
||||||
|
:class="$style.shortcut"
|
||||||
|
>
|
||||||
|
</n8n-keyboard-shortcut>
|
||||||
</div>
|
</div>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
|
@ -38,6 +55,8 @@ import type { PropType } from 'vue';
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus';
|
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus';
|
||||||
import N8nIcon from '../N8nIcon';
|
import N8nIcon from '../N8nIcon';
|
||||||
|
import { N8nKeyboardShortcut } from '../N8nKeyboardShortcut';
|
||||||
|
import type { KeyboardShortcut } from '../../types';
|
||||||
|
|
||||||
export interface IActionDropdownItem {
|
export interface IActionDropdownItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -45,6 +64,7 @@ export interface IActionDropdownItem {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
divided?: boolean;
|
divided?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
shortcut?: KeyboardShortcut;
|
||||||
customClass?: string;
|
customClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,6 +81,7 @@ export default defineComponent({
|
||||||
ElDropdownMenu,
|
ElDropdownMenu,
|
||||||
ElDropdownItem,
|
ElDropdownItem,
|
||||||
N8nIcon,
|
N8nIcon,
|
||||||
|
N8nKeyboardShortcut,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
const testIdPrefix = this.$attrs['data-test-id'];
|
const testIdPrefix = this.$attrs['data-test-id'];
|
||||||
|
@ -79,7 +100,11 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
activatorIcon: {
|
activatorIcon: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'ellipsis-v',
|
default: 'ellipsis-h',
|
||||||
|
},
|
||||||
|
activatorSize: {
|
||||||
|
type: String,
|
||||||
|
default: 'medium',
|
||||||
},
|
},
|
||||||
iconSize: {
|
iconSize: {
|
||||||
type: String,
|
type: String,
|
||||||
|
@ -91,11 +116,16 @@ export default defineComponent({
|
||||||
default: 'click',
|
default: 'click',
|
||||||
validator: (value: string): boolean => ['click', 'hover'].includes(value),
|
validator: (value: string): boolean => ['click', 'hover'].includes(value),
|
||||||
},
|
},
|
||||||
|
hideArrow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getItemClasses(item: IActionDropdownItem): Record<string, boolean> {
|
getItemClasses(item: IActionDropdownItem): Record<string, boolean> {
|
||||||
return {
|
return {
|
||||||
[this.$style.itemContainer]: true,
|
[this.$style.itemContainer]: true,
|
||||||
|
[this.$style.disabled]: item.disabled,
|
||||||
[this.$style.hasCustomStyling]: item.customClass !== undefined,
|
[this.$style.hasCustomStyling]: item.customClass !== undefined,
|
||||||
...(item.customClass !== undefined ? { [item.customClass]: true } : {}),
|
...(item.customClass !== undefined ? { [item.customClass]: true } : {}),
|
||||||
};
|
};
|
||||||
|
@ -103,46 +133,71 @@ export default defineComponent({
|
||||||
onSelect(action: string): void {
|
onSelect(action: string): void {
|
||||||
this.$emit('select', action);
|
this.$emit('select', action);
|
||||||
},
|
},
|
||||||
|
onVisibleChange(open: boolean): void {
|
||||||
|
this.$emit('visibleChange', open);
|
||||||
|
},
|
||||||
onButtonBlur(event: FocusEvent): void {
|
onButtonBlur(event: FocusEvent): void {
|
||||||
const elementDropdown = this.$refs.elementDropdown as InstanceType<ElDropdown>;
|
const elementDropdown = this.$refs.elementDropdown as InstanceType<typeof ElDropdown>;
|
||||||
|
|
||||||
// Hide dropdown when clicking outside of current document
|
// Hide dropdown when clicking outside of current document
|
||||||
if (elementDropdown?.handleClose && event.relatedTarget === null) {
|
if (elementDropdown?.handleClose && event.relatedTarget === null) {
|
||||||
elementDropdown.handleClose();
|
elementDropdown.handleClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
open() {
|
||||||
|
const elementDropdown = this.$refs.elementDropdown as InstanceType<typeof ElDropdown>;
|
||||||
|
elementDropdown.handleOpen();
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
const elementDropdown = this.$refs.elementDropdown as InstanceType<typeof ElDropdown>;
|
||||||
|
elementDropdown.handleClose();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.userActionsMenu {
|
:global(.el-dropdown__list) {
|
||||||
min-width: 160px;
|
.userActionsMenu {
|
||||||
|
min-width: 160px;
|
||||||
|
padding: var(--spacing-4xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elementItem {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.el-popper).hideArrow {
|
||||||
|
:global(.el-popper__arrow) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow {
|
||||||
|
box-shadow: var(--box-shadow-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activator {
|
.activator {
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
margin: 0;
|
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
line-height: normal !important;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
position: static !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-background-base);
|
background-color: var(--color-background-base);
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemContainer {
|
.itemContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: var(--font-size-2xs);
|
||||||
|
line-height: 18px;
|
||||||
|
padding: var(--spacing-3xs) var(--spacing-2xs);
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
.shortcut {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
|
@ -154,6 +209,10 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shortcut {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
:global(li.is-disabled) {
|
:global(li.is-disabled) {
|
||||||
.hasCustomStyling {
|
.hasCustomStyling {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
|
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
exports[`components > N8nActionDropdown > should render custom styling correctly 1`] = `
|
exports[`components > N8nActionDropdown > should render custom styling correctly 1`] = `
|
||||||
"<div class=\\"action-dropdown-container actionDropdownContainer\\">
|
"<div class=\\"action-dropdown-container actionDropdownContainer\\">
|
||||||
<el-dropdown-stub trigger=\\"click\\" effect=\\"light\\" placement=\\"bottom\\" popperoptions=\\"[object Object]\\" size=\\"\\" splitbutton=\\"false\\" hideonclick=\\"true\\" loop=\\"true\\" showtimeout=\\"150\\" hidetimeout=\\"150\\" tabindex=\\"0\\" maxheight=\\"\\" popperclass=\\"\\" disabled=\\"false\\" role=\\"menu\\" teleported=\\"true\\"></el-dropdown-stub>
|
<el-dropdown-stub trigger=\\"click\\" effect=\\"light\\" placement=\\"bottom\\" popperoptions=\\"[object Object]\\" size=\\"\\" splitbutton=\\"false\\" hideonclick=\\"true\\" loop=\\"true\\" showtimeout=\\"150\\" hidetimeout=\\"150\\" tabindex=\\"0\\" maxheight=\\"\\" popperclass=\\"[object Object]\\" disabled=\\"false\\" role=\\"menu\\" teleported=\\"true\\"></el-dropdown-stub>
|
||||||
</div>"
|
</div>"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`components > N8nActionDropdown > should render default styling correctly 1`] = `
|
exports[`components > N8nActionDropdown > should render default styling correctly 1`] = `
|
||||||
"<div class=\\"action-dropdown-container actionDropdownContainer\\" teleported=\\"false\\">
|
"<div class=\\"action-dropdown-container actionDropdownContainer\\" teleported=\\"false\\">
|
||||||
<el-dropdown-stub trigger=\\"click\\" effect=\\"light\\" placement=\\"bottom\\" popperoptions=\\"[object Object]\\" size=\\"\\" splitbutton=\\"false\\" hideonclick=\\"true\\" loop=\\"true\\" showtimeout=\\"150\\" hidetimeout=\\"150\\" tabindex=\\"0\\" maxheight=\\"\\" popperclass=\\"\\" disabled=\\"false\\" role=\\"menu\\" teleported=\\"true\\"></el-dropdown-stub>
|
<el-dropdown-stub trigger=\\"click\\" effect=\\"light\\" placement=\\"bottom\\" popperoptions=\\"[object Object]\\" size=\\"\\" splitbutton=\\"false\\" hideonclick=\\"true\\" loop=\\"true\\" showtimeout=\\"150\\" hidetimeout=\\"150\\" tabindex=\\"0\\" maxheight=\\"\\" popperclass=\\"[object Object]\\" disabled=\\"false\\" role=\\"menu\\" teleported=\\"true\\"></el-dropdown-stub>
|
||||||
</div>"
|
</div>"
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import N8nKeyboardShorcut from './N8nKeyboardShortcut.vue';
|
||||||
|
import type { StoryFn } from '@storybook/vue3';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Atoms/KeyboardShortcut',
|
||||||
|
component: N8nKeyboardShorcut,
|
||||||
|
};
|
||||||
|
|
||||||
|
const template: StoryFn = (args, { argTypes }) => ({
|
||||||
|
setup: () => ({ args }),
|
||||||
|
props: Object.keys(argTypes),
|
||||||
|
components: {
|
||||||
|
N8nKeyboardShorcut,
|
||||||
|
},
|
||||||
|
template: '<n8n-keyboard-shortcut v-bind="args" />',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const defaultShortcut = template.bind({});
|
||||||
|
defaultShortcut.args = {
|
||||||
|
keys: ['s'],
|
||||||
|
altKey: true,
|
||||||
|
metaKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
};
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useDeviceSupport } from '../../composables';
|
||||||
|
import type { KeyboardShortcut } from '../../types/keyboardshortcut';
|
||||||
|
|
||||||
|
const props = defineProps<KeyboardShortcut>();
|
||||||
|
const { isMacOs } = useDeviceSupport();
|
||||||
|
|
||||||
|
const keys = computed(() => {
|
||||||
|
const allKeys = props.keys.map((key) => key.charAt(0).toUpperCase() + key.slice(1));
|
||||||
|
|
||||||
|
if (props.metaKey && isMacOs) {
|
||||||
|
allKeys.unshift('⌘');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.shiftKey) {
|
||||||
|
allKeys.unshift('⇧');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.altKey) {
|
||||||
|
allKeys.unshift(isMacOs ? '⌥' : 'Alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.metaKey && !isMacOs) {
|
||||||
|
allKeys.unshift('Ctrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
return allKeys;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.shortcut">
|
||||||
|
<div v-for="key of keys" :class="$style.keyWrapper" :key="key">
|
||||||
|
<div :class="$style.key">{{ key }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.shortcut {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
.keyWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
height: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 0 var(--spacing-4xs);
|
||||||
|
border: solid 1px var(--color-foreground-base);
|
||||||
|
background: var(--color-background-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
font-size: var(--font-size-3xs);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as N8nKeyboardShortcut } from './N8nKeyboardShortcut.vue';
|
|
@ -50,3 +50,4 @@ export { default as N8nUserStack } from './N8nUserStack';
|
||||||
export { default as N8nUserInfo } from './N8nUserInfo';
|
export { default as N8nUserInfo } from './N8nUserInfo';
|
||||||
export { default as N8nUserSelect } from './N8nUserSelect';
|
export { default as N8nUserSelect } from './N8nUserSelect';
|
||||||
export { default as N8nUsersList } from './N8nUsersList';
|
export { default as N8nUsersList } from './N8nUsersList';
|
||||||
|
export { N8nKeyboardShortcut } from './N8nKeyboardShortcut';
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from './useI18n';
|
export * from './useI18n';
|
||||||
|
export { useDeviceSupport } from './useDeviceSupport';
|
||||||
|
|
|
@ -7,7 +7,7 @@ interface DeviceSupportHelpers {
|
||||||
isCtrlKeyPressed: (e: MouseEvent | KeyboardEvent) => boolean;
|
isCtrlKeyPressed: (e: MouseEvent | KeyboardEvent) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useDeviceSupportHelpers(): DeviceSupportHelpers {
|
export function useDeviceSupport(): DeviceSupportHelpers {
|
||||||
const isTouchDevice = ref('ontouchstart' in window || navigator.maxTouchPoints > 0);
|
const isTouchDevice = ref('ontouchstart' in window || navigator.maxTouchPoints > 0);
|
||||||
const userAgent = ref(navigator.userAgent.toLowerCase());
|
const userAgent = ref(navigator.userAgent.toLowerCase());
|
||||||
const isMacOs = ref(
|
const isMacOs = ref(
|
|
@ -25,6 +25,10 @@
|
||||||
--color-background-light: var(--prim-gray-820);
|
--color-background-light: var(--prim-gray-820);
|
||||||
--color-background-xlight: var(--prim-gray-740);
|
--color-background-xlight: var(--prim-gray-740);
|
||||||
|
|
||||||
|
--box-shadow-base: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--box-shadow-dark: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 6px rgba(0, 0, 0, 0.2);
|
||||||
|
--box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
// Secondary tokens
|
// Secondary tokens
|
||||||
|
|
||||||
// Canvas
|
// Canvas
|
||||||
|
@ -160,6 +164,9 @@
|
||||||
--color-switch-background: var(--prim-gray-820);
|
--color-switch-background: var(--prim-gray-820);
|
||||||
--color-switch-toggle: var(--prim-gray-40);
|
--color-switch-toggle: var(--prim-gray-40);
|
||||||
|
|
||||||
|
// Action Dropdown
|
||||||
|
--color-action-dropdown-item-active-background: var(--color-background-xlight);
|
||||||
|
|
||||||
// Various
|
// Various
|
||||||
--color-info-tint-1: var(--prim-gray-420);
|
--color-info-tint-1: var(--prim-gray-420);
|
||||||
--color-info-tint-2: var(--prim-gray-740);
|
--color-info-tint-2: var(--prim-gray-740);
|
||||||
|
|
|
@ -236,6 +236,8 @@
|
||||||
--color-value-survey-background: var(--prim-gray-740);
|
--color-value-survey-background: var(--prim-gray-740);
|
||||||
--color-value-survey-font: var(--prim-gray-0);
|
--color-value-survey-font: var(--prim-gray-0);
|
||||||
|
|
||||||
|
// Action Dropdown
|
||||||
|
--color-action-dropdown-item-active-background: var(--color-background-base);
|
||||||
// Switch (Activation, boolean)
|
// Switch (Activation, boolean)
|
||||||
--color-switch-background: var(--prim-gray-420);
|
--color-switch-background: var(--prim-gray-420);
|
||||||
--color-switch-active-background: var(--prim-color-alt-i);
|
--color-switch-active-background: var(--prim-color-alt-i);
|
||||||
|
@ -292,6 +294,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
--box-shadow-base: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
|
||||||
|
--box-shadow-dark: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.12);
|
||||||
|
--box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.07);
|
||||||
|
|
||||||
--border-radius-xlarge: 12px;
|
--border-radius-xlarge: 12px;
|
||||||
--border-radius-large: 8px;
|
--border-radius-large: 8px;
|
||||||
--border-radius-base: 4px;
|
--border-radius-base: 4px;
|
||||||
|
|
|
@ -45,6 +45,10 @@
|
||||||
transform: scaleY(1);
|
transform: scaleY(1);
|
||||||
transition: var.$md-fade-transition;
|
transition: var.$md-fade-transition;
|
||||||
transform-origin: center top;
|
transform-origin: center top;
|
||||||
|
|
||||||
|
&[data-popper-placement^='top'] {
|
||||||
|
transform-origin: center bottom;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.el-zoom-in-top-enter-from,
|
.el-zoom-in-top-enter-from,
|
||||||
.el-zoom-in-top-leave-active {
|
.el-zoom-in-top-leave-active {
|
||||||
|
|
|
@ -75,15 +75,11 @@ $focus-outline-width: 2px;
|
||||||
/* Box shadow
|
/* Box shadow
|
||||||
-------------------------- */
|
-------------------------- */
|
||||||
/// boxShadow|1|Shadow|1
|
/// boxShadow|1|Shadow|1
|
||||||
$box-shadow-base:
|
$box-shadow-base: var(--box-shadow-base);
|
||||||
0 2px 4px rgba(0, 0, 0, 0.12),
|
|
||||||
0 0 6px rgba(0, 0, 0, 0.04);
|
|
||||||
// boxShadow|1|Shadow|1
|
// boxShadow|1|Shadow|1
|
||||||
$box-shadow-dark:
|
$box-shadow-dark: var(--box-shadow-dark);
|
||||||
0 2px 4px rgba(0, 0, 0, 0.12),
|
|
||||||
0 0 6px rgba(0, 0, 0, 0.12);
|
|
||||||
/// boxShadow|1|Shadow|1
|
/// boxShadow|1|Shadow|1
|
||||||
$box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
$box-shadow-light: var(--box-shadow-light);
|
||||||
|
|
||||||
/* Fill
|
/* Fill
|
||||||
-------------------------- */
|
-------------------------- */
|
||||||
|
@ -726,13 +722,13 @@ $popover-title-font-color: var(--color-text-dark);
|
||||||
/* Tooltip
|
/* Tooltip
|
||||||
-------------------------- */
|
-------------------------- */
|
||||||
/// color|1|Color|0
|
/// color|1|Color|0
|
||||||
$tooltip-fill: var(--color-text-dark);
|
$tooltip-fill: var(--color-background-dark);
|
||||||
/// color|1|Color|0
|
/// color|1|Color|0
|
||||||
$tooltip-color: $color-white;
|
$tooltip-color: $color-white;
|
||||||
/// fontSize||Font|1
|
/// fontSize||Font|1
|
||||||
$tooltip-font-size: 12px;
|
$tooltip-font-size: 12px;
|
||||||
/// color||Color|0
|
/// color||Color|0
|
||||||
$tooltip-border-color: var(--color-text-dark);
|
$tooltip-border-color: var(--color-background-dark);
|
||||||
$tooltip-arrow-size: 6px;
|
$tooltip-arrow-size: 6px;
|
||||||
/// padding||Spacing|3
|
/// padding||Spacing|3
|
||||||
$tooltip-padding: 10px;
|
$tooltip-padding: 10px;
|
||||||
|
@ -766,8 +762,8 @@ $tree-expand-icon-color: var(--color-text-lighter);
|
||||||
/* Dropdown
|
/* Dropdown
|
||||||
-------------------------- */
|
-------------------------- */
|
||||||
$dropdown-menu-box-shadow: $box-shadow-light;
|
$dropdown-menu-box-shadow: $box-shadow-light;
|
||||||
$dropdown-menuItem-hover-fill: var(--color-background-xlight);
|
$dropdown-menuItem-hover-fill: var(--color-action-dropdown-item-active-background);
|
||||||
$dropdown-menuItem-hover-color: $link-color;
|
$dropdown-menuItem-hover-color: var(--color-text-dark);
|
||||||
|
|
||||||
/* Badge
|
/* Badge
|
||||||
-------------------------- */
|
-------------------------- */
|
||||||
|
|
|
@ -81,7 +81,6 @@
|
||||||
background-color: var.$color-white;
|
background-color: var.$color-white;
|
||||||
border: 1px solid var(--border-color-light);
|
border: 1px solid var(--border-color-light);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
box-shadow: var.$dropdown-menu-box-shadow;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
|
@ -92,7 +91,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var.$font-size-base;
|
font-size: var.$font-size-base;
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-base);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
@ -117,14 +116,13 @@
|
||||||
content: '';
|
content: '';
|
||||||
height: $divided-offset;
|
height: $divided-offset;
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 -16px;
|
|
||||||
background-color: var.$color-white;
|
background-color: var.$color-white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include mixins.when(disabled) {
|
@include mixins.when(disabled) {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
color: var.$font-color-disabled-base;
|
color: var(--color-text-lighter);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -143,7 +141,6 @@
|
||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
height: $divided-offset;
|
height: $divided-offset;
|
||||||
margin: 0 -17px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -163,7 +160,6 @@
|
||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
height: $divided-offset;
|
height: $divided-offset;
|
||||||
margin: 0 -15px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -183,7 +179,6 @@
|
||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
height: $divided-offset;
|
height: $divided-offset;
|
||||||
margin: 0 -10px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,7 +54,7 @@
|
||||||
&[data-popper-placement^='top'] .el-popper__arrow {
|
&[data-popper-placement^='top'] .el-popper__arrow {
|
||||||
bottom: -(var.$popover-arrow-size);
|
bottom: -(var.$popover-arrow-size);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
margin-right: #{var.$tooltip-arrow-size * 0.5};
|
margin: 0 #{var.$tooltip-arrow-size * 0.5};
|
||||||
border-top-color: var.$popover-border-color;
|
border-top-color: var.$popover-border-color;
|
||||||
border-bottom-width: 0;
|
border-bottom-width: 0;
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@
|
||||||
&[data-popper-placement^='bottom'] .el-popper__arrow {
|
&[data-popper-placement^='bottom'] .el-popper__arrow {
|
||||||
top: -(var.$popover-arrow-size);
|
top: -(var.$popover-arrow-size);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
margin-right: #{var.$tooltip-arrow-size * 0.5};
|
margin: 0 #{var.$tooltip-arrow-size * 0.5};
|
||||||
border-top-width: 0;
|
border-top-width: 0;
|
||||||
border-bottom-color: var.$popover-border-color;
|
border-bottom-color: var.$popover-border-color;
|
||||||
|
|
||||||
|
@ -84,7 +84,7 @@
|
||||||
&[data-popper-placement^='right'] .el-popper__arrow {
|
&[data-popper-placement^='right'] .el-popper__arrow {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: -(var.$popover-arrow-size);
|
left: -(var.$popover-arrow-size);
|
||||||
margin-bottom: #{var.$tooltip-arrow-size * 0.5};
|
margin: #{var.$tooltip-arrow-size * 0.5} 0;
|
||||||
border-right-color: var.$popover-border-color;
|
border-right-color: var.$popover-border-color;
|
||||||
border-left-width: 0;
|
border-left-width: 0;
|
||||||
|
|
||||||
|
@ -99,7 +99,7 @@
|
||||||
&[data-popper-placement^='left'] .el-popper__arrow {
|
&[data-popper-placement^='left'] .el-popper__arrow {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: -(var.$popover-arrow-size);
|
right: -(var.$popover-arrow-size);
|
||||||
margin-bottom: #{var.$tooltip-arrow-size * 0.5};
|
margin: #{var.$tooltip-arrow-size * 0.5} 0;
|
||||||
border-right-width: 0;
|
border-right-width: 0;
|
||||||
border-left-color: var.$popover-border-color;
|
border-left-color: var.$popover-border-color;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import * as locale from './locale';
|
import * as locale from './locale';
|
||||||
|
|
||||||
|
export { useDeviceSupport } from './composables';
|
||||||
export * from './components';
|
export * from './components';
|
||||||
export * from './plugin';
|
export * from './plugin';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|
|
@ -51,6 +51,7 @@ import {
|
||||||
N8nUserInfo,
|
N8nUserInfo,
|
||||||
N8nUserSelect,
|
N8nUserSelect,
|
||||||
N8nUsersList,
|
N8nUsersList,
|
||||||
|
N8nKeyboardShortcut,
|
||||||
N8nUserStack,
|
N8nUserStack,
|
||||||
} from './components';
|
} from './components';
|
||||||
|
|
||||||
|
@ -108,5 +109,6 @@ export const N8nPlugin: Plugin<{}> = {
|
||||||
app.component('n8n-user-info', N8nUserInfo);
|
app.component('n8n-user-info', N8nUserInfo);
|
||||||
app.component('n8n-users-list', N8nUsersList);
|
app.component('n8n-users-list', N8nUsersList);
|
||||||
app.component('n8n-user-select', N8nUserSelect);
|
app.component('n8n-user-select', N8nUserSelect);
|
||||||
|
app.component('n8n-keyboard-shortcut', N8nKeyboardShortcut);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,3 +5,4 @@ export * from './i18n';
|
||||||
export * from './menu';
|
export * from './menu';
|
||||||
export * from './router';
|
export * from './router';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './keyboardshortcut';
|
||||||
|
|
6
packages/design-system/src/types/keyboardshortcut.ts
Normal file
6
packages/design-system/src/types/keyboardshortcut.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export interface KeyboardShortcut {
|
||||||
|
metaKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
shiftKey?: boolean;
|
||||||
|
keys: string[];
|
||||||
|
}
|
|
@ -1228,6 +1228,7 @@ export type NodeFilterType =
|
||||||
|
|
||||||
export type NodeCreatorOpenSource =
|
export type NodeCreatorOpenSource =
|
||||||
| ''
|
| ''
|
||||||
|
| 'context_menu'
|
||||||
| 'no_trigger_execution_tooltip'
|
| 'no_trigger_execution_tooltip'
|
||||||
| 'plus_endpoint'
|
| 'plus_endpoint'
|
||||||
| 'add_input_endpoint'
|
| 'add_input_endpoint'
|
||||||
|
|
|
@ -6,45 +6,62 @@
|
||||||
[$style.demoZoomMenu]: isDemo,
|
[$style.demoZoomMenu]: isDemo,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<n8n-icon-button
|
<keyboard-shortcut-tooltip
|
||||||
@click="zoomToFit"
|
:label="$locale.baseText('nodeView.zoomToFit')"
|
||||||
type="tertiary"
|
:shortcut="{ keys: ['1'] }"
|
||||||
size="large"
|
>
|
||||||
:title="$locale.baseText('nodeView.zoomToFit')"
|
<n8n-icon-button
|
||||||
icon="expand"
|
@click="zoomToFit"
|
||||||
data-test-id="zoom-to-fit"
|
type="tertiary"
|
||||||
/>
|
size="large"
|
||||||
<n8n-icon-button
|
icon="expand"
|
||||||
@click="zoomIn"
|
data-test-id="zoom-to-fit"
|
||||||
type="tertiary"
|
/>
|
||||||
size="large"
|
</keyboard-shortcut-tooltip>
|
||||||
:title="$locale.baseText('nodeView.zoomIn')"
|
<keyboard-shortcut-tooltip
|
||||||
icon="search-plus"
|
:label="$locale.baseText('nodeView.zoomIn')"
|
||||||
data-test-id="zoom-in-button"
|
:shortcut="{ keys: ['+'] }"
|
||||||
/>
|
>
|
||||||
<n8n-icon-button
|
<n8n-icon-button
|
||||||
@click="zoomOut"
|
@click="zoomIn"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
size="large"
|
size="large"
|
||||||
:title="$locale.baseText('nodeView.zoomOut')"
|
icon="search-plus"
|
||||||
icon="search-minus"
|
data-test-id="zoom-in-button"
|
||||||
data-test-id="zoom-out-button"
|
/>
|
||||||
/>
|
</keyboard-shortcut-tooltip>
|
||||||
<n8n-icon-button
|
<keyboard-shortcut-tooltip
|
||||||
v-if="nodeViewScale !== 1 && !isDemo"
|
:label="$locale.baseText('nodeView.zoomOut')"
|
||||||
@click="resetZoom"
|
:shortcut="{ keys: ['-'] }"
|
||||||
type="tertiary"
|
>
|
||||||
size="large"
|
<n8n-icon-button
|
||||||
:title="$locale.baseText('nodeView.resetZoom')"
|
@click="zoomOut"
|
||||||
icon="undo"
|
type="tertiary"
|
||||||
data-test-id="reset-zoom-button"
|
size="large"
|
||||||
/>
|
icon="search-minus"
|
||||||
|
data-test-id="zoom-out-button"
|
||||||
|
/>
|
||||||
|
</keyboard-shortcut-tooltip>
|
||||||
|
<keyboard-shortcut-tooltip
|
||||||
|
:label="$locale.baseText('nodeView.resetZoom')"
|
||||||
|
:shortcut="{ keys: ['0'] }"
|
||||||
|
>
|
||||||
|
<n8n-icon-button
|
||||||
|
v-if="nodeViewScale !== 1 && !isDemo"
|
||||||
|
@click="resetZoom"
|
||||||
|
type="tertiary"
|
||||||
|
size="large"
|
||||||
|
icon="undo"
|
||||||
|
data-test-id="reset-zoom-button"
|
||||||
|
/>
|
||||||
|
</keyboard-shortcut-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onBeforeMount, onBeforeUnmount } from 'vue';
|
import { onBeforeMount, onBeforeUnmount } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||||
|
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
const { zoomToFit, zoomIn, zoomOut, resetZoom } = canvasStore;
|
const { zoomToFit, zoomIn, zoomOut, resetZoom } = canvasStore;
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { type ContextMenuAction, useContextMenu } from '@/composables';
|
||||||
|
import { N8nActionDropdown } from 'n8n-design-system';
|
||||||
|
import type { INode } from 'n8n-workflow';
|
||||||
|
import { watch, ref } from 'vue';
|
||||||
|
|
||||||
|
const { isOpen, actions, position, targetNodes, target, close } = useContextMenu();
|
||||||
|
const contextMenu = ref<InstanceType<typeof N8nActionDropdown>>();
|
||||||
|
const emit = defineEmits<{ (event: 'action', action: ContextMenuAction, nodes: INode[]): void }>();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
isOpen,
|
||||||
|
() => {
|
||||||
|
if (isOpen) {
|
||||||
|
contextMenu.value?.open();
|
||||||
|
} else {
|
||||||
|
contextMenu.value?.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
);
|
||||||
|
|
||||||
|
function onActionSelect(item: string) {
|
||||||
|
emit('action', item as ContextMenuAction, targetNodes.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibleChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-if="isOpen" to="body">
|
||||||
|
<div :class="$style.contextMenu" :style="{ top: `${position[1]}px`, left: `${position[0]}px` }">
|
||||||
|
<n8n-action-dropdown
|
||||||
|
ref="contextMenu"
|
||||||
|
:items="actions"
|
||||||
|
placement="bottom-start"
|
||||||
|
data-test-id="context-menu"
|
||||||
|
:hideArrow="target.source !== 'node-button'"
|
||||||
|
@select="onActionSelect"
|
||||||
|
@visibleChange="onVisibleChange"
|
||||||
|
>
|
||||||
|
<template #activator>
|
||||||
|
<div :class="$style.activator"></div>
|
||||||
|
</template>
|
||||||
|
</n8n-action-dropdown>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.contextMenu {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activator {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Placement } from 'element-plus';
|
||||||
|
import type { KeyboardShortcut } from 'n8n-design-system/src/components/N8nKeyboardShortcut';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
shortcut: KeyboardShortcut;
|
||||||
|
placement?: Placement;
|
||||||
|
}
|
||||||
|
withDefaults(defineProps<Props>(), { placement: 'top' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n8n-tooltip :placement="placement" :show-after="500">
|
||||||
|
<template #content>
|
||||||
|
<div :class="$style.shortcut">
|
||||||
|
<div :class="$style.label">{{ label }}</div>
|
||||||
|
<n8n-keyboard-shortcut v-bind="shortcut"></n8n-keyboard-shortcut>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<slot />
|
||||||
|
</n8n-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.shortcut {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--font-size-2xs);
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -6,6 +6,7 @@
|
||||||
data-test-id="canvas-node"
|
data-test-id="canvas-node"
|
||||||
:ref="data.name"
|
:ref="data.name"
|
||||||
:data-name="data.name"
|
:data-name="data.name"
|
||||||
|
@contextmenu="(e: MouseEvent) => openContextMenu(e, 'node-right-click')"
|
||||||
>
|
>
|
||||||
<div class="select-background" v-show="isSelected"></div>
|
<div class="select-background" v-show="isSelected"></div>
|
||||||
<div
|
<div
|
||||||
|
@ -13,6 +14,7 @@
|
||||||
'node-default': true,
|
'node-default': true,
|
||||||
'touch-active': isTouchActive,
|
'touch-active': isTouchActive,
|
||||||
'is-touch-device': isTouchDevice,
|
'is-touch-device': isTouchDevice,
|
||||||
|
'menu-open': isContextMenuOpen,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -35,7 +37,7 @@
|
||||||
:class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }"
|
:class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }"
|
||||||
>
|
>
|
||||||
<div v-if="hasIssues" class="node-issues" data-test-id="node-issues">
|
<div v-if="hasIssues" class="node-issues" data-test-id="node-issues">
|
||||||
<n8n-tooltip placement="bottom">
|
<n8n-tooltip :show-after="500" placement="bottom">
|
||||||
<template #content>
|
<template #content>
|
||||||
<titled-list :title="`${$locale.baseText('node.issues')}:`" :items="nodeIssues" />
|
<titled-list :title="`${$locale.baseText('node.issues')}:`" :items="nodeIssues" />
|
||||||
</template>
|
</template>
|
||||||
|
@ -70,6 +72,7 @@
|
||||||
<div class="node-trigger-tooltip__wrapper">
|
<div class="node-trigger-tooltip__wrapper">
|
||||||
<n8n-tooltip
|
<n8n-tooltip
|
||||||
placement="top"
|
placement="top"
|
||||||
|
:show-after="500"
|
||||||
:visible="showTriggerNodeTooltip"
|
:visible="showTriggerNodeTooltip"
|
||||||
popper-class="node-trigger-tooltip__wrapper--item"
|
popper-class="node-trigger-tooltip__wrapper--item"
|
||||||
>
|
>
|
||||||
|
@ -102,48 +105,22 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="node-options no-select-on-click" v-if="!isReadOnly" v-show="!hideActions">
|
<div class="node-options no-select-on-click" v-if="!isReadOnly" v-show="!hideActions">
|
||||||
<div
|
<n8n-icon-button
|
||||||
v-touch:tap="deleteNode"
|
|
||||||
class="option"
|
|
||||||
:title="$locale.baseText('node.deleteNode')"
|
|
||||||
data-test-id="delete-node-button"
|
|
||||||
>
|
|
||||||
<font-awesome-icon icon="trash" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-touch:tap="disableNode"
|
|
||||||
class="option"
|
|
||||||
:title="$locale.baseText('node.activateDeactivateNode')"
|
|
||||||
data-test-id="disable-node-button"
|
|
||||||
>
|
|
||||||
<font-awesome-icon :icon="nodeDisabledIcon" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-touch:tap="duplicateNode"
|
|
||||||
class="option"
|
|
||||||
:title="$locale.baseText('node.duplicateNode')"
|
|
||||||
v-if="isDuplicatable"
|
|
||||||
data-test-id="duplicate-node-button"
|
|
||||||
>
|
|
||||||
<font-awesome-icon icon="clone" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-touch:tap="setNodeActive"
|
|
||||||
class="option touch"
|
|
||||||
:title="$locale.baseText('node.editNode')"
|
|
||||||
data-test-id="activate-node-button"
|
|
||||||
>
|
|
||||||
<font-awesome-icon class="execute-icon" icon="cog" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-touch:tap="executeNode"
|
|
||||||
class="option"
|
|
||||||
:title="$locale.baseText('node.executeNode')"
|
|
||||||
v-if="!workflowRunning && !isConfigNode"
|
|
||||||
data-test-id="execute-node-button"
|
data-test-id="execute-node-button"
|
||||||
>
|
type="tertiary"
|
||||||
<font-awesome-icon class="execute-icon" icon="play-circle" />
|
text
|
||||||
</div>
|
icon="play"
|
||||||
|
:disabled="workflowRunning || isConfigNode"
|
||||||
|
:title="$locale.baseText('node.executeNode')"
|
||||||
|
@click="executeNode"
|
||||||
|
/>
|
||||||
|
<n8n-icon-button
|
||||||
|
data-test-id="overflow-node-button"
|
||||||
|
type="tertiary"
|
||||||
|
text
|
||||||
|
icon="ellipsis-h"
|
||||||
|
@click="(e: MouseEvent) => openContextMenu(e, 'node-button')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
|
@ -208,9 +185,14 @@ import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { EnableNodeToggleCommand } from '@/models/history';
|
import { EnableNodeToggleCommand } from '@/models/history';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||||
|
import { type ContextMenuTarget, useContextMenu } from '@/composables';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Node',
|
name: 'Node',
|
||||||
|
setup() {
|
||||||
|
const contextMenu = useContextMenu();
|
||||||
|
return { contextMenu };
|
||||||
|
},
|
||||||
mixins: [externalHooks, nodeBase, nodeHelpers, workflowHelpers, pinData, debounceHelper],
|
mixins: [externalHooks, nodeBase, nodeHelpers, workflowHelpers, pinData, debounceHelper],
|
||||||
components: {
|
components: {
|
||||||
TitledList,
|
TitledList,
|
||||||
|
@ -542,6 +524,13 @@ export default defineComponent({
|
||||||
!this.dragging
|
!this.dragging
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
isContextMenuOpen(): boolean {
|
||||||
|
return (
|
||||||
|
this.contextMenu.isOpen.value &&
|
||||||
|
this.contextMenu.target.value.source === 'node-button' &&
|
||||||
|
this.contextMenu.target.value.node.name === this.data?.name
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
isActive(newValue, oldValue) {
|
isActive(newValue, oldValue) {
|
||||||
|
@ -667,27 +656,6 @@ export default defineComponent({
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
workflow_id: this.workflowsStore.workflowId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async deleteNode() {
|
|
||||||
this.$telemetry.track('User clicked node hover button', {
|
|
||||||
node_type: this.data.type,
|
|
||||||
button_name: 'delete',
|
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait a tick else vue causes problems because the data is gone
|
|
||||||
await this.$nextTick();
|
|
||||||
this.$emit('removeNode', this.data.name);
|
|
||||||
},
|
|
||||||
async duplicateNode() {
|
|
||||||
this.$telemetry.track('User clicked node hover button', {
|
|
||||||
node_type: this.data.type,
|
|
||||||
button_name: 'duplicate',
|
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
|
||||||
});
|
|
||||||
// Wait a tick else vue causes problems because the data is gone
|
|
||||||
await this.$nextTick();
|
|
||||||
this.$emit('duplicateNode', this.data.name);
|
|
||||||
},
|
|
||||||
|
|
||||||
onClick(event: MouseEvent) {
|
onClick(event: MouseEvent) {
|
||||||
void this.callDebounced('onClickDebounced', { debounceTime: 50, trailing: true }, event);
|
void this.callDebounced('onClickDebounced', { debounceTime: 50, trailing: true }, event);
|
||||||
|
@ -714,11 +682,20 @@ export default defineComponent({
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openContextMenu(event: MouseEvent, source: ContextMenuTarget['source']) {
|
||||||
|
if (this.data) {
|
||||||
|
this.contextMenu.open(event, { source, node: this.data });
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.context-menu {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
.node-wrapper {
|
.node-wrapper {
|
||||||
--node-width: 100px;
|
--node-width: 100px;
|
||||||
/*
|
/*
|
||||||
|
@ -792,13 +769,11 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
&.touch-active,
|
&.touch-active,
|
||||||
&:hover {
|
&:hover,
|
||||||
.node-execute {
|
&.menu-open {
|
||||||
display: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-options {
|
.node-options {
|
||||||
display: initial;
|
pointer-events: all;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -860,19 +835,27 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-options {
|
.node-options {
|
||||||
display: none;
|
--node-options-height: 26px;
|
||||||
|
:deep(.button) {
|
||||||
|
--button-font-color: var(--color-text-light);
|
||||||
|
}
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -25px;
|
display: flex;
|
||||||
left: -10px;
|
align-items: center;
|
||||||
width: calc(var(--node-width) + 20px);
|
justify-content: space-between;
|
||||||
height: 26px;
|
gap: var(--spacing-2xs);
|
||||||
font-size: 0.9em;
|
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;
|
z-index: 10;
|
||||||
color: #aaa;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
width: 28px;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
&.touch {
|
&.touch {
|
||||||
|
@ -885,8 +868,7 @@ export default defineComponent({
|
||||||
|
|
||||||
.execute-icon {
|
.execute-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 2px;
|
font-size: var(----font-size-xl);
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import type { AddedNodesAndConnections, ToggleNodeCreatorOptions } from '@/Interface';
|
import type { AddedNodesAndConnections, ToggleNodeCreatorOptions } from '@/Interface';
|
||||||
import { useActions } from './NodeCreator/composables/useActions';
|
import { useActions } from './NodeCreator/composables/useActions';
|
||||||
|
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
nodeViewScale: number;
|
nodeViewScale: number;
|
||||||
|
@ -105,24 +106,31 @@ function nodeTypeSelected(nodeTypes: string[]) {
|
||||||
@mouseenter="onCreateMenuHoverIn"
|
@mouseenter="onCreateMenuHoverIn"
|
||||||
>
|
>
|
||||||
<div :class="$style.nodeCreatorButton" data-test-id="node-creator-plus-button">
|
<div :class="$style.nodeCreatorButton" data-test-id="node-creator-plus-button">
|
||||||
<n8n-icon-button
|
<keyboard-shortcut-tooltip
|
||||||
size="xlarge"
|
:label="$locale.baseText('nodeView.openNodesPanel')"
|
||||||
icon="plus"
|
:shortcut="{ keys: ['Tab'] }"
|
||||||
type="tertiary"
|
placement="left"
|
||||||
:class="$style.nodeCreatorPlus"
|
>
|
||||||
@click="openNodeCreator"
|
<n8n-icon-button
|
||||||
:title="$locale.baseText('nodeView.addNode')"
|
size="xlarge"
|
||||||
/>
|
icon="plus"
|
||||||
|
type="tertiary"
|
||||||
|
:class="$style.nodeCreatorPlus"
|
||||||
|
@click="openNodeCreator"
|
||||||
|
/>
|
||||||
|
</keyboard-shortcut-tooltip>
|
||||||
<div
|
<div
|
||||||
:class="[$style.addStickyButton, state.showStickyButton ? $style.visibleButton : '']"
|
:class="[$style.addStickyButton, state.showStickyButton ? $style.visibleButton : '']"
|
||||||
@click="addStickyNote"
|
@click="addStickyNote"
|
||||||
data-test-id="add-sticky-button"
|
data-test-id="add-sticky-button"
|
||||||
>
|
>
|
||||||
<n8n-icon-button
|
<keyboard-shortcut-tooltip
|
||||||
type="tertiary"
|
:label="$locale.baseText('nodeView.addStickyHint')"
|
||||||
:icon="['far', 'note-sticky']"
|
:shortcut="{ keys: ['s'], shiftKey: true }"
|
||||||
:title="$locale.baseText('nodeView.addSticky')"
|
placement="left"
|
||||||
/>
|
>
|
||||||
|
<n8n-icon-button type="tertiary" :icon="['far', 'note-sticky']" />
|
||||||
|
</keyboard-shortcut-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -170,7 +170,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import useDeviceSupport from '@/composables/useDeviceSupport';
|
import { useDeviceSupport } from 'n8n-design-system';
|
||||||
import { useMessage } from '@/composables';
|
import { useMessage } from '@/composables';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
|
|
@ -1,22 +1,32 @@
|
||||||
<template>
|
<template>
|
||||||
<span :class="$style.container" data-test-id="save-button">
|
<span :class="$style.container" data-test-id="save-button">
|
||||||
<span :class="$style.saved" v-if="saved">{{ $locale.baseText('saveButton.saved') }}</span>
|
<span :class="$style.saved" v-if="saved">{{ $locale.baseText('saveButton.saved') }}</span>
|
||||||
<n8n-button
|
<keyboard-shortcut-tooltip
|
||||||
|
:label="$locale.baseText('saveButton.hint')"
|
||||||
|
:shortcut="{ keys: ['s'], metaKey: true }"
|
||||||
|
placement="bottom"
|
||||||
v-else
|
v-else
|
||||||
:label="saveButtonLabel"
|
>
|
||||||
:loading="isSaving"
|
<n8n-button
|
||||||
:disabled="disabled"
|
:label="saveButtonLabel"
|
||||||
:class="$style.button"
|
:loading="isSaving"
|
||||||
:type="type"
|
:disabled="disabled"
|
||||||
/>
|
:class="$style.button"
|
||||||
|
:type="type"
|
||||||
|
/>
|
||||||
|
</keyboard-shortcut-tooltip>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'SaveButton',
|
name: 'SaveButton',
|
||||||
|
components: {
|
||||||
|
KeyboardShortcutTooltip,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
saved: {
|
saved: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
<div
|
<div
|
||||||
class="sticky-box"
|
class="sticky-box"
|
||||||
@click.left="mouseLeftClick"
|
@click.left="mouseLeftClick"
|
||||||
|
@contextmenu="onContextMenu"
|
||||||
v-touch:start="touchStart"
|
v-touch:start="touchStart"
|
||||||
v-touch:end="touchEnd"
|
v-touch:end="touchEnd"
|
||||||
>
|
>
|
||||||
|
@ -120,11 +121,15 @@ import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { useContextMenu } from '@/composables';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Sticky',
|
name: 'Sticky',
|
||||||
mixins: [externalHooks, nodeBase, nodeHelpers, workflowHelpers],
|
mixins: [externalHooks, nodeBase, nodeHelpers, workflowHelpers],
|
||||||
|
setup() {
|
||||||
|
const contextMenu = useContextMenu();
|
||||||
|
return { contextMenu };
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
nodeViewScale: {
|
nodeViewScale: {
|
||||||
type: Number,
|
type: Number,
|
||||||
|
@ -310,6 +315,11 @@ export default defineComponent({
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onContextMenu(e: MouseEvent): void {
|
||||||
|
if (this.node) {
|
||||||
|
this.contextMenu.open(e, { source: 'node-right-click', node: this.node });
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
|
@ -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> = {}): 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]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,7 +1,6 @@
|
||||||
export { default as useCanvasMouseSelect } from './useCanvasMouseSelect';
|
export { default as useCanvasMouseSelect } from './useCanvasMouseSelect';
|
||||||
export * from './useCopyToClipboard';
|
export * from './useCopyToClipboard';
|
||||||
export * from './useDebounce';
|
export * from './useDebounce';
|
||||||
export { default as useDeviceSupport } from './useDeviceSupport';
|
|
||||||
export * from './useExternalHooks';
|
export * from './useExternalHooks';
|
||||||
export * from './useExternalSecretsProvider';
|
export * from './useExternalSecretsProvider';
|
||||||
export { default as useGlobalLinkActions } from './useGlobalLinkActions';
|
export { default as useGlobalLinkActions } from './useGlobalLinkActions';
|
||||||
|
@ -15,3 +14,4 @@ export * from './useToast';
|
||||||
export * from './useNodeSpecificationValues';
|
export * from './useNodeSpecificationValues';
|
||||||
export * from './useDataSchema';
|
export * from './useDataSchema';
|
||||||
export * from './useExecutionDebugging';
|
export * from './useExecutionDebugging';
|
||||||
|
export * from './useContextMenu';
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import type { INodeUi, XYPosition } from '@/Interface';
|
import type { INodeUi, XYPosition } from '@/Interface';
|
||||||
|
|
||||||
import useDeviceSupport from './useDeviceSupport';
|
import { useDeviceSupport } from 'n8n-design-system';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils';
|
import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils';
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useContextMenu } from './useContextMenu';
|
||||||
|
|
||||||
interface ExtendedHTMLSpanElement extends HTMLSpanElement {
|
interface ExtendedHTMLSpanElement extends HTMLSpanElement {
|
||||||
x: number;
|
x: number;
|
||||||
|
@ -20,6 +21,7 @@ export default function useCanvasMouseSelect() {
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const { isOpen: isContextMenuOpen } = useContextMenu();
|
||||||
|
|
||||||
function _setSelectBoxStyle(styles: Record<string, string>) {
|
function _setSelectBoxStyle(styles: Record<string, string>) {
|
||||||
Object.assign(selectBox.value.style, styles);
|
Object.assign(selectBox.value.style, styles);
|
||||||
|
@ -127,6 +129,9 @@ export default function useCanvasMouseSelect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mouseUpMouseSelect(e: MouseEvent) {
|
function mouseUpMouseSelect(e: MouseEvent) {
|
||||||
|
// Ignore right-click
|
||||||
|
if (e.button === 2 || isContextMenuOpen.value) return;
|
||||||
|
|
||||||
if (!selectActive.value) {
|
if (!selectActive.value) {
|
||||||
if (isTouchDevice && e.target instanceof HTMLElement) {
|
if (isTouchDevice && e.target instanceof HTMLElement) {
|
||||||
if (e.target && e.target.id.includes('node-view')) {
|
if (e.target && e.target.id.includes('node-view')) {
|
||||||
|
@ -156,7 +161,7 @@ export default function useCanvasMouseSelect() {
|
||||||
_hideSelectBox();
|
_hideSelectBox();
|
||||||
}
|
}
|
||||||
function mouseDownMouseSelect(e: MouseEvent, moveButtonPressed: boolean) {
|
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.
|
// We only care about it when the ctrl key is not pressed at the same time.
|
||||||
// So we exit when it is pressed.
|
// So we exit when it is pressed.
|
||||||
return;
|
return;
|
||||||
|
|
242
packages/editor-ui/src/composables/useContextMenu.ts
Normal file
242
packages/editor-ui/src/composables/useContextMenu.ts
Normal file
|
@ -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<XYPosition>([0, 0]);
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const target = ref<ContextMenuTarget>({ source: 'canvas' });
|
||||||
|
const actions = ref<IActionDropdownItem[]>([]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -7,7 +7,7 @@ import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
import { ref, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue';
|
import { ref, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue';
|
||||||
import { useDebounceHelper } from './useDebounce';
|
import { useDebounceHelper } from './useDebounce';
|
||||||
import useDeviceSupportHelpers from './useDeviceSupport';
|
import { useDeviceSupport } from 'n8n-design-system';
|
||||||
import { getNodeViewTab } from '@/utils';
|
import { getNodeViewTab } from '@/utils';
|
||||||
import type { Route } from 'vue-router';
|
import type { Route } from 'vue-router';
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ export function useHistoryHelper(activeRoute: Route) {
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
const { callDebounced } = useDebounceHelper();
|
const { callDebounced } = useDebounceHelper();
|
||||||
const { isCtrlKeyPressed } = useDeviceSupportHelpers();
|
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||||
|
|
||||||
const isNDVOpen = ref<boolean>(ndvStore.activeNodeName !== null);
|
const isNDVOpen = ref<boolean>(ndvStore.activeNodeName !== null);
|
||||||
|
|
||||||
|
|
|
@ -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 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];
|
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',
|
TAB: 'tab',
|
||||||
NODE_CONNECTION_ACTION: 'node_connection_action',
|
NODE_CONNECTION_ACTION: 'node_connection_action',
|
||||||
NODE_CONNECTION_DROP: 'node_connection_drop',
|
NODE_CONNECTION_DROP: 'node_connection_drop',
|
||||||
|
CONTEXT_MENU: 'context_menu',
|
||||||
'': '',
|
'': '',
|
||||||
};
|
};
|
||||||
export const CORE_NODES_CATEGORY = 'Core Nodes';
|
export const CORE_NODES_CATEGORY = 'Core Nodes';
|
||||||
|
|
|
@ -19,9 +19,11 @@ export type PinDataSource =
|
||||||
| 'save-edit'
|
| 'save-edit'
|
||||||
| 'on-ndv-close-modal'
|
| 'on-ndv-close-modal'
|
||||||
| 'duplicate-node'
|
| '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({
|
export const pinData = defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
|
|
|
@ -820,12 +820,10 @@
|
||||||
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
|
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
|
||||||
"node.thisIsATriggerNode": "This is a Trigger node. <a target=\"_blank\" href=\"https://docs.n8n.io/workflows/components/nodes/\">Learn more</a>",
|
"node.thisIsATriggerNode": "This is a Trigger node. <a target=\"_blank\" href=\"https://docs.n8n.io/workflows/components/nodes/\">Learn more</a>",
|
||||||
"node.activateDeactivateNode": "Activate/Deactivate Node",
|
"node.activateDeactivateNode": "Activate/Deactivate Node",
|
||||||
"node.deleteNode": "Delete Node",
|
|
||||||
"node.changeColor": "Change Color",
|
"node.changeColor": "Change Color",
|
||||||
"node.disabled": "Disabled",
|
"node.disabled": "Disabled",
|
||||||
"node.duplicateNode": "Duplicate Node",
|
"node.executeNode": "Execute node",
|
||||||
"node.editNode": "Edit Node",
|
"node.deleteNode": "Delete node",
|
||||||
"node.executeNode": "Execute Node",
|
|
||||||
"node.issues": "Issues",
|
"node.issues": "Issues",
|
||||||
"node.nodeIsExecuting": "Node is executing",
|
"node.nodeIsExecuting": "Node is executing",
|
||||||
"node.nodeIsWaitingTill": "Node is waiting until {date} {time}",
|
"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.description": "How long to wait between each attempt (in milliseconds)",
|
||||||
"nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)",
|
"nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)",
|
||||||
"nodeSettings.hasForeignCredential": "To edit this node, either:<br/>a) Ask {owner} to share the credential with you, or<br/>b) Duplicate the node and add your own credential",
|
"nodeSettings.hasForeignCredential": "To edit this node, either:<br/>a) Ask {owner} to share the credential with you, or<br/>b) Duplicate the node and add your own credential",
|
||||||
"nodeView.addNode": "Add node",
|
"nodeView.openNodesPanel": "Open nodes panel",
|
||||||
"nodeView.addATriggerNodeFirst": "Add a <a data-action='showNodeCreator'>Trigger Node</a> first",
|
"nodeView.addATriggerNodeFirst": "Add a <a data-action='showNodeCreator'>Trigger Node</a> first",
|
||||||
"nodeView.addOrEnableTriggerNode": "<a data-action='showNodeCreator'>Add</a> or enable a Trigger node to execute the workflow",
|
"nodeView.addOrEnableTriggerNode": "<a data-action='showNodeCreator'>Add</a> or enable a Trigger node to execute the workflow",
|
||||||
"nodeView.addSticky": "Click to add sticky note",
|
"nodeView.addSticky": "Click to add sticky note",
|
||||||
|
"nodeView.addStickyHint": "Add sticky note",
|
||||||
"nodeView.cantExecuteNoTrigger": "Cannot execute workflow",
|
"nodeView.cantExecuteNoTrigger": "Cannot execute workflow",
|
||||||
"nodeView.canvasAddButton.addATriggerNodeBeforeExecuting": "Add a Trigger Node before executing the workflow",
|
"nodeView.canvasAddButton.addATriggerNodeBeforeExecuting": "Add a Trigger Node before executing the workflow",
|
||||||
"nodeView.canvasAddButton.addFirstStep": "Add first step…",
|
"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.confirmMessage.debug.message": "Loading this execution will unpin the data currently pinned in these nodes",
|
||||||
"nodeView.couldntImportWorkflow": "Could not import workflow",
|
"nodeView.couldntImportWorkflow": "Could not import workflow",
|
||||||
"nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data",
|
"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.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.",
|
||||||
"nodeView.loadingTemplate": "Loading template",
|
"nodeView.loadingTemplate": "Loading template",
|
||||||
"nodeView.moreInfo": "More info",
|
"nodeView.moreInfo": "More info",
|
||||||
|
@ -1069,6 +1067,23 @@
|
||||||
"nodeView.zoomOut": "Zoom Out",
|
"nodeView.zoomOut": "Zoom Out",
|
||||||
"nodeView.zoomToFit": "Zoom to Fit",
|
"nodeView.zoomToFit": "Zoom to Fit",
|
||||||
"nodeView.replaceMe": "Replace Me",
|
"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": "Click to copy webhook URLs",
|
||||||
"nodeWebhooks.clickToCopyWebhookUrls.formTrigger": "Click to copy Form URL",
|
"nodeWebhooks.clickToCopyWebhookUrls.formTrigger": "Click to copy Form URL",
|
||||||
"nodeWebhooks.clickToDisplayWebhookUrls": "Click to display webhook URLs",
|
"nodeWebhooks.clickToDisplayWebhookUrls": "Click to display webhook URLs",
|
||||||
|
@ -1317,6 +1332,7 @@
|
||||||
"runData.aiContentBlock.tokens.completion": "Completion:",
|
"runData.aiContentBlock.tokens.completion": "Completion:",
|
||||||
"saveButton.save": "@:_reusableBaseText.save",
|
"saveButton.save": "@:_reusableBaseText.save",
|
||||||
"saveButton.saved": "Saved",
|
"saveButton.saved": "Saved",
|
||||||
|
"saveButton.hint": "Save workflow",
|
||||||
"saveButton.saving": "Saving",
|
"saveButton.saving": "Saving",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"settings.communityNodes": "Community nodes",
|
"settings.communityNodes": "Community nodes",
|
||||||
|
|
|
@ -548,7 +548,10 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addSelectedNode(node: INodeUi): void {
|
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 {
|
removeNodeFromSelection(node: INodeUi): void {
|
||||||
let index;
|
let index;
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
@mousedown="mouseDown"
|
@mousedown="mouseDown"
|
||||||
v-touch:tap="touchTap"
|
v-touch:tap="touchTap"
|
||||||
@mouseup="mouseUp"
|
@mouseup="mouseUp"
|
||||||
|
@contextmenu="contextMenu.open"
|
||||||
@wheel="canvasStore.wheelScroll"
|
@wheel="canvasStore.wheelScroll"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -44,11 +45,9 @@
|
||||||
/>
|
/>
|
||||||
<node
|
<node
|
||||||
v-for="nodeData in nodesToRender"
|
v-for="nodeData in nodesToRender"
|
||||||
@duplicateNode="duplicateNode"
|
|
||||||
@deselectAllNodes="deselectAllNodes"
|
@deselectAllNodes="deselectAllNodes"
|
||||||
@deselectNode="nodeDeselectedByName"
|
@deselectNode="nodeDeselectedByName"
|
||||||
@nodeSelected="nodeSelectedByName"
|
@nodeSelected="nodeSelectedByName"
|
||||||
@removeNode="(name) => removeNode(name, true)"
|
|
||||||
@runWorkflow="onRunNode"
|
@runWorkflow="onRunNode"
|
||||||
@moved="onNodeMoved"
|
@moved="onNodeMoved"
|
||||||
@run="onNodeRun"
|
@run="onNodeRun"
|
||||||
|
@ -104,23 +103,30 @@
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<CanvasControls />
|
<CanvasControls />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense>
|
||||||
|
<ContextMenu @action="onContextMenuAction" />
|
||||||
|
</Suspense>
|
||||||
<div class="workflow-execute-wrapper" v-if="!isReadOnlyRoute && !readOnlyEnv">
|
<div class="workflow-execute-wrapper" v-if="!isReadOnlyRoute && !readOnlyEnv">
|
||||||
<span
|
<span
|
||||||
@mouseenter="showTriggerMissingToltip(true)"
|
@mouseenter="showTriggerMissingToltip(true)"
|
||||||
@mouseleave="showTriggerMissingToltip(false)"
|
@mouseleave="showTriggerMissingToltip(false)"
|
||||||
@click="onRunContainerClick"
|
@click="onRunContainerClick"
|
||||||
>
|
>
|
||||||
<n8n-button
|
<keyboard-shortcut-tooltip
|
||||||
@click.stop="onRunWorkflow"
|
|
||||||
:loading="workflowRunning"
|
|
||||||
:label="runButtonText"
|
:label="runButtonText"
|
||||||
:title="$locale.baseText('nodeView.executesTheWorkflowFromATriggerNode')"
|
:shortcut="{ metaKey: true, keys: ['↵'] }"
|
||||||
size="large"
|
>
|
||||||
icon="play-circle"
|
<n8n-button
|
||||||
type="primary"
|
@click.stop="onRunWorkflow"
|
||||||
:disabled="isExecutionDisabled"
|
:loading="workflowRunning"
|
||||||
data-test-id="execute-workflow-button"
|
:label="runButtonText"
|
||||||
/>
|
size="large"
|
||||||
|
icon="play-circle"
|
||||||
|
type="primary"
|
||||||
|
:disabled="isExecutionDisabled"
|
||||||
|
data-test-id="execute-workflow-button"
|
||||||
|
/>
|
||||||
|
</keyboard-shortcut-tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<n8n-button
|
<n8n-button
|
||||||
|
@ -229,6 +235,7 @@ import { copyPaste } from '@/mixins/copyPaste';
|
||||||
import { externalHooks } from '@/mixins/externalHooks';
|
import { externalHooks } from '@/mixins/externalHooks';
|
||||||
import { genericHelpers } from '@/mixins/genericHelpers';
|
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||||
import { moveNodeWorkflow } from '@/mixins/moveNodeWorkflow';
|
import { moveNodeWorkflow } from '@/mixins/moveNodeWorkflow';
|
||||||
|
import { nodeHelpers } from '@/mixins/nodeHelpers';
|
||||||
import {
|
import {
|
||||||
useGlobalLinkActions,
|
useGlobalLinkActions,
|
||||||
useCanvasMouseSelect,
|
useCanvasMouseSelect,
|
||||||
|
@ -236,17 +243,22 @@ import {
|
||||||
useToast,
|
useToast,
|
||||||
useTitleChange,
|
useTitleChange,
|
||||||
useExecutionDebugging,
|
useExecutionDebugging,
|
||||||
|
useContextMenu,
|
||||||
|
type ContextMenuAction,
|
||||||
|
useDataSchema,
|
||||||
} from '@/composables';
|
} from '@/composables';
|
||||||
import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
|
import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||||
import { workflowRun } from '@/mixins/workflowRun';
|
import { workflowRun } from '@/mixins/workflowRun';
|
||||||
import { pinData } from '@/mixins/pinData';
|
import { type PinDataSource, pinData } from '@/mixins/pinData';
|
||||||
|
|
||||||
import NodeDetailsView from '@/components/NodeDetailsView.vue';
|
import NodeDetailsView from '@/components/NodeDetailsView.vue';
|
||||||
|
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
|
||||||
import Node from '@/components/Node.vue';
|
import Node from '@/components/Node.vue';
|
||||||
import Sticky from '@/components/Sticky.vue';
|
import Sticky from '@/components/Sticky.vue';
|
||||||
import CanvasAddButton from './CanvasAddButton.vue';
|
import CanvasAddButton from './CanvasAddButton.vue';
|
||||||
|
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type {
|
import type {
|
||||||
IConnection,
|
IConnection,
|
||||||
|
@ -368,6 +380,7 @@ export default defineComponent({
|
||||||
workflowHelpers,
|
workflowHelpers,
|
||||||
workflowRun,
|
workflowRun,
|
||||||
debounceHelper,
|
debounceHelper,
|
||||||
|
nodeHelpers,
|
||||||
pinData,
|
pinData,
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
|
@ -375,14 +388,20 @@ export default defineComponent({
|
||||||
Node,
|
Node,
|
||||||
Sticky,
|
Sticky,
|
||||||
CanvasAddButton,
|
CanvasAddButton,
|
||||||
|
KeyboardShortcutTooltip,
|
||||||
NodeCreation,
|
NodeCreation,
|
||||||
CanvasControls,
|
CanvasControls,
|
||||||
|
ContextMenu,
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
|
const contextMenu = useContextMenu();
|
||||||
|
const dataSchema = useDataSchema();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
|
contextMenu,
|
||||||
|
dataSchema,
|
||||||
...useCanvasMouseSelect(),
|
...useCanvasMouseSelect(),
|
||||||
...useGlobalLinkActions(),
|
...useGlobalLinkActions(),
|
||||||
...useTitleChange(),
|
...useTitleChange(),
|
||||||
|
@ -1079,6 +1098,8 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async keyDown(e: KeyboardEvent) {
|
async keyDown(e: KeyboardEvent) {
|
||||||
|
this.contextMenu.close();
|
||||||
|
|
||||||
if (e.key === 's' && this.isCtrlKeyPressed(e)) {
|
if (e.key === 's' && this.isCtrlKeyPressed(e)) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -1127,18 +1148,41 @@ export default defineComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'd') {
|
const selectedNodes = this.uiStore.getSelectedNodes
|
||||||
void this.callDebounced('deactivateSelectedNode', { debounceTime: 350 });
|
.map((node) => 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') {
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
void this.callDebounced('deleteSelectedNodes', { debounceTime: 500 });
|
void this.callDebounced('deleteNodes', { debounceTime: 500 }, selectedNodes);
|
||||||
} else if (e.key === 'Tab') {
|
} else if (e.key === 'Tab') {
|
||||||
this.onToggleNodeCreator({
|
this.onToggleNodeCreator({
|
||||||
source: NODE_CREATOR_OPEN_SOURCES.TAB,
|
source: NODE_CREATOR_OPEN_SOURCES.TAB,
|
||||||
createNodeActive: !this.createNodeActive && !this.isReadOnlyRoute && !this.readOnlyEnv,
|
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) {
|
} else if (e.key === this.controlKeyCode) {
|
||||||
this.ctrlKeyPressed = true;
|
this.ctrlKeyPressed = true;
|
||||||
} else if (e.key === ' ') {
|
} else if (e.key === ' ') {
|
||||||
|
@ -1159,13 +1203,13 @@ export default defineComponent({
|
||||||
|
|
||||||
void this.callDebounced('selectAllNodes', { debounceTime: 1000 });
|
void this.callDebounced('selectAllNodes', { debounceTime: 1000 });
|
||||||
} else if (e.key === 'c' && this.isCtrlKeyPressed(e)) {
|
} 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)) {
|
} else if (e.key === 'x' && this.isCtrlKeyPressed(e)) {
|
||||||
// Cut nodes
|
// Cut nodes
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
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) {
|
} else if (e.key === 'n' && this.isCtrlKeyPressed(e) && e.altKey) {
|
||||||
// Create a new workflow
|
// Create a new workflow
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -1333,23 +1377,46 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deactivateSelectedNode() {
|
toggleActivationNodes(nodes: INode[]) {
|
||||||
if (!this.editAllowedCheck()) {
|
if (!this.editAllowedCheck()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.disableNodes(this.uiStore.getSelectedNodes, true);
|
|
||||||
|
this.disableNodes(nodes, true);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSelectedNodes() {
|
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();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteNodes(nodes: INode[]) {
|
||||||
// Copy "selectedNodes" as the nodes get deleted out of selection
|
// Copy "selectedNodes" as the nodes get deleted out of selection
|
||||||
// when they get deleted and if we would use original it would mess
|
// when they get deleted and if we would use original it would mess
|
||||||
// with the index and would so not delete all nodes
|
// 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();
|
this.historyStore.startRecordingUndo();
|
||||||
nodesToDelete.forEach((nodeName: string) => {
|
nodes.forEach((node) => {
|
||||||
this.removeNode(nodeName, true, false);
|
this.removeNode(node.name, true, false);
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.historyStore.stopRecordingUndo();
|
this.historyStore.stopRecordingUndo();
|
||||||
|
@ -1437,16 +1504,16 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
cutSelectedNodes() {
|
cutNodes(nodes: INode[]) {
|
||||||
const deleteCopiedNodes = !this.isReadOnlyRoute && !this.readOnlyEnv;
|
const deleteCopiedNodes = !this.isReadOnlyRoute && !this.readOnlyEnv;
|
||||||
this.copySelectedNodes(deleteCopiedNodes);
|
this.copyNodes(nodes, deleteCopiedNodes);
|
||||||
if (deleteCopiedNodes) {
|
if (deleteCopiedNodes) {
|
||||||
this.deleteSelectedNodes();
|
this.deleteNodes(nodes);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
copySelectedNodes(isCut: boolean) {
|
copyNodes(nodes: INode[], isCut = false) {
|
||||||
void this.getSelectedNodesToSave().then((data) => {
|
void this.getNodesToSave(nodes).then((data) => {
|
||||||
const workflowToCopy: IWorkflowToShare = {
|
const workflowToCopy: IWorkflowToShare = {
|
||||||
meta: {
|
meta: {
|
||||||
instanceId: this.rootStore.instanceId,
|
instanceId: this.rootStore.instanceId,
|
||||||
|
@ -1708,6 +1775,11 @@ export default defineComponent({
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
workflow_id: this.workflowsStore.workflowId,
|
||||||
node_graph_string: nodeGraph,
|
node_graph_string: nodeGraph,
|
||||||
});
|
});
|
||||||
|
} else if (source === 'duplicate') {
|
||||||
|
this.$telemetry.track('User duplicated nodes', {
|
||||||
|
workflow_id: this.workflowsStore.workflowId,
|
||||||
|
node_graph_string: nodeGraph,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
this.$telemetry.track('User imported workflow', {
|
this.$telemetry.track('User imported workflow', {
|
||||||
source,
|
source,
|
||||||
|
@ -3211,84 +3283,13 @@ export default defineComponent({
|
||||||
this.workflowsStore.removeConnection({ connection: connectionInfo });
|
this.workflowsStore.removeConnection({ connection: connectionInfo });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async duplicateNode(nodeName: string) {
|
async duplicateNodes(nodes: INode[]): Promise<void> {
|
||||||
if (!this.editAllowedCheck()) {
|
if (!this.editAllowedCheck()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const node = this.workflowsStore.getNodeByName(nodeName);
|
|
||||||
|
|
||||||
if (node) {
|
const workflowData = deepCopy(await this.getNodesToSave(nodes));
|
||||||
const nodeTypeData = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
await this.importWorkflowData(workflowData, 'duplicate', false);
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
getJSPlumbConnection(
|
getJSPlumbConnection(
|
||||||
sourceNodeName: string,
|
sourceNodeName: string,
|
||||||
|
@ -4036,21 +4037,43 @@ export default defineComponent({
|
||||||
connections: tempWorkflow.connectionsBySourceNode,
|
connections: tempWorkflow.connectionsBySourceNode,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async getSelectedNodesToSave(): Promise<IWorkflowData> {
|
async getNodesToSave(nodes: INode[]): Promise<IWorkflowData> {
|
||||||
const data: IWorkflowData = {
|
const data: IWorkflowData = {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
connections: {},
|
connections: {},
|
||||||
|
pinData: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get data of all the selected noes
|
// Get data of all the selected noes
|
||||||
let nodeData;
|
let nodeData;
|
||||||
const exportNodeNames: string[] = [];
|
const exportNodeNames: string[] = [];
|
||||||
|
|
||||||
for (const node of this.uiStore.getSelectedNodes) {
|
for (const node of nodes) {
|
||||||
nodeData = this.getNodeDataToSave(node);
|
nodeData = this.getNodeDataToSave(node);
|
||||||
exportNodeNames.push(node.name);
|
exportNodeNames.push(node.name);
|
||||||
|
|
||||||
data.nodes.push(nodeData);
|
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
|
// 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() {
|
async onSourceControlPull() {
|
||||||
let workflowId = null as string | null;
|
let workflowId = null as string | null;
|
||||||
|
|
Loading…
Reference in a new issue