feat(editor): Add node context menu (#7620)

![image](https://github.com/n8n-io/n8n/assets/8850410/5a601fae-cb8e-41bb-beca-ac9ab7065b75)
This commit is contained in:
Elias Meire
2023-11-20 14:37:12 +01:00
committed by GitHub
parent 4dbae0e2e9
commit 8d12c1ad8d
46 changed files with 1612 additions and 373 deletions

View File

@@ -65,13 +65,10 @@ describe('Undo/Redo', () => {
.should('have.css', 'top', '220px');
});
it('should undo/redo deleting node using delete button', () => {
it('should undo/redo deleting node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodeByName(CODE_NODE_NAME)
.find('[data-test-id=delete-node-button]')
.click({ force: true });
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitUndo();
@@ -151,7 +148,7 @@ describe('Undo/Redo', () => {
.should('have.css', 'top', '320px');
});
it('should undo/redo deleting a connection by pressing delete button', () => {
it('should undo/redo deleting a connection using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().realHover();
@@ -177,14 +174,10 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should undo/redo disabling a node using disable button', () => {
it('should undo/redo disabling a node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodes()
.last()
.find('[data-test-id="disable-node-button"]')
.click({ force: true });
WorkflowPage.actions.disableNode(CODE_NODE_NAME);
WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.disabledNodes().should('have.length', 0);
@@ -252,11 +245,7 @@ describe('Undo/Redo', () => {
it('should undo/redo duplicating a node', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodes()
.last()
.find('[data-test-id="duplicate-node-button"]')
.click({ force: true });
WorkflowPage.actions.duplicateNode(CODE_NODE_NAME);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.actions.hitRedo();

View File

@@ -134,7 +134,7 @@ describe('Canvas Actions', () => {
.canvasNodes()
.last()
.should('have.css', 'left', '860px')
.should('have.css', 'top', '220px')
.should('have.css', 'top', '220px');
});
it('should delete connections by pressing the delete button', () => {
@@ -163,21 +163,29 @@ describe('Canvas Actions', () => {
.find('[data-test-id="execute-node-button"]')
.click({ force: true });
WorkflowPage.getters.successToast().should('contain', 'Node executed successfully');
WorkflowPage.actions.executeNode(CODE_NODE_NAME);
WorkflowPage.getters.successToast().should('contain', 'Node executed successfully');
});
it('should copy selected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.selectAll();
WorkflowPage.actions.hitCopy();
WorkflowPage.getters.successToast().should('contain', 'Copied!');
WorkflowPage.actions.copyNode(CODE_NODE_NAME);
WorkflowPage.getters.successToast().should('contain', 'Copied!');
});
it('should select all nodes', () => {
it('should select/deselect all nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.selectAll();
WorkflowPage.getters.selectedNodes().should('have.length', 2);
WorkflowPage.actions.deselectAll();
WorkflowPage.getters.selectedNodes().should('have.length', 0);
});
it('should select nodes using arrow keys', () => {
@@ -205,22 +213,21 @@ describe('Canvas Actions', () => {
WorkflowPage.getters
.canvasNodes()
.last()
.findChildByTestId('disable-node-button').as('disableNodeButton');
cy.drag('@disableNodeButton', [200, 200]);
.findChildByTestId('execute-node-button')
.as('executeNodeButton');
cy.drag('@executeNodeButton', [200, 200]);
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
});
it('should not break lasso selection with multiple clicks on node action buttons', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
WorkflowPage.getters
.canvasNodes()
.last().as('lastNode');
cy.get('@lastNode').findChildByTestId('disable-node-button').as('disableNodeButton');
WorkflowPage.getters.canvasNodes().last().as('lastNode');
cy.get('@lastNode').findChildByTestId('execute-node-button').as('executeNodeButton');
for (let i = 0; i < 20; i++) {
cy.get('@lastNode').realHover();
cy.get('@disableNodeButton').should('be.visible');
cy.get('@disableNodeButton').realTouch();
cy.get('@executeNodeButton').should('be.visible');
cy.get('@executeNodeButton').realTouch();
cy.getByTestId('execute-workflow-button').realHover();
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
}

View File

@@ -22,6 +22,7 @@ const ZOOM_OUT_X2_FACTOR = 0.64;
const PINCH_ZOOM_IN_FACTOR = 1.05702;
const PINCH_ZOOM_OUT_FACTOR = 0.946058;
const RENAME_NODE_NAME = 'Something else';
const RENAME_NODE_NAME2 = 'Something different';
describe('Canvas Node Manipulation and Navigation', () => {
beforeEach(() => {
@@ -129,13 +130,10 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.get('.jtk-connector').should('have.length', 4);
});
it('should delete node using node action button', () => {
it('should delete node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodeByName(CODE_NODE_NAME)
.find('[data-test-id=delete-node-button]')
.click({ force: true });
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
@@ -162,13 +160,38 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 1);
});
it('should delete multiple nodes', () => {
it('should delete multiple nodes (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.selectAll();
cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.length', 0);
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.selectAllFromContextMenu();
WorkflowPage.actions.openContextMenu();
WorkflowPage.actions.contextMenuAction('delete');
WorkflowPage.getters.canvasNodes().should('have.length', 0);
});
it('should delete multiple nodes (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.selectAll();
cy.get('body').type('{backspace}');
WorkflowPage.getters.canvasNodes().should('have.length', 0);
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.selectAllFromContextMenu();
WorkflowPage.actions.openContextMenu();
WorkflowPage.actions.contextMenuAction('delete');
WorkflowPage.getters.canvasNodes().should('have.length', 0);
});
it('should move node', () => {
@@ -272,39 +295,42 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.canvasNodes().last().should('be.visible');
});
it('should disable node by pressing the disable button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodes()
.last()
.find('[data-test-id="disable-node-button"]')
.click({ force: true });
WorkflowPage.getters.disabledNodes().should('have.length', 1);
});
it('should disable node using keyboard shortcut', () => {
it('should disable node (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.disableNode(CODE_NODE_NAME);
WorkflowPage.getters.disabledNodes().should('have.length', 0);
});
it('should disable multiple nodes', () => {
it('should disable multiple nodes (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.get('body').type('{esc}');
cy.get('body').type('{esc}');
WorkflowPage.actions.selectAll();
// Keyboard shortcut
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 2);
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 0);
// Context menu
WorkflowPage.actions.openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 2);
WorkflowPage.actions.openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 0);
});
it('should rename node using keyboard shortcut', () => {
it('should rename node (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().last().click();
@@ -313,19 +339,25 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.get('body').type(RENAME_NODE_NAME);
cy.get('body').type('{enter}');
WorkflowPage.getters.canvasNodeByName(RENAME_NODE_NAME).should('exist');
WorkflowPage.actions.renameNode(RENAME_NODE_NAME);
cy.get('.rename-prompt').should('be.visible');
cy.get('body').type(RENAME_NODE_NAME2);
cy.get('body').type('{enter}');
WorkflowPage.getters.canvasNodeByName(RENAME_NODE_NAME2).should('exist');
});
it('should duplicate node', () => {
it('should duplicate nodes (context menu or shortcut)', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodes()
.last()
.find('[data-test-id="duplicate-node-button"]')
.click({ force: true });
WorkflowPage.actions.duplicateNode(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.selectAll();
WorkflowPage.actions.hitDuplicateNodeShortcut();
WorkflowPage.getters.canvasNodes().should('have.length', 5);
});
// ADO-1240: Connections would get deleted after activating and deactivating NodeView
@@ -365,7 +397,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.actions.openNode('n8n');
WorkflowPage.actions.openNodeFromContextMenu('n8n');
cy.get('[class*=hasIssues]').should('have.length', 1);
NDVDialog.actions.close();
});
@@ -392,15 +424,9 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.executeWorkflow();
cy.contains('Unrecognized node type').should('be.visible');
WorkflowPage.getters
.canvasNodeByName(`${unknownNodeName} 1`)
.find('[data-test-id=delete-node-button]')
.click({ force: true });
WorkflowPage.getters
.canvasNodeByName(`${unknownNodeName} 2`)
.find('[data-test-id=delete-node-button]')
.click({ force: true });
WorkflowPage.actions.deselectAll();
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 1`);
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 2`);
WorkflowPage.actions.executeWorkflow();

View File

@@ -70,7 +70,7 @@ describe('Data pinning', () => {
it('Should be duplicating pin data when duplicating node', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
workflowPage.actions.addNodeToCanvas('Edit Fields', true, true);
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
ndv.getters.container().should('be.visible');
ndv.getters.pinDataButton().should('not.exist');
ndv.getters.editPinnedDataButton().should('be.visible');
@@ -78,7 +78,7 @@ describe('Data pinning', () => {
ndv.actions.setPinnedData([{ test: 1 }]);
ndv.actions.close();
workflowPage.actions.duplicateNode(workflowPage.getters.canvasNodes().last());
workflowPage.actions.duplicateNode(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.saveWorkflowOnButtonClick();
@@ -88,9 +88,37 @@ describe('Data pinning', () => {
ndv.getters.outputTbodyCell(1, 0).should('include.text', 1);
});
it('Should be able to pin data from canvas (context menu or shortcut)', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.openContextMenu(EDIT_FIELDS_SET_NODE_NAME, 'overflow-button');
workflowPage.getters
.contextMenuAction('toggle_pin')
.parent()
.should('have.class', 'is-disabled');
// Unpin using context menu
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.actions.setPinnedData([{ test: 1 }]);
ndv.actions.close();
workflowPage.actions.pinNode(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.getters.nodeOutputHint().should('exist');
ndv.actions.close();
// Unpin using shortcut
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.actions.setPinnedData([{ test: 1 }]);
ndv.actions.close();
workflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click();
workflowPage.actions.hitPinNodeShortcut();
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.getters.nodeOutputHint().should('exist');
});
it('Should show an error when maximum pin data size is exceeded', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
workflowPage.actions.addNodeToCanvas('Edit Fields', true, true);
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
ndv.getters.container().should('be.visible');
ndv.getters.pinDataButton().should('not.exist');
ndv.getters.editPinnedDataButton().should('be.visible');

View File

@@ -31,6 +31,11 @@ describe('Canvas Actions', () => {
workflowPage.getters.addStickyButton().should('not.be.visible');
addDefaultSticky();
workflowPage.actions.deselectAll();
workflowPage.actions.addStickyFromContextMenu();
workflowPage.actions.hitAddStickyShortcut();
workflowPage.getters.stickies().should('have.length', 3);
workflowPage.getters
.stickies()
.eq(0)

View File

@@ -24,6 +24,7 @@ export class NDV extends BasePage {
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller'),
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
nodeOutputHint: () => cy.getByTestId('ndv-output-run-node-hint'),
savePinnedDataButton: () =>
this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'),
outputTableRows: () => this.getters.outputDataContainer().find('table tr'),

View File

@@ -127,6 +127,7 @@ export class WorkflowPage extends BasePage {
editorTabButton: () => cy.getByTestId('radio-button-workflow'),
workflowHistoryButton: () => cy.getByTestId('workflow-history-button'),
colors: () => cy.getByTestId('color'),
contextMenuAction: (action: string) => cy.getByTestId(`context-menu-item-${action}`),
};
actions = {
visit: (preventNodeViewUnload = true) => {
@@ -185,11 +186,70 @@ export class WorkflowPage extends BasePage {
if (!preventNdvClose) cy.get('body').type('{esc}');
},
openContextMenu: (
nodeTypeName?: string,
method: 'right-click' | 'overflow-button' = 'right-click',
) => {
const target = nodeTypeName
? this.getters.canvasNodeByName(nodeTypeName)
: this.getters.nodeViewBackground();
if (method === 'right-click') {
target.rightclick(nodeTypeName ? 'center' : 'topLeft', { force: true });
} else {
target.realHover();
target.find('[data-test-id="overflow-node-button"]').click({ force: true });
}
},
openNode: (nodeTypeName: string) => {
this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
},
duplicateNode: (node: Chainable<JQuery<HTMLElement>>) => {
node.find('[data-test-id="duplicate-node-button"]').click({ force: true });
duplicateNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('duplicate');
},
deleteNodeFromContextMenu: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('delete');
},
executeNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('execute');
},
addStickyFromContextMenu: () => {
this.actions.openContextMenu();
this.actions.contextMenuAction('add_sticky');
},
renameNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('rename');
},
copyNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('copy');
},
contextMenuAction: (action: string) => {
this.getters.contextMenuAction(action).click();
},
disableNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('toggle_activation');
},
pinNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('toggle_pin');
},
openNodeFromContextMenu: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName, 'overflow-button');
this.actions.contextMenuAction('open');
},
selectAllFromContextMenu: () => {
this.actions.openContextMenu();
this.actions.contextMenuAction('select_all');
},
deselectAll: () => {
this.actions.openContextMenu();
this.actions.contextMenuAction('deselect_all');
},
openExpressionEditorModal: () => {
cy.contains('Expression').invoke('show').click();
@@ -284,7 +344,7 @@ export class WorkflowPage extends BasePage {
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('a');
},
hitDisableNodeShortcut: () => {
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('d');
cy.get('body').type('d');
},
hitCopy: () => {
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('c');
@@ -292,6 +352,18 @@ export class WorkflowPage extends BasePage {
hitPaste: () => {
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('P');
},
hitPinNodeShortcut: () => {
cy.get('body').type('p');
},
hitExecuteWorkflowShortcut: () => {
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('{enter}');
},
hitDuplicateNodeShortcut: () => {
cy.get('body').type(META_KEY, { delay: 500, release: false }).type('d');
},
hitAddStickyShortcut: () => {
cy.get('body').type('{shift}', { delay: 500, release: false }).type('S');
},
executeWorkflow: () => {
this.getters.executeWorkflowButton().click();
},