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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'] },
},
],
};

View file

@ -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 {
: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;

View file

@ -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>"
`;

View file

@ -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,
};

View file

@ -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>

View file

@ -0,0 +1 @@
export { default as N8nKeyboardShortcut } from './N8nKeyboardShortcut.vue';

View file

@ -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';

View file

@ -1 +1,2 @@
export * from './useI18n';
export { useDeviceSupport } from './useDeviceSupport';

View file

@ -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(

View file

@ -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);

View file

@ -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;

View file

@ -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 {

View file

@ -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
-------------------------- */

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -1,5 +1,6 @@
import * as locale from './locale';
export { useDeviceSupport } from './composables';
export * from './components';
export * from './plugin';
export * from './types';

View file

@ -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);
},
};

View file

@ -5,3 +5,4 @@ export * from './i18n';
export * from './menu';
export * from './router';
export * from './user';
export * from './keyboardshortcut';

View file

@ -0,0 +1,6 @@
export interface KeyboardShortcut {
metaKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
keys: string[];
}

View file

@ -1228,6 +1228,7 @@ export type NodeFilterType =
export type NodeCreatorOpenSource =
| ''
| 'context_menu'
| 'no_trigger_execution_tooltip'
| 'plus_endpoint'
| 'add_input_endpoint'

View file

@ -5,46 +5,63 @@
[$style.regularZoomMenu]: !isDemo,
[$style.demoZoomMenu]: isDemo,
}"
>
<keyboard-shortcut-tooltip
:label="$locale.baseText('nodeView.zoomToFit')"
:shortcut="{ keys: ['1'] }"
>
<n8n-icon-button
@click="zoomToFit"
type="tertiary"
size="large"
:title="$locale.baseText('nodeView.zoomToFit')"
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"
:title="$locale.baseText('nodeView.zoomIn')"
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"
:title="$locale.baseText('nodeView.zoomOut')"
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"
:title="$locale.baseText('nodeView.resetZoom')"
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;

View file

@ -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>

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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">
<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"
:title="$locale.baseText('nodeView.addNode')"
/>
</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>

View file

@ -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({

View file

@ -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
>
<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,

View file

@ -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>

View file

@ -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",
],
},
},
]
`;

View file

@ -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]);
});
});

View file

@ -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';

View file

@ -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;

View 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,
};
};

View file

@ -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);

View file

@ -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';

View file

@ -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() {

View file

@ -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",

View file

@ -548,7 +548,10 @@ export const useUIStore = defineStore(STORES.UI, {
}
},
addSelectedNode(node: INodeUi): void {
const isAlreadySelected = this.selectedNodes.some((n) => n.name === node.name);
if (!isAlreadySelected) {
this.selectedNodes.push(node);
}
},
removeNodeFromSelection(node: INodeUi): void {
let index;

View file

@ -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"
>
<keyboard-shortcut-tooltip
:label="runButtonText"
:shortcut="{ metaKey: true, keys: ['↵'] }"
>
<n8n-button
@click.stop="onRunWorkflow"
:loading="workflowRunning"
:label="runButtonText"
:title="$locale.baseText('nodeView.executesTheWorkflowFromATriggerNode')"
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;