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

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

View File

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

@@ -1,37 +0,0 @@
import { ref } from 'vue';
interface DeviceSupportHelpers {
isTouchDevice: boolean;
isMacOs: boolean;
controlKeyCode: string;
isCtrlKeyPressed: (e: MouseEvent | KeyboardEvent) => boolean;
}
export default function useDeviceSupportHelpers(): DeviceSupportHelpers {
const isTouchDevice = ref('ontouchstart' in window || navigator.maxTouchPoints > 0);
const userAgent = ref(navigator.userAgent.toLowerCase());
const isMacOs = ref(
userAgent.value.includes('macintosh') ||
userAgent.value.includes('ipad') ||
userAgent.value.includes('iphone') ||
userAgent.value.includes('ipod'),
);
const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control');
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
if (isTouchDevice.value && e instanceof MouseEvent) {
return true;
}
if (isMacOs.value) {
return (e as KeyboardEvent).metaKey;
}
return (e as KeyboardEvent).ctrlKey;
}
return {
isTouchDevice: isTouchDevice.value,
isMacOs: isMacOs.value,
controlKeyCode: controlKeyCode.value,
isCtrlKeyPressed,
};
}

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