mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
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:
parent
4dbae0e2e9
commit
8d12c1ad8d
|
@ -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();
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
:trigger="trigger"
|
||||
@command="onSelect"
|
||||
:popper-class="{ [$style.shadow]: true, [$style.hideArrow]: hideArrow }"
|
||||
@visible-change="onVisibleChange"
|
||||
ref="elementDropdown"
|
||||
>
|
||||
<div :class="$style.activator" @click.stop.prevent @blur="onButtonBlur">
|
||||
<n8n-icon :icon="activatorIcon" />
|
||||
</div>
|
||||
<slot v-if="$slots.activator" name="activator" />
|
||||
<n8n-icon-button
|
||||
v-else
|
||||
@blur="onButtonBlur"
|
||||
type="tertiary"
|
||||
text
|
||||
:class="$style.activator"
|
||||
:size="activatorSize"
|
||||
:icon="activatorIcon"
|
||||
/>
|
||||
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu :class="$style.userActionsMenu">
|
||||
<el-dropdown-item
|
||||
|
@ -17,6 +27,7 @@
|
|||
:command="item.id"
|
||||
:disabled="item.disabled"
|
||||
:divided="item.divided"
|
||||
:class="$style.elementItem"
|
||||
>
|
||||
<div :class="getItemClasses(item)" :data-test-id="`${testIdPrefix}-item-${item.id}`">
|
||||
<span v-if="item.icon" :class="$style.icon">
|
||||
|
@ -25,6 +36,12 @@
|
|||
<span :class="$style.label">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<n8n-keyboard-shortcut
|
||||
v-if="item.shortcut"
|
||||
v-bind="item.shortcut"
|
||||
:class="$style.shortcut"
|
||||
>
|
||||
</n8n-keyboard-shortcut>
|
||||
</div>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
|
@ -38,6 +55,8 @@ import type { PropType } from 'vue';
|
|||
import { defineComponent } from 'vue';
|
||||
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
import { N8nKeyboardShortcut } from '../N8nKeyboardShortcut';
|
||||
import type { KeyboardShortcut } from '../../types';
|
||||
|
||||
export interface IActionDropdownItem {
|
||||
id: string;
|
||||
|
@ -45,6 +64,7 @@ export interface IActionDropdownItem {
|
|||
icon?: string;
|
||||
divided?: boolean;
|
||||
disabled?: boolean;
|
||||
shortcut?: KeyboardShortcut;
|
||||
customClass?: string;
|
||||
}
|
||||
|
||||
|
@ -61,6 +81,7 @@ export default defineComponent({
|
|||
ElDropdownMenu,
|
||||
ElDropdownItem,
|
||||
N8nIcon,
|
||||
N8nKeyboardShortcut,
|
||||
},
|
||||
data() {
|
||||
const testIdPrefix = this.$attrs['data-test-id'];
|
||||
|
@ -79,7 +100,11 @@ export default defineComponent({
|
|||
},
|
||||
activatorIcon: {
|
||||
type: String,
|
||||
default: 'ellipsis-v',
|
||||
default: 'ellipsis-h',
|
||||
},
|
||||
activatorSize: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
},
|
||||
iconSize: {
|
||||
type: String,
|
||||
|
@ -91,11 +116,16 @@ export default defineComponent({
|
|||
default: 'click',
|
||||
validator: (value: string): boolean => ['click', 'hover'].includes(value),
|
||||
},
|
||||
hideArrow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getItemClasses(item: IActionDropdownItem): Record<string, boolean> {
|
||||
return {
|
||||
[this.$style.itemContainer]: true,
|
||||
[this.$style.disabled]: item.disabled,
|
||||
[this.$style.hasCustomStyling]: item.customClass !== undefined,
|
||||
...(item.customClass !== undefined ? { [item.customClass]: true } : {}),
|
||||
};
|
||||
|
@ -103,46 +133,71 @@ export default defineComponent({
|
|||
onSelect(action: string): void {
|
||||
this.$emit('select', action);
|
||||
},
|
||||
onVisibleChange(open: boolean): void {
|
||||
this.$emit('visibleChange', open);
|
||||
},
|
||||
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
|
||||
if (elementDropdown?.handleClose && event.relatedTarget === null) {
|
||||
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>
|
||||
|
||||
<style lang="scss" module>
|
||||
.userActionsMenu {
|
||||
min-width: 160px;
|
||||
:global(.el-dropdown__list) {
|
||||
.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 {
|
||||
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 {
|
||||
background-color: var(--color-background-base);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.itemContainer {
|
||||
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 {
|
||||
|
@ -154,6 +209,10 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:global(li.is-disabled) {
|
||||
.hasCustomStyling {
|
||||
color: inherit !important;
|
||||
|
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
exports[`components > N8nActionDropdown > should render custom styling correctly 1`] = `
|
||||
"<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>"
|
||||
`;
|
||||
|
||||
exports[`components > N8nActionDropdown > should render default styling correctly 1`] = `
|
||||
"<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>"
|
||||
`;
|
||||
|
|
|
@ -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 N8nUserSelect } from './N8nUserSelect';
|
||||
export { default as N8nUsersList } from './N8nUsersList';
|
||||
export { N8nKeyboardShortcut } from './N8nKeyboardShortcut';
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from './useI18n';
|
||||
export { useDeviceSupport } from './useDeviceSupport';
|
||||
|
|
|
@ -7,7 +7,7 @@ interface DeviceSupportHelpers {
|
|||
isCtrlKeyPressed: (e: MouseEvent | KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
export default function useDeviceSupportHelpers(): DeviceSupportHelpers {
|
||||
export function useDeviceSupport(): DeviceSupportHelpers {
|
||||
const isTouchDevice = ref('ontouchstart' in window || navigator.maxTouchPoints > 0);
|
||||
const userAgent = ref(navigator.userAgent.toLowerCase());
|
||||
const isMacOs = ref(
|
|
@ -25,6 +25,10 @@
|
|||
--color-background-light: var(--prim-gray-820);
|
||||
--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
|
||||
|
||||
// Canvas
|
||||
|
@ -160,6 +164,9 @@
|
|||
--color-switch-background: var(--prim-gray-820);
|
||||
--color-switch-toggle: var(--prim-gray-40);
|
||||
|
||||
// Action Dropdown
|
||||
--color-action-dropdown-item-active-background: var(--color-background-xlight);
|
||||
|
||||
// Various
|
||||
--color-info-tint-1: var(--prim-gray-420);
|
||||
--color-info-tint-2: var(--prim-gray-740);
|
||||
|
|
|
@ -236,6 +236,8 @@
|
|||
--color-value-survey-background: var(--prim-gray-740);
|
||||
--color-value-survey-font: var(--prim-gray-0);
|
||||
|
||||
// Action Dropdown
|
||||
--color-action-dropdown-item-active-background: var(--color-background-base);
|
||||
// Switch (Activation, boolean)
|
||||
--color-switch-background: var(--prim-gray-420);
|
||||
--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-large: 8px;
|
||||
--border-radius-base: 4px;
|
||||
|
|
|
@ -45,6 +45,10 @@
|
|||
transform: scaleY(1);
|
||||
transition: var.$md-fade-transition;
|
||||
transform-origin: center top;
|
||||
|
||||
&[data-popper-placement^='top'] {
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
}
|
||||
.el-zoom-in-top-enter-from,
|
||||
.el-zoom-in-top-leave-active {
|
||||
|
|
|
@ -75,15 +75,11 @@ $focus-outline-width: 2px;
|
|||
/* Box shadow
|
||||
-------------------------- */
|
||||
/// boxShadow|1|Shadow|1
|
||||
$box-shadow-base:
|
||||
0 2px 4px rgba(0, 0, 0, 0.12),
|
||||
0 0 6px rgba(0, 0, 0, 0.04);
|
||||
$box-shadow-base: var(--box-shadow-base);
|
||||
// boxShadow|1|Shadow|1
|
||||
$box-shadow-dark:
|
||||
0 2px 4px rgba(0, 0, 0, 0.12),
|
||||
0 0 6px rgba(0, 0, 0, 0.12);
|
||||
$box-shadow-dark: var(--box-shadow-dark);
|
||||
/// 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
|
||||
-------------------------- */
|
||||
|
@ -726,13 +722,13 @@ $popover-title-font-color: var(--color-text-dark);
|
|||
/* Tooltip
|
||||
-------------------------- */
|
||||
/// color|1|Color|0
|
||||
$tooltip-fill: var(--color-text-dark);
|
||||
$tooltip-fill: var(--color-background-dark);
|
||||
/// color|1|Color|0
|
||||
$tooltip-color: $color-white;
|
||||
/// fontSize||Font|1
|
||||
$tooltip-font-size: 12px;
|
||||
/// color||Color|0
|
||||
$tooltip-border-color: var(--color-text-dark);
|
||||
$tooltip-border-color: var(--color-background-dark);
|
||||
$tooltip-arrow-size: 6px;
|
||||
/// padding||Spacing|3
|
||||
$tooltip-padding: 10px;
|
||||
|
@ -766,8 +762,8 @@ $tree-expand-icon-color: var(--color-text-lighter);
|
|||
/* Dropdown
|
||||
-------------------------- */
|
||||
$dropdown-menu-box-shadow: $box-shadow-light;
|
||||
$dropdown-menuItem-hover-fill: var(--color-background-xlight);
|
||||
$dropdown-menuItem-hover-color: $link-color;
|
||||
$dropdown-menuItem-hover-fill: var(--color-action-dropdown-item-active-background);
|
||||
$dropdown-menuItem-hover-color: var(--color-text-dark);
|
||||
|
||||
/* Badge
|
||||
-------------------------- */
|
||||
|
|
|
@ -81,7 +81,6 @@
|
|||
background-color: var.$color-white;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: var(--border-radius-base);
|
||||
box-shadow: var.$dropdown-menu-box-shadow;
|
||||
position: relative;
|
||||
list-style: none;
|
||||
|
||||
|
@ -92,7 +91,7 @@
|
|||
margin: 0;
|
||||
font-size: var.$font-size-base;
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-base);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
|
@ -117,14 +116,13 @@
|
|||
content: '';
|
||||
height: $divided-offset;
|
||||
display: block;
|
||||
margin: 0 -16px;
|
||||
background-color: var.$color-white;
|
||||
}
|
||||
}
|
||||
|
||||
@include mixins.when(disabled) {
|
||||
cursor: default;
|
||||
color: var.$font-color-disabled-base;
|
||||
color: var(--color-text-lighter);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
@ -143,7 +141,6 @@
|
|||
|
||||
&:before {
|
||||
height: $divided-offset;
|
||||
margin: 0 -17px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -163,7 +160,6 @@
|
|||
|
||||
&:before {
|
||||
height: $divided-offset;
|
||||
margin: 0 -15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -183,7 +179,6 @@
|
|||
|
||||
&:before {
|
||||
height: $divided-offset;
|
||||
margin: 0 -10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
&[data-popper-placement^='top'] .el-popper__arrow {
|
||||
bottom: -(var.$popover-arrow-size);
|
||||
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-bottom-width: 0;
|
||||
|
||||
|
@ -69,7 +69,7 @@
|
|||
&[data-popper-placement^='bottom'] .el-popper__arrow {
|
||||
top: -(var.$popover-arrow-size);
|
||||
left: 50%;
|
||||
margin-right: #{var.$tooltip-arrow-size * 0.5};
|
||||
margin: 0 #{var.$tooltip-arrow-size * 0.5};
|
||||
border-top-width: 0;
|
||||
border-bottom-color: var.$popover-border-color;
|
||||
|
||||
|
@ -84,7 +84,7 @@
|
|||
&[data-popper-placement^='right'] .el-popper__arrow {
|
||||
top: 50%;
|
||||
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-left-width: 0;
|
||||
|
||||
|
@ -99,7 +99,7 @@
|
|||
&[data-popper-placement^='left'] .el-popper__arrow {
|
||||
top: 50%;
|
||||
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-left-color: var.$popover-border-color;
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import * as locale from './locale';
|
||||
|
||||
export { useDeviceSupport } from './composables';
|
||||
export * from './components';
|
||||
export * from './plugin';
|
||||
export * from './types';
|
||||
|
|
|
@ -51,6 +51,7 @@ import {
|
|||
N8nUserInfo,
|
||||
N8nUserSelect,
|
||||
N8nUsersList,
|
||||
N8nKeyboardShortcut,
|
||||
N8nUserStack,
|
||||
} from './components';
|
||||
|
||||
|
@ -108,5 +109,6 @@ export const N8nPlugin: Plugin<{}> = {
|
|||
app.component('n8n-user-info', N8nUserInfo);
|
||||
app.component('n8n-users-list', N8nUsersList);
|
||||
app.component('n8n-user-select', N8nUserSelect);
|
||||
app.component('n8n-keyboard-shortcut', N8nKeyboardShortcut);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -5,3 +5,4 @@ export * from './i18n';
|
|||
export * from './menu';
|
||||
export * from './router';
|
||||
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 =
|
||||
| ''
|
||||
| 'context_menu'
|
||||
| 'no_trigger_execution_tooltip'
|
||||
| 'plus_endpoint'
|
||||
| 'add_input_endpoint'
|
||||
|
|
|
@ -6,45 +6,62 @@
|
|||
[$style.demoZoomMenu]: isDemo,
|
||||
}"
|
||||
>
|
||||
<n8n-icon-button
|
||||
@click="zoomToFit"
|
||||
type="tertiary"
|
||||
size="large"
|
||||
:title="$locale.baseText('nodeView.zoomToFit')"
|
||||
icon="expand"
|
||||
data-test-id="zoom-to-fit"
|
||||
/>
|
||||
<n8n-icon-button
|
||||
@click="zoomIn"
|
||||
type="tertiary"
|
||||
size="large"
|
||||
:title="$locale.baseText('nodeView.zoomIn')"
|
||||
icon="search-plus"
|
||||
data-test-id="zoom-in-button"
|
||||
/>
|
||||
<n8n-icon-button
|
||||
@click="zoomOut"
|
||||
type="tertiary"
|
||||
size="large"
|
||||
:title="$locale.baseText('nodeView.zoomOut')"
|
||||
icon="search-minus"
|
||||
data-test-id="zoom-out-button"
|
||||
/>
|
||||
<n8n-icon-button
|
||||
v-if="nodeViewScale !== 1 && !isDemo"
|
||||
@click="resetZoom"
|
||||
type="tertiary"
|
||||
size="large"
|
||||
:title="$locale.baseText('nodeView.resetZoom')"
|
||||
icon="undo"
|
||||
data-test-id="reset-zoom-button"
|
||||
/>
|
||||
<keyboard-shortcut-tooltip
|
||||
:label="$locale.baseText('nodeView.zoomToFit')"
|
||||
:shortcut="{ keys: ['1'] }"
|
||||
>
|
||||
<n8n-icon-button
|
||||
@click="zoomToFit"
|
||||
type="tertiary"
|
||||
size="large"
|
||||
icon="expand"
|
||||
data-test-id="zoom-to-fit"
|
||||
/>
|
||||
</keyboard-shortcut-tooltip>
|
||||
<keyboard-shortcut-tooltip
|
||||
:label="$locale.baseText('nodeView.zoomIn')"
|
||||
:shortcut="{ keys: ['+'] }"
|
||||
>
|
||||
<n8n-icon-button
|
||||
@click="zoomIn"
|
||||
type="tertiary"
|
||||
size="large"
|
||||
icon="search-plus"
|
||||
data-test-id="zoom-in-button"
|
||||
/>
|
||||
</keyboard-shortcut-tooltip>
|
||||
<keyboard-shortcut-tooltip
|
||||
:label="$locale.baseText('nodeView.zoomOut')"
|
||||
:shortcut="{ keys: ['-'] }"
|
||||
>
|
||||
<n8n-icon-button
|
||||
@click="zoomOut"
|
||||
type="tertiary"
|
||||
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>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeMount, onBeforeUnmount } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
|
||||
const canvasStore = useCanvasStore();
|
||||
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"
|
||||
:ref="data.name"
|
||||
:data-name="data.name"
|
||||
@contextmenu="(e: MouseEvent) => openContextMenu(e, 'node-right-click')"
|
||||
>
|
||||
<div class="select-background" v-show="isSelected"></div>
|
||||
<div
|
||||
|
@ -13,6 +14,7 @@
|
|||
'node-default': true,
|
||||
'touch-active': isTouchActive,
|
||||
'is-touch-device': isTouchDevice,
|
||||
'menu-open': isContextMenuOpen,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
|
@ -35,7 +37,7 @@
|
|||
:class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }"
|
||||
>
|
||||
<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>
|
||||
<titled-list :title="`${$locale.baseText('node.issues')}:`" :items="nodeIssues" />
|
||||
</template>
|
||||
|
@ -70,6 +72,7 @@
|
|||
<div class="node-trigger-tooltip__wrapper">
|
||||
<n8n-tooltip
|
||||
placement="top"
|
||||
:show-after="500"
|
||||
:visible="showTriggerNodeTooltip"
|
||||
popper-class="node-trigger-tooltip__wrapper--item"
|
||||
>
|
||||
|
@ -102,48 +105,22 @@
|
|||
</div>
|
||||
|
||||
<div class="node-options no-select-on-click" v-if="!isReadOnly" v-show="!hideActions">
|
||||
<div
|
||||
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"
|
||||
<n8n-icon-button
|
||||
data-test-id="execute-node-button"
|
||||
>
|
||||
<font-awesome-icon class="execute-icon" icon="play-circle" />
|
||||
</div>
|
||||
type="tertiary"
|
||||
text
|
||||
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
|
||||
:class="{
|
||||
|
@ -208,9 +185,14 @@ import { useNDVStore } from '@/stores/ndv.store';
|
|||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { EnableNodeToggleCommand } from '@/models/history';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { type ContextMenuTarget, useContextMenu } from '@/composables';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Node',
|
||||
setup() {
|
||||
const contextMenu = useContextMenu();
|
||||
return { contextMenu };
|
||||
},
|
||||
mixins: [externalHooks, nodeBase, nodeHelpers, workflowHelpers, pinData, debounceHelper],
|
||||
components: {
|
||||
TitledList,
|
||||
|
@ -542,6 +524,13 @@ export default defineComponent({
|
|||
!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: {
|
||||
isActive(newValue, oldValue) {
|
||||
|
@ -667,27 +656,6 @@ export default defineComponent({
|
|||
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) {
|
||||
void this.callDebounced('onClickDebounced', { debounceTime: 50, trailing: true }, event);
|
||||
|
@ -714,11 +682,20 @@ export default defineComponent({
|
|||
}, 2000);
|
||||
}
|
||||
},
|
||||
openContextMenu(event: MouseEvent, source: ContextMenuTarget['source']) {
|
||||
if (this.data) {
|
||||
this.contextMenu.open(event, { source, node: this.data });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.node-wrapper {
|
||||
--node-width: 100px;
|
||||
/*
|
||||
|
@ -792,13 +769,11 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
&.touch-active,
|
||||
&:hover {
|
||||
.node-execute {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.menu-open {
|
||||
.node-options {
|
||||
display: initial;
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -860,19 +835,27 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.node-options {
|
||||
display: none;
|
||||
--node-options-height: 26px;
|
||||
:deep(.button) {
|
||||
--button-font-color: var(--color-text-light);
|
||||
}
|
||||
position: absolute;
|
||||
top: -25px;
|
||||
left: -10px;
|
||||
width: calc(var(--node-width) + 20px);
|
||||
height: 26px;
|
||||
font-size: 0.9em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2xs);
|
||||
transition: opacity 100ms ease-in;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
top: calc(-1 * (var(--node-options-height) + var(--spacing-4xs)));
|
||||
left: 0;
|
||||
width: var(--node-width);
|
||||
height: var(--node-options-height);
|
||||
font-size: var(--font-size-s);
|
||||
z-index: 10;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
|
||||
.option {
|
||||
width: 28px;
|
||||
display: inline-block;
|
||||
|
||||
&.touch {
|
||||
|
@ -885,8 +868,7 @@ export default defineComponent({
|
|||
|
||||
.execute-icon {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
font-size: 1.2em;
|
||||
font-size: var(----font-size-xl);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { useUIStore } from '@/stores/ui.store';
|
||||
import type { AddedNodesAndConnections, ToggleNodeCreatorOptions } from '@/Interface';
|
||||
import { useActions } from './NodeCreator/composables/useActions';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
|
||||
type Props = {
|
||||
nodeViewScale: number;
|
||||
|
@ -105,24 +106,31 @@ function nodeTypeSelected(nodeTypes: string[]) {
|
|||
@mouseenter="onCreateMenuHoverIn"
|
||||
>
|
||||
<div :class="$style.nodeCreatorButton" data-test-id="node-creator-plus-button">
|
||||
<n8n-icon-button
|
||||
size="xlarge"
|
||||
icon="plus"
|
||||
type="tertiary"
|
||||
:class="$style.nodeCreatorPlus"
|
||||
@click="openNodeCreator"
|
||||
:title="$locale.baseText('nodeView.addNode')"
|
||||
/>
|
||||
<keyboard-shortcut-tooltip
|
||||
:label="$locale.baseText('nodeView.openNodesPanel')"
|
||||
:shortcut="{ keys: ['Tab'] }"
|
||||
placement="left"
|
||||
>
|
||||
<n8n-icon-button
|
||||
size="xlarge"
|
||||
icon="plus"
|
||||
type="tertiary"
|
||||
:class="$style.nodeCreatorPlus"
|
||||
@click="openNodeCreator"
|
||||
/>
|
||||
</keyboard-shortcut-tooltip>
|
||||
<div
|
||||
:class="[$style.addStickyButton, state.showStickyButton ? $style.visibleButton : '']"
|
||||
@click="addStickyNote"
|
||||
data-test-id="add-sticky-button"
|
||||
>
|
||||
<n8n-icon-button
|
||||
type="tertiary"
|
||||
:icon="['far', 'note-sticky']"
|
||||
:title="$locale.baseText('nodeView.addSticky')"
|
||||
/>
|
||||
<keyboard-shortcut-tooltip
|
||||
:label="$locale.baseText('nodeView.addStickyHint')"
|
||||
:shortcut="{ keys: ['s'], shiftKey: true }"
|
||||
placement="left"
|
||||
>
|
||||
<n8n-icon-button type="tertiary" :icon="['far', 'note-sticky']" />
|
||||
</keyboard-shortcut-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -170,7 +170,7 @@ import { useNDVStore } from '@/stores/ndv.store';
|
|||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import useDeviceSupport from '@/composables/useDeviceSupport';
|
||||
import { useDeviceSupport } from 'n8n-design-system';
|
||||
import { useMessage } from '@/composables';
|
||||
|
||||
export default defineComponent({
|
||||
|
|
|
@ -1,22 +1,32 @@
|
|||
<template>
|
||||
<span :class="$style.container" data-test-id="save-button">
|
||||
<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
|
||||
:label="saveButtonLabel"
|
||||
:loading="isSaving"
|
||||
:disabled="disabled"
|
||||
:class="$style.button"
|
||||
:type="type"
|
||||
/>
|
||||
>
|
||||
<n8n-button
|
||||
:label="saveButtonLabel"
|
||||
:loading="isSaving"
|
||||
:disabled="disabled"
|
||||
:class="$style.button"
|
||||
:type="type"
|
||||
/>
|
||||
</keyboard-shortcut-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SaveButton',
|
||||
components: {
|
||||
KeyboardShortcutTooltip,
|
||||
},
|
||||
props: {
|
||||
saved: {
|
||||
type: Boolean,
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
<div
|
||||
class="sticky-box"
|
||||
@click.left="mouseLeftClick"
|
||||
@contextmenu="onContextMenu"
|
||||
v-touch:start="touchStart"
|
||||
v-touch:end="touchEnd"
|
||||
>
|
||||
|
@ -120,11 +121,15 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useContextMenu } from '@/composables';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Sticky',
|
||||
mixins: [externalHooks, nodeBase, nodeHelpers, workflowHelpers],
|
||||
|
||||
setup() {
|
||||
const contextMenu = useContextMenu();
|
||||
return { contextMenu };
|
||||
},
|
||||
props: {
|
||||
nodeViewScale: {
|
||||
type: Number,
|
||||
|
@ -310,6 +315,11 @@ export default defineComponent({
|
|||
}, 2000);
|
||||
}
|
||||
},
|
||||
onContextMenu(e: MouseEvent): void {
|
||||
if (this.node) {
|
||||
this.contextMenu.open(e, { source: 'node-right-click', node: this.node });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</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 * from './useCopyToClipboard';
|
||||
export * from './useDebounce';
|
||||
export { default as useDeviceSupport } from './useDeviceSupport';
|
||||
export * from './useExternalHooks';
|
||||
export * from './useExternalSecretsProvider';
|
||||
export { default as useGlobalLinkActions } from './useGlobalLinkActions';
|
||||
|
@ -15,3 +14,4 @@ export * from './useToast';
|
|||
export * from './useNodeSpecificationValues';
|
||||
export * from './useDataSchema';
|
||||
export * from './useExecutionDebugging';
|
||||
export * from './useContextMenu';
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
|
||||
import useDeviceSupport from './useDeviceSupport';
|
||||
import { useDeviceSupport } from 'n8n-design-system';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { getMousePosition, getRelativePosition } from '@/utils/nodeViewUtils';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useContextMenu } from './useContextMenu';
|
||||
|
||||
interface ExtendedHTMLSpanElement extends HTMLSpanElement {
|
||||
x: number;
|
||||
|
@ -20,6 +21,7 @@ export default function useCanvasMouseSelect() {
|
|||
const uiStore = useUIStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const { isOpen: isContextMenuOpen } = useContextMenu();
|
||||
|
||||
function _setSelectBoxStyle(styles: Record<string, string>) {
|
||||
Object.assign(selectBox.value.style, styles);
|
||||
|
@ -127,6 +129,9 @@ export default function useCanvasMouseSelect() {
|
|||
}
|
||||
|
||||
function mouseUpMouseSelect(e: MouseEvent) {
|
||||
// Ignore right-click
|
||||
if (e.button === 2 || isContextMenuOpen.value) return;
|
||||
|
||||
if (!selectActive.value) {
|
||||
if (isTouchDevice && e.target instanceof HTMLElement) {
|
||||
if (e.target && e.target.id.includes('node-view')) {
|
||||
|
@ -156,7 +161,7 @@ export default function useCanvasMouseSelect() {
|
|||
_hideSelectBox();
|
||||
}
|
||||
function mouseDownMouseSelect(e: MouseEvent, moveButtonPressed: boolean) {
|
||||
if (isCtrlKeyPressed(e) || moveButtonPressed) {
|
||||
if (isCtrlKeyPressed(e) || moveButtonPressed || e.button === 2) {
|
||||
// We only care about it when the ctrl key is not pressed at the same time.
|
||||
// So we exit when it is pressed.
|
||||
return;
|
||||
|
|
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 { useDebounceHelper } from './useDebounce';
|
||||
import useDeviceSupportHelpers from './useDeviceSupport';
|
||||
import { useDeviceSupport } from 'n8n-design-system';
|
||||
import { getNodeViewTab } from '@/utils';
|
||||
import type { Route } from 'vue-router';
|
||||
|
||||
|
@ -22,7 +22,7 @@ export function useHistoryHelper(activeRoute: Route) {
|
|||
const uiStore = useUIStore();
|
||||
|
||||
const { callDebounced } = useDebounceHelper();
|
||||
const { isCtrlKeyPressed } = useDeviceSupportHelpers();
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
|
||||
const isNDVOpen = ref<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 PIN_DATA_NODE_TYPES_DENYLIST = [SPLIT_IN_BATCHES_NODE_TYPE];
|
||||
export const PIN_DATA_NODE_TYPES_DENYLIST = [SPLIT_IN_BATCHES_NODE_TYPE, STICKY_NODE_TYPE];
|
||||
|
||||
export const OPEN_URL_PANEL_TRIGGER_NODE_TYPES = [WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE];
|
||||
|
||||
|
@ -194,6 +194,7 @@ export const NODE_CREATOR_OPEN_SOURCES: Record<
|
|||
TAB: 'tab',
|
||||
NODE_CONNECTION_ACTION: 'node_connection_action',
|
||||
NODE_CONNECTION_DROP: 'node_connection_drop',
|
||||
CONTEXT_MENU: 'context_menu',
|
||||
'': '',
|
||||
};
|
||||
export const CORE_NODES_CATEGORY = 'Core Nodes';
|
||||
|
|
|
@ -19,9 +19,11 @@ export type PinDataSource =
|
|||
| 'save-edit'
|
||||
| 'on-ndv-close-modal'
|
||||
| 'duplicate-node'
|
||||
| 'add-nodes';
|
||||
| 'add-nodes'
|
||||
| 'context-menu'
|
||||
| 'keyboard-shortcut';
|
||||
|
||||
export type UnpinDataSource = 'unpin-and-execute-modal';
|
||||
export type UnpinDataSource = 'unpin-and-execute-modal' | 'context-menu' | 'keyboard-shortcut';
|
||||
|
||||
export const pinData = defineComponent({
|
||||
setup() {
|
||||
|
|
|
@ -820,12 +820,10 @@
|
|||
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
|
||||
"node.thisIsATriggerNode": "This is a Trigger node. <a target=\"_blank\" href=\"https://docs.n8n.io/workflows/components/nodes/\">Learn more</a>",
|
||||
"node.activateDeactivateNode": "Activate/Deactivate Node",
|
||||
"node.deleteNode": "Delete Node",
|
||||
"node.changeColor": "Change Color",
|
||||
"node.disabled": "Disabled",
|
||||
"node.duplicateNode": "Duplicate Node",
|
||||
"node.editNode": "Edit Node",
|
||||
"node.executeNode": "Execute Node",
|
||||
"node.executeNode": "Execute node",
|
||||
"node.deleteNode": "Delete node",
|
||||
"node.issues": "Issues",
|
||||
"node.nodeIsExecuting": "Node is executing",
|
||||
"node.nodeIsWaitingTill": "Node is waiting until {date} {time}",
|
||||
|
@ -997,10 +995,11 @@
|
|||
"nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)",
|
||||
"nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)",
|
||||
"nodeSettings.hasForeignCredential": "To edit this node, either:<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.addOrEnableTriggerNode": "<a data-action='showNodeCreator'>Add</a> or enable a Trigger node to execute the workflow",
|
||||
"nodeView.addSticky": "Click to add sticky note",
|
||||
"nodeView.addStickyHint": "Add sticky note",
|
||||
"nodeView.cantExecuteNoTrigger": "Cannot execute workflow",
|
||||
"nodeView.canvasAddButton.addATriggerNodeBeforeExecuting": "Add a Trigger Node before executing the workflow",
|
||||
"nodeView.canvasAddButton.addFirstStep": "Add first step…",
|
||||
|
@ -1014,7 +1013,6 @@
|
|||
"nodeView.confirmMessage.debug.message": "Loading this execution will unpin the data currently pinned in these nodes",
|
||||
"nodeView.couldntImportWorkflow": "Could not import workflow",
|
||||
"nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data",
|
||||
"nodeView.executesTheWorkflowFromATriggerNode": "Runs the workflow, starting from a Trigger node",
|
||||
"nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.",
|
||||
"nodeView.loadingTemplate": "Loading template",
|
||||
"nodeView.moreInfo": "More info",
|
||||
|
@ -1069,6 +1067,23 @@
|
|||
"nodeView.zoomOut": "Zoom Out",
|
||||
"nodeView.zoomToFit": "Zoom to Fit",
|
||||
"nodeView.replaceMe": "Replace Me",
|
||||
"contextMenu.node": "node | nodes",
|
||||
"contextMenu.sticky": "sticky note | sticky notes",
|
||||
"contextMenu.selectAll": "Select all",
|
||||
"contextMenu.deselectAll": "Clear selection",
|
||||
"contextMenu.duplicate": "Duplicate {subject} | Duplicate {count} {subject}",
|
||||
"contextMenu.open": "Open node...",
|
||||
"contextMenu.execute": "Execute node",
|
||||
"contextMenu.rename": "Rename node",
|
||||
"contextMenu.copy": "Copy {subject} | Copy {count} {subject}",
|
||||
"contextMenu.deactivate": "Deactivate {subject} | Deactivate {count} {subject}",
|
||||
"contextMenu.activate": "Activate node | Activate {count} nodes",
|
||||
"contextMenu.pin": "Pin node | Pin {count} nodes",
|
||||
"contextMenu.unpin": "Unpin node | Unpin {count} nodes",
|
||||
"contextMenu.delete": "Delete {subject} | Delete {count} {subject}",
|
||||
"contextMenu.addNode": "Add node",
|
||||
"contextMenu.addSticky": "Add sticky note",
|
||||
"contextMenu.editSticky": "Edit sticky note",
|
||||
"nodeWebhooks.clickToCopyWebhookUrls": "Click to copy webhook URLs",
|
||||
"nodeWebhooks.clickToCopyWebhookUrls.formTrigger": "Click to copy Form URL",
|
||||
"nodeWebhooks.clickToDisplayWebhookUrls": "Click to display webhook URLs",
|
||||
|
@ -1317,6 +1332,7 @@
|
|||
"runData.aiContentBlock.tokens.completion": "Completion:",
|
||||
"saveButton.save": "@:_reusableBaseText.save",
|
||||
"saveButton.saved": "Saved",
|
||||
"saveButton.hint": "Save workflow",
|
||||
"saveButton.saving": "Saving",
|
||||
"settings": "Settings",
|
||||
"settings.communityNodes": "Community nodes",
|
||||
|
|
|
@ -548,7 +548,10 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
}
|
||||
},
|
||||
addSelectedNode(node: INodeUi): void {
|
||||
this.selectedNodes.push(node);
|
||||
const isAlreadySelected = this.selectedNodes.some((n) => n.name === node.name);
|
||||
if (!isAlreadySelected) {
|
||||
this.selectedNodes.push(node);
|
||||
}
|
||||
},
|
||||
removeNodeFromSelection(node: INodeUi): void {
|
||||
let index;
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
@mousedown="mouseDown"
|
||||
v-touch:tap="touchTap"
|
||||
@mouseup="mouseUp"
|
||||
@contextmenu="contextMenu.open"
|
||||
@wheel="canvasStore.wheelScroll"
|
||||
>
|
||||
<div
|
||||
|
@ -44,11 +45,9 @@
|
|||
/>
|
||||
<node
|
||||
v-for="nodeData in nodesToRender"
|
||||
@duplicateNode="duplicateNode"
|
||||
@deselectAllNodes="deselectAllNodes"
|
||||
@deselectNode="nodeDeselectedByName"
|
||||
@nodeSelected="nodeSelectedByName"
|
||||
@removeNode="(name) => removeNode(name, true)"
|
||||
@runWorkflow="onRunNode"
|
||||
@moved="onNodeMoved"
|
||||
@run="onNodeRun"
|
||||
|
@ -104,23 +103,30 @@
|
|||
<Suspense>
|
||||
<CanvasControls />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<ContextMenu @action="onContextMenuAction" />
|
||||
</Suspense>
|
||||
<div class="workflow-execute-wrapper" v-if="!isReadOnlyRoute && !readOnlyEnv">
|
||||
<span
|
||||
@mouseenter="showTriggerMissingToltip(true)"
|
||||
@mouseleave="showTriggerMissingToltip(false)"
|
||||
@click="onRunContainerClick"
|
||||
>
|
||||
<n8n-button
|
||||
@click.stop="onRunWorkflow"
|
||||
:loading="workflowRunning"
|
||||
<keyboard-shortcut-tooltip
|
||||
:label="runButtonText"
|
||||
:title="$locale.baseText('nodeView.executesTheWorkflowFromATriggerNode')"
|
||||
size="large"
|
||||
icon="play-circle"
|
||||
type="primary"
|
||||
:disabled="isExecutionDisabled"
|
||||
data-test-id="execute-workflow-button"
|
||||
/>
|
||||
:shortcut="{ metaKey: true, keys: ['↵'] }"
|
||||
>
|
||||
<n8n-button
|
||||
@click.stop="onRunWorkflow"
|
||||
:loading="workflowRunning"
|
||||
:label="runButtonText"
|
||||
size="large"
|
||||
icon="play-circle"
|
||||
type="primary"
|
||||
:disabled="isExecutionDisabled"
|
||||
data-test-id="execute-workflow-button"
|
||||
/>
|
||||
</keyboard-shortcut-tooltip>
|
||||
</span>
|
||||
|
||||
<n8n-button
|
||||
|
@ -229,6 +235,7 @@ import { copyPaste } from '@/mixins/copyPaste';
|
|||
import { externalHooks } from '@/mixins/externalHooks';
|
||||
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||
import { moveNodeWorkflow } from '@/mixins/moveNodeWorkflow';
|
||||
import { nodeHelpers } from '@/mixins/nodeHelpers';
|
||||
import {
|
||||
useGlobalLinkActions,
|
||||
useCanvasMouseSelect,
|
||||
|
@ -236,17 +243,22 @@ import {
|
|||
useToast,
|
||||
useTitleChange,
|
||||
useExecutionDebugging,
|
||||
useContextMenu,
|
||||
type ContextMenuAction,
|
||||
useDataSchema,
|
||||
} from '@/composables';
|
||||
import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { workflowRun } from '@/mixins/workflowRun';
|
||||
import { pinData } from '@/mixins/pinData';
|
||||
import { type PinDataSource, pinData } from '@/mixins/pinData';
|
||||
|
||||
import NodeDetailsView from '@/components/NodeDetailsView.vue';
|
||||
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
|
||||
import Node from '@/components/Node.vue';
|
||||
import Sticky from '@/components/Sticky.vue';
|
||||
import CanvasAddButton from './CanvasAddButton.vue';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type {
|
||||
IConnection,
|
||||
|
@ -368,6 +380,7 @@ export default defineComponent({
|
|||
workflowHelpers,
|
||||
workflowRun,
|
||||
debounceHelper,
|
||||
nodeHelpers,
|
||||
pinData,
|
||||
],
|
||||
components: {
|
||||
|
@ -375,14 +388,20 @@ export default defineComponent({
|
|||
Node,
|
||||
Sticky,
|
||||
CanvasAddButton,
|
||||
KeyboardShortcutTooltip,
|
||||
NodeCreation,
|
||||
CanvasControls,
|
||||
ContextMenu,
|
||||
},
|
||||
setup(props) {
|
||||
const locale = useI18n();
|
||||
const contextMenu = useContextMenu();
|
||||
const dataSchema = useDataSchema();
|
||||
|
||||
return {
|
||||
locale,
|
||||
contextMenu,
|
||||
dataSchema,
|
||||
...useCanvasMouseSelect(),
|
||||
...useGlobalLinkActions(),
|
||||
...useTitleChange(),
|
||||
|
@ -1079,6 +1098,8 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
async keyDown(e: KeyboardEvent) {
|
||||
this.contextMenu.close();
|
||||
|
||||
if (e.key === 's' && this.isCtrlKeyPressed(e)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
@ -1127,18 +1148,41 @@ export default defineComponent({
|
|||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'd') {
|
||||
void this.callDebounced('deactivateSelectedNode', { debounceTime: 350 });
|
||||
const selectedNodes = this.uiStore.getSelectedNodes
|
||||
.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') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
void this.callDebounced('deleteSelectedNodes', { debounceTime: 500 });
|
||||
void this.callDebounced('deleteNodes', { debounceTime: 500 }, selectedNodes);
|
||||
} else if (e.key === 'Tab') {
|
||||
this.onToggleNodeCreator({
|
||||
source: NODE_CREATOR_OPEN_SOURCES.TAB,
|
||||
createNodeActive: !this.createNodeActive && !this.isReadOnlyRoute && !this.readOnlyEnv,
|
||||
});
|
||||
} else if (
|
||||
e.key === 'Enter' &&
|
||||
this.isCtrlKeyPressed(e) &&
|
||||
!this.isReadOnlyRoute &&
|
||||
!this.readOnlyEnv
|
||||
) {
|
||||
void this.onRunWorkflow();
|
||||
} else if (e.key === 'S' && e.shiftKey && !this.isReadOnlyRoute && !this.readOnlyEnv) {
|
||||
void this.onAddNodes({ nodes: [{ type: STICKY_NODE_TYPE }], connections: [] });
|
||||
} else if (e.key === this.controlKeyCode) {
|
||||
this.ctrlKeyPressed = true;
|
||||
} else if (e.key === ' ') {
|
||||
|
@ -1159,13 +1203,13 @@ export default defineComponent({
|
|||
|
||||
void this.callDebounced('selectAllNodes', { debounceTime: 1000 });
|
||||
} else if (e.key === 'c' && this.isCtrlKeyPressed(e)) {
|
||||
void this.callDebounced('copySelectedNodes', { debounceTime: 1000 });
|
||||
void this.callDebounced('copyNodes', { debounceTime: 1000 }, selectedNodes);
|
||||
} else if (e.key === 'x' && this.isCtrlKeyPressed(e)) {
|
||||
// Cut nodes
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
void this.callDebounced('cutSelectedNodes', { debounceTime: 1000 });
|
||||
void this.callDebounced('cutNodes', { debounceTime: 1000 }, selectedNodes);
|
||||
} else if (e.key === 'n' && this.isCtrlKeyPressed(e) && e.altKey) {
|
||||
// Create a new workflow
|
||||
e.stopPropagation();
|
||||
|
@ -1333,23 +1377,46 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
deactivateSelectedNode() {
|
||||
toggleActivationNodes(nodes: INode[]) {
|
||||
if (!this.editAllowedCheck()) {
|
||||
return;
|
||||
}
|
||||
this.disableNodes(this.uiStore.getSelectedNodes, true);
|
||||
|
||||
this.disableNodes(nodes, true);
|
||||
},
|
||||
|
||||
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
|
||||
// when they get deleted and if we would use original it would mess
|
||||
// with the index and would so not delete all nodes
|
||||
const nodesToDelete: string[] = this.uiStore.getSelectedNodes.map((node: INodeUi) => {
|
||||
return node.name;
|
||||
});
|
||||
this.historyStore.startRecordingUndo();
|
||||
nodesToDelete.forEach((nodeName: string) => {
|
||||
this.removeNode(nodeName, true, false);
|
||||
nodes.forEach((node) => {
|
||||
this.removeNode(node.name, true, false);
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.historyStore.stopRecordingUndo();
|
||||
|
@ -1437,16 +1504,16 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
cutSelectedNodes() {
|
||||
cutNodes(nodes: INode[]) {
|
||||
const deleteCopiedNodes = !this.isReadOnlyRoute && !this.readOnlyEnv;
|
||||
this.copySelectedNodes(deleteCopiedNodes);
|
||||
this.copyNodes(nodes, deleteCopiedNodes);
|
||||
if (deleteCopiedNodes) {
|
||||
this.deleteSelectedNodes();
|
||||
this.deleteNodes(nodes);
|
||||
}
|
||||
},
|
||||
|
||||
copySelectedNodes(isCut: boolean) {
|
||||
void this.getSelectedNodesToSave().then((data) => {
|
||||
copyNodes(nodes: INode[], isCut = false) {
|
||||
void this.getNodesToSave(nodes).then((data) => {
|
||||
const workflowToCopy: IWorkflowToShare = {
|
||||
meta: {
|
||||
instanceId: this.rootStore.instanceId,
|
||||
|
@ -1708,6 +1775,11 @@ export default defineComponent({
|
|||
workflow_id: this.workflowsStore.workflowId,
|
||||
node_graph_string: nodeGraph,
|
||||
});
|
||||
} else if (source === 'duplicate') {
|
||||
this.$telemetry.track('User duplicated nodes', {
|
||||
workflow_id: this.workflowsStore.workflowId,
|
||||
node_graph_string: nodeGraph,
|
||||
});
|
||||
} else {
|
||||
this.$telemetry.track('User imported workflow', {
|
||||
source,
|
||||
|
@ -3211,84 +3283,13 @@ export default defineComponent({
|
|||
this.workflowsStore.removeConnection({ connection: connectionInfo });
|
||||
}
|
||||
},
|
||||
async duplicateNode(nodeName: string) {
|
||||
async duplicateNodes(nodes: INode[]): Promise<void> {
|
||||
if (!this.editAllowedCheck()) {
|
||||
return;
|
||||
}
|
||||
const node = this.workflowsStore.getNodeByName(nodeName);
|
||||
|
||||
if (node) {
|
||||
const nodeTypeData = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
|
||||
if (
|
||||
nodeTypeData?.maxNodes !== undefined &&
|
||||
this.getNodeTypeCount(node.type) >= nodeTypeData.maxNodes
|
||||
) {
|
||||
this.showMaxNodeTypeError(nodeTypeData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deep copy the data so that data on lower levels of the node-properties do
|
||||
// not share objects
|
||||
const newNodeData = deepCopy(this.getNodeDataToSave(node));
|
||||
newNodeData.id = uuid();
|
||||
|
||||
const localizedName = this.locale.localizeNodeName(newNodeData.name, newNodeData.type);
|
||||
|
||||
newNodeData.name = this.uniqueNodeName(localizedName);
|
||||
|
||||
newNodeData.position = NodeViewUtils.getNewNodePosition(
|
||||
this.nodes,
|
||||
[node.position[0], node.position[1] + 140],
|
||||
[0, 140],
|
||||
);
|
||||
|
||||
if (newNodeData.webhookId) {
|
||||
// Make sure that the node gets a new unique webhook-ID
|
||||
newNodeData.webhookId = uuid();
|
||||
}
|
||||
|
||||
if (
|
||||
newNodeData.credentials &&
|
||||
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)
|
||||
) {
|
||||
const usedCredentials = this.workflowsStore.usedCredentials;
|
||||
newNodeData.credentials = Object.fromEntries(
|
||||
Object.entries(newNodeData.credentials).filter(([_, credential]) => {
|
||||
return (
|
||||
credential.id &&
|
||||
(!usedCredentials[credential.id] ||
|
||||
usedCredentials[credential.id]?.currentUserHasAccess)
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await this.addNodes([newNodeData], [], true);
|
||||
|
||||
const pinDataForNode = this.workflowsStore.pinDataByNodeName(nodeName);
|
||||
if (pinDataForNode?.length) {
|
||||
try {
|
||||
this.setPinData(newNodeData, pinDataForNode, 'duplicate-node');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.uiStore.stateIsDirty = true;
|
||||
|
||||
// Automatically deselect all nodes and select the current one and also active
|
||||
// current node
|
||||
this.deselectAllNodes();
|
||||
setTimeout(() => {
|
||||
this.nodeSelectedByName(newNodeData.name, false);
|
||||
});
|
||||
|
||||
this.$telemetry.track('User duplicated node', {
|
||||
node_type: node.type,
|
||||
workflow_id: this.workflowsStore.workflowId,
|
||||
});
|
||||
}
|
||||
const workflowData = deepCopy(await this.getNodesToSave(nodes));
|
||||
await this.importWorkflowData(workflowData, 'duplicate', false);
|
||||
},
|
||||
getJSPlumbConnection(
|
||||
sourceNodeName: string,
|
||||
|
@ -4036,21 +4037,43 @@ export default defineComponent({
|
|||
connections: tempWorkflow.connectionsBySourceNode,
|
||||
};
|
||||
},
|
||||
async getSelectedNodesToSave(): Promise<IWorkflowData> {
|
||||
async getNodesToSave(nodes: INode[]): Promise<IWorkflowData> {
|
||||
const data: IWorkflowData = {
|
||||
nodes: [],
|
||||
connections: {},
|
||||
pinData: {},
|
||||
};
|
||||
|
||||
// Get data of all the selected noes
|
||||
let nodeData;
|
||||
const exportNodeNames: string[] = [];
|
||||
|
||||
for (const node of this.uiStore.getSelectedNodes) {
|
||||
for (const node of nodes) {
|
||||
nodeData = this.getNodeDataToSave(node);
|
||||
exportNodeNames.push(node.name);
|
||||
|
||||
data.nodes.push(nodeData);
|
||||
|
||||
const pinDataForNode = this.workflowsStore.pinDataByNodeName(node.name);
|
||||
if (pinDataForNode) {
|
||||
data.pinData![node.name] = pinDataForNode;
|
||||
}
|
||||
|
||||
if (
|
||||
nodeData.credentials &&
|
||||
this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)
|
||||
) {
|
||||
const usedCredentials = this.workflowsStore.usedCredentials;
|
||||
nodeData.credentials = Object.fromEntries(
|
||||
Object.entries(nodeData.credentials).filter(([_, credential]) => {
|
||||
return (
|
||||
credential.id &&
|
||||
(!usedCredentials[credential.id] ||
|
||||
usedCredentials[credential.id]?.currentUserHasAccess)
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get only connections of exported nodes and ignore all other ones
|
||||
|
@ -4418,6 +4441,49 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
},
|
||||
onContextMenuAction(action: ContextMenuAction, nodes: INode[]): void {
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
this.copyNodes(nodes);
|
||||
break;
|
||||
case 'delete':
|
||||
this.deleteNodes(nodes);
|
||||
break;
|
||||
case 'duplicate':
|
||||
void this.duplicateNodes(nodes);
|
||||
break;
|
||||
case 'execute':
|
||||
this.onRunNode(nodes[0].name, 'NodeView.onContextMenuAction');
|
||||
break;
|
||||
case 'open':
|
||||
this.ndvStore.activeNodeName = nodes[0].name;
|
||||
break;
|
||||
case 'rename':
|
||||
void this.renameNodePrompt(nodes[0].name);
|
||||
break;
|
||||
case 'toggle_activation':
|
||||
this.toggleActivationNodes(nodes);
|
||||
break;
|
||||
case 'toggle_pin':
|
||||
this.togglePinNodes(nodes, 'context-menu');
|
||||
break;
|
||||
case 'add_node':
|
||||
this.onToggleNodeCreator({
|
||||
source: NODE_CREATOR_OPEN_SOURCES.CONTEXT_MENU,
|
||||
createNodeActive: !this.isReadOnlyRoute && !this.readOnlyEnv,
|
||||
});
|
||||
break;
|
||||
case 'add_sticky':
|
||||
void this.onAddNodes({ nodes: [{ type: STICKY_NODE_TYPE }], connections: [] });
|
||||
break;
|
||||
case 'select_all':
|
||||
this.selectAllNodes();
|
||||
break;
|
||||
case 'deselect_all':
|
||||
this.deselectAllNodes();
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
async onSourceControlPull() {
|
||||
let workflowId = null as string | null;
|
||||
|
|
Loading…
Reference in a new issue