feat(editor): Add context menu to canvas v2 (no-changelog) (#10088)

This commit is contained in:
Elias Meire 2024-07-18 13:00:54 +02:00 committed by GitHub
parent 45affe5d89
commit 5b440a7679
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 573 additions and 168 deletions

View file

@ -1,13 +1,12 @@
<script lang="ts" setup>
import { type ContextMenuAction, useContextMenu } from '@/composables/useContextMenu';
import { N8nActionDropdown } from 'n8n-design-system';
import type { INode } from 'n8n-workflow';
import { watch, ref } from 'vue';
const contextMenu = useContextMenu();
const { position, isOpen, actions, target } = contextMenu;
const dropdown = ref<InstanceType<typeof N8nActionDropdown>>();
const emit = defineEmits<{ action: [action: ContextMenuAction, nodes: INode[]] }>();
const emit = defineEmits<{ action: [action: ContextMenuAction, nodeIds: string[]] }>();
watch(
isOpen,
@ -24,7 +23,13 @@ watch(
function onActionSelect(item: string) {
const action = item as ContextMenuAction;
contextMenu._dispatchAction(action);
emit('action', action, contextMenu.targetNodes.value);
emit('action', action, contextMenu.targetNodeIds.value);
}
function onClickOutside(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
contextMenu.close();
}
function onVisibleChange(open: boolean) {
@ -37,6 +42,7 @@ function onVisibleChange(open: boolean) {
<template>
<Teleport v-if="isOpen" to="body">
<div
v-on-click-outside="onClickOutside"
:class="$style.contextMenu"
:style="{
left: `${position[0]}px`,
@ -48,7 +54,8 @@ function onVisibleChange(open: boolean) {
:items="actions"
placement="bottom-start"
data-test-id="context-menu"
:hide-arrow="target.source !== 'node-button'"
:hide-arrow="target?.source !== 'node-button'"
:teleported="false"
@select="onActionSelect"
@visible-change="onVisibleChange"
>

View file

@ -217,7 +217,7 @@ 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/useContextMenu';
import { useContextMenu } from '@/composables/useContextMenu';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { usePinnedData } from '@/composables/usePinnedData';
@ -660,8 +660,8 @@ export default defineComponent({
isContextMenuOpen(): boolean {
return (
this.contextMenu.isOpen.value &&
this.contextMenu.target.value.source === 'node-button' &&
this.contextMenu.target.value.node.name === this.data?.name
this.contextMenu.target.value?.source === 'node-button' &&
this.contextMenu.target.value.nodeId === this.data?.id
);
},
iconNodeType() {
@ -861,9 +861,9 @@ export default defineComponent({
}, 2000);
}
},
openContextMenu(event: MouseEvent, source: ContextMenuTarget['source']) {
openContextMenu(event: MouseEvent, source: 'node-button' | 'node-right-click') {
if (this.data) {
this.contextMenu.open(event, { source, node: this.data });
this.contextMenu.open(event, { source, nodeId: this.data.id });
}
},
},

View file

@ -58,19 +58,21 @@
<font-awesome-icon icon="trash" />
</div>
<n8n-popover
v-on-click-outside="() => setColorPopoverVisible(false)"
effect="dark"
:popper-style="{ width: '208px' }"
trigger="click"
placement="top"
:popper-style="{ width: '208px' }"
:visible="isColorPopoverVisible"
@show="onShowPopover"
@hide="onHidePopover"
>
<template #reference>
<div
ref="colorPopoverTrigger"
class="option"
data-test-id="change-sticky-color"
:title="$locale.baseText('node.changeColor')"
@click="() => setColorPopoverVisible(!isColorPopoverVisible)"
>
<font-awesome-icon icon="palette" />
</div>
@ -174,15 +176,19 @@ export default defineComponent({
setup(props, { emit }) {
const deviceSupport = useDeviceSupport();
const toast = useToast();
const colorPopoverTrigger = ref<HTMLDivElement>();
const forceActions = ref(false);
const isColorPopoverVisible = ref(false);
const setForceActions = (value: boolean) => {
forceActions.value = value;
};
const setColorPopoverVisible = (value: boolean) => {
isColorPopoverVisible.value = value;
};
const contextMenu = useContextMenu((action) => {
if (action === 'change_color') {
setForceActions(true);
colorPopoverTrigger.value?.click();
setColorPopoverVisible(true);
}
});
@ -197,11 +203,12 @@ export default defineComponent({
return {
deviceSupport,
toast,
colorPopoverTrigger,
contextMenu,
forceActions,
...nodeBase,
setForceActions,
isColorPopoverVisible,
setColorPopoverVisible,
};
},
data() {
@ -416,7 +423,7 @@ export default defineComponent({
},
onContextMenu(e: MouseEvent): void {
if (this.node && !this.isActive) {
this.contextMenu.open(e, { source: 'node-right-click', node: this.node });
this.contextMenu.open(e, { source: 'node-right-click', nodeId: this.node.id });
} else {
e.stopPropagation();
}

View file

@ -1,5 +1,4 @@
// @vitest-environment jsdom
import { fireEvent, waitFor } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import Canvas from '@/components/canvas/Canvas.vue';
@ -7,19 +6,18 @@ import { createPinia, setActivePinia } from 'pinia';
import type { CanvasConnection, CanvasNode } from '@/types';
import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data';
import { NodeConnectionType } from 'n8n-workflow';
import type { useDeviceSupport } from 'n8n-design-system';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
global.window = jsdom.window as unknown as Window & typeof globalThis;
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
getNodeType: vi.fn(() => ({
name: 'test',
description: 'Test Node Description',
})),
})),
}));
vi.mock('n8n-design-system', async (importOriginal) => {
const actual = await importOriginal<typeof useDeviceSupport>();
return { ...actual, useDeviceSupport: vi.fn(() => ({ isCtrlKeyPressed: vi.fn() })) };
});
vi.mock('@/composables/useDeviceSupport');
let renderComponent: ReturnType<typeof createComponentRenderer>;
beforeEach(() => {

View file

@ -7,9 +7,14 @@ import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import Node from './elements/nodes/CanvasNode.vue';
import Edge from './elements/edges/CanvasEdge.vue';
import { onMounted, onUnmounted, ref, useCssModule } from 'vue';
import { computed, onMounted, onUnmounted, ref, useCssModule } from 'vue';
import type { EventBus } from 'n8n-design-system';
import { createEventBus } from 'n8n-design-system';
import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu';
import { useKeybindings } from '@/composables/useKeybindings';
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
import type { NodeCreatorOpenSource } from '@/Interface';
import type { PinDataSource } from '@/composables/usePinnedData';
const $style = useCssModule();
@ -18,10 +23,19 @@ const emit = defineEmits<{
'update:node:position': [id: string, position: XYPosition];
'update:node:active': [id: string];
'update:node:enabled': [id: string];
'update:node:selected': [id?: string];
'update:node:selected': [id: string];
'update:node:name': [id: string];
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
'run:node': [id: string];
'delete:node': [id: string];
'create:node': [source: NodeCreatorOpenSource];
'create:sticky': [];
'delete:nodes': [ids: string[]];
'update:nodes:enabled': [ids: string[]];
'copy:nodes': [ids: string[]];
'duplicate:nodes': [ids: string[]];
'update:nodes:pin': [ids: string[], source: PinDataSource];
'cut:nodes': [ids: string[]];
'delete:connection': [connection: Connection];
'create:connection:start': [handle: ConnectStartEvent];
'create:connection': [connection: Connection];
@ -29,6 +43,9 @@ const emit = defineEmits<{
'create:connection:cancelled': [handle: ConnectStartEvent];
'click:connection:add': [connection: Connection];
'click:pane': [position: XYPosition];
'run:workflow': [];
'save:workflow': [];
'create:workflow': [];
}>();
const props = withDefaults(
@ -48,16 +65,43 @@ const props = withDefaults(
},
);
const { getSelectedEdges, getSelectedNodes, viewportRef, fitView, project, onPaneReady } =
useVueFlow({
id: props.id,
});
const {
getSelectedNodes: selectedNodes,
addSelectedNodes,
removeSelectedNodes,
viewportRef,
fitView,
project,
nodes: graphNodes,
onPaneReady,
} = useVueFlow({ id: props.id, deleteKeyCode: null });
onPaneReady(async () => {
await onFitView();
paneReady.value = true;
useKeybindings({
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)),
'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)),
ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)),
d: emitWithSelectedNodes((ids) => emit('update:nodes:enabled', ids)),
p: emitWithSelectedNodes((ids) => emit('update:nodes:pin', ids, 'keyboard-shortcut')),
enter: () => emitWithLastSelectedNode((id) => emit('update:node:active', id)),
f2: () => emitWithLastSelectedNode((id) => emit('update:node:name', id)),
tab: () => emit('create:node', 'tab'),
shift_s: () => emit('create:sticky'),
ctrl_alt_n: () => emit('create:workflow'),
ctrl_enter: () => emit('run:workflow'),
ctrl_s: () => emit('save:workflow'),
ctrl_a: () => addSelectedNodes(graphNodes.value),
// @TODO implement arrow key shortcuts to modify selection
});
const contextMenu = useContextMenu();
const lastSelectedNode = computed(() => selectedNodes.value[selectedNodes.value.length - 1]);
const hasSelection = computed(() => selectedNodes.value.length > 0);
const selectedNodeIds = computed(() => selectedNodes.value.map((node) => node.id));
const paneReady = ref(false);
/**
@ -83,8 +127,8 @@ function onSetNodeActive(id: string) {
}
function onSelectNode() {
const selectedNodeId = getSelectedNodes.value[getSelectedNodes.value.length - 1]?.id;
emit('update:node:selected', selectedNodeId);
if (!lastSelectedNode.value) return;
emit('update:node:selected', lastSelectedNode.value.id);
}
function onToggleNodeEnabled(id: string) {
@ -166,14 +210,23 @@ function onRunNode(id: string) {
}
/**
* Keyboard events
* Emit helpers
*/
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Delete') {
getSelectedEdges.value.forEach(onDeleteConnection);
getSelectedNodes.value.forEach(({ id }) => onDeleteNode(id));
}
function emitWithSelectedNodes(emitFn: (ids: string[]) => void) {
return () => {
if (hasSelection.value) {
emitFn(selectedNodeIds.value);
}
};
}
function emitWithLastSelectedNode(emitFn: (id: string) => void) {
return () => {
if (lastSelectedNode.value) {
emitFn(lastSelectedNode.value.id);
}
};
}
/**
@ -194,18 +247,73 @@ async function onFitView() {
await fitView({ maxZoom: 1.2, padding: 0.1 });
}
/**
* Context menu
*/
function onOpenContextMenu(event: MouseEvent) {
contextMenu.open(event, {
source: 'canvas',
nodeIds: selectedNodeIds.value,
});
}
function onOpenNodeContextMenu(
id: string,
event: MouseEvent,
source: 'node-button' | 'node-right-click',
) {
if (selectedNodeIds.value.includes(id)) {
onOpenContextMenu(event);
}
contextMenu.open(event, { source, nodeId: id });
}
function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
switch (action) {
case 'add_node':
return emit('create:node', 'context_menu');
case 'add_sticky':
return emit('create:sticky');
case 'copy':
return emit('copy:nodes', nodeIds);
case 'delete':
return emit('delete:nodes', nodeIds);
case 'select_all':
return addSelectedNodes(graphNodes.value);
case 'deselect_all':
return removeSelectedNodes(selectedNodes.value);
case 'duplicate':
return emit('duplicate:nodes', nodeIds);
case 'toggle_pin':
return emit('update:nodes:pin', nodeIds, 'context-menu');
case 'execute':
return emit('run:node', nodeIds[0]);
case 'toggle_activation':
return emit('update:nodes:enabled', nodeIds);
case 'open':
return emit('update:node:active', nodeIds[0]);
case 'rename':
return emit('update:node:name', nodeIds[0]);
}
}
/**
* Lifecycle
*/
onMounted(() => {
document.addEventListener('keydown', onKeyDown);
props.eventBus.on('fitView', onFitView);
});
onUnmounted(() => {
props.eventBus.off('fitView', onFitView);
document.removeEventListener('keydown', onKeyDown);
});
onPaneReady(async () => {
await onFitView();
paneReady.value = true;
});
</script>
@ -230,6 +338,7 @@ onUnmounted(() => {
@connect="onConnect"
@connect-end="onConnectEnd"
@pane-click="onClickPane"
@contextmenu="onOpenContextMenu"
>
<template #node-canvas-node="canvasNodeProps">
<Node
@ -239,6 +348,7 @@ onUnmounted(() => {
@select="onSelectNode"
@toggle="onToggleNodeEnabled"
@activate="onSetNodeActive"
@open:contextmenu="onOpenNodeContextMenu"
@update="onUpdateNodeParameters"
@move="onUpdateNodePosition"
/>
@ -263,6 +373,10 @@ onUnmounted(() => {
:position="controlsPosition"
@fit-view="onFitView"
></Controls>
<Suspense>
<ContextMenu @action="onContextMenuAction" />
</Suspense>
</VueFlow>
</template>

View file

@ -8,6 +8,7 @@ import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRen
import HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import { useContextMenu } from '@/composables/useContextMenu';
import { Position } from '@vue-flow/core';
import type { XYPosition, NodeProps } from '@vue-flow/core';
@ -17,12 +18,14 @@ const emit = defineEmits<{
select: [id: string, selected: boolean];
toggle: [id: string];
activate: [id: string];
'open:contextmenu': [id: string, event: MouseEvent, source: 'node-button' | 'node-right-click'];
update: [id: string, parameters: Record<string, unknown>];
move: [id: string, position: XYPosition];
}>();
const props = defineProps<NodeProps<CanvasNodeData>>();
const nodeTypesStore = useNodeTypesStore();
const contextMenu = useContextMenu();
const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs);
@ -110,6 +113,13 @@ function onActivate() {
emit('activate', props.id);
}
function onOpenContextMenuFromToolbar(event: MouseEvent) {
emit('open:contextmenu', props.id, event, 'node-button');
}
function onOpenContextMenuFromNode(event: MouseEvent) {
emit('open:contextmenu', props.id, event, 'node-right-click');
}
function onUpdate(parameters: Record<string, unknown>) {
emit('update', props.id, parameters);
}
@ -135,6 +145,11 @@ provide(CanvasNodeKey, {
nodeType,
});
const showToolbar = computed(() => {
const target = contextMenu.target.value;
return contextMenu.isOpen && target?.source === 'node-button' && target.nodeId === id.value;
});
/**
* Lifecycle
*/
@ -148,7 +163,10 @@ watch(
</script>
<template>
<div :class="$style.canvasNode" data-test-id="canvas-node">
<div
:class="[$style.canvasNode, { [$style.showToolbar]: showToolbar }]"
data-test-id="canvas-node"
>
<template v-for="source in outputsWithPosition" :key="`${source.type}/${source.index}`">
<HandleRenderer
mode="output"
@ -182,9 +200,15 @@ watch(
@delete="onDelete"
@toggle="onDisabledToggle"
@run="onRun"
@open:contextmenu="onOpenContextMenuFromToolbar"
/>
<CanvasNodeRenderer @dblclick="onActivate" @move="onMove" @update="onUpdate">
<CanvasNodeRenderer
@dblclick="onActivate"
@move="onMove"
@update="onUpdate"
@open:contextmenu="onOpenContextMenuFromNode"
>
<NodeIcon
v-if="nodeType"
:node-type="nodeType"
@ -199,7 +223,8 @@ watch(
<style lang="scss" module>
.canvasNode {
&:hover {
&:hover,
&.showToolbar {
.canvasNodeToolbar {
opacity: 1;
}
@ -214,8 +239,4 @@ watch(
transform: translate(-50%, -100%);
opacity: 0;
}
.canvasNodeToolbar:focus-within {
opacity: 1;
}
</style>

View file

@ -38,75 +38,59 @@ describe('CanvasNodeToolbar', () => {
expect(queryByTestId('execute-node-button')).not.toBeInTheDocument();
});
it('should call executeNode function when execute node button is clicked', async () => {
const executeNode = vi.fn();
const { getByTestId } = renderComponent({
it('should emit "run" when execute node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
executeNode,
},
},
});
await fireEvent.click(getByTestId('execute-node-button'));
expect(executeNode).toHaveBeenCalled();
expect(emitted('run')[0]).toEqual([]);
});
it('should call toggleDisableNode function when disable node button is clicked', async () => {
const onToggleNode = vi.fn();
const { getByTestId } = renderComponent({
it('should emit "toggle" when disable node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
onToggleNode,
},
},
});
await fireEvent.click(getByTestId('disable-node-button'));
expect(onToggleNode).toHaveBeenCalled();
expect(emitted('toggle')[0]).toEqual([]);
});
it('should call deleteNode function when delete node button is clicked', async () => {
const onDeleteNode = vi.fn();
const { getByTestId } = renderComponent({
it('should emit "delete" when delete node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
onDeleteNode,
},
},
});
await fireEvent.click(getByTestId('delete-node-button'));
expect(onDeleteNode).toHaveBeenCalled();
expect(emitted('delete')[0]).toEqual([]);
});
it('should call openContextMenu function when overflow node button is clicked', async () => {
const openContextMenu = vi.fn();
const { getByTestId } = renderComponent({
it('should emit "open:contextmenu" when overflow node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
mocks: {
openContextMenu,
},
},
});
await fireEvent.click(getByTestId('overflow-node-button'));
expect(openContextMenu).toHaveBeenCalled();
expect(emitted('open:contextmenu')[0]).toEqual([expect.any(MouseEvent)]);
});
});

View file

@ -8,6 +8,7 @@ const emit = defineEmits<{
delete: [];
toggle: [];
run: [];
'open:contextmenu': [event: MouseEvent];
}>();
const $style = useCssModule();
@ -45,8 +46,9 @@ function onDeleteNode() {
emit('delete');
}
// @TODO
function openContextMenu(_e: MouseEvent, _type: string) {}
function onOpenContextMenu(event: MouseEvent) {
emit('open:contextmenu', event);
}
</script>
<template>
@ -88,7 +90,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
size="small"
text
icon="ellipsis-h"
@click="(e: MouseEvent) => openContextMenu(e, 'node-button')"
@click="onOpenContextMenu"
/>
</div>
</div>

View file

@ -12,6 +12,10 @@ import type { CanvasNodeDefaultRender } from '@/types';
const $style = useCssModule();
const i18n = useI18n();
const emit = defineEmits<{
'open:contextmenu': [event: MouseEvent];
}>();
const {
label,
inputs,
@ -79,10 +83,14 @@ const dataTestId = computed(() => {
return `canvas-${type}-node`;
});
function openContextMenu(event: MouseEvent) {
emit('open:contextmenu', event);
}
</script>
<template>
<div :class="classes" :style="styles" :data-test-id="dataTestId">
<div :class="classes" :style="styles" :data-test-id="dataTestId" @contextmenu="openContextMenu">
<slot />
<N8nTooltip v-if="renderOptions.trigger" placement="bottom">
<template #content>

View file

@ -56,90 +56,97 @@ describe('useContextMenu', () => {
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();
const { open, close, isOpen, actions, position, target, targetNodeIds } = useContextMenu();
expect(isOpen.value).toBe(false);
expect(actions.value).toEqual([]);
expect(position.value).toEqual([0, 0]);
expect(targetNodes.value).toEqual([]);
expect(targetNodeIds.value).toEqual([]);
open(mockEvent);
const nodeIds = selectedNodes.map((n) => n.id);
open(mockEvent, { source: 'canvas', nodeIds });
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);
expect(target.value).toEqual({ source: 'canvas', nodeIds });
expect(targetNodeIds.value).toEqual(nodeIds);
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([]);
expect(targetNodeIds.value).toEqual([]);
});
it('should return the correct actions when right clicking a sticky', () => {
const { open, isOpen, actions, targetNodes } = useContextMenu();
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
open(mockEvent, { source: 'node-right-click', node: sticky });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
open(mockEvent, { source: 'node-right-click', nodeId: sticky.id });
expect(isOpen.value).toBe(true);
expect(actions.value).toMatchSnapshot();
expect(targetNodes.value).toEqual([sticky]);
expect(targetNodeIds.value).toEqual([sticky.id]);
});
it('should disable pinning for node that has other inputs then "main"', () => {
const { open, isOpen, actions, targetNodes } = useContextMenu();
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const basicChain = nodeFactory({ type: BASIC_CHAIN_NODE_TYPE });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(basicChain);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue(['main', 'ai_languageModel']);
open(mockEvent, { source: 'node-right-click', node: basicChain });
open(mockEvent, { source: 'node-right-click', nodeId: basicChain.id });
expect(isOpen.value).toBe(true);
expect(actions.value.find((action) => action.id === 'toggle_pin')?.disabled).toBe(true);
expect(targetNodes.value).toEqual([basicChain]);
expect(targetNodeIds.value).toEqual([basicChain.id]);
});
it('should return the correct actions when right clicking a Node', () => {
const { open, isOpen, actions, targetNodes } = useContextMenu();
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const node = nodeFactory();
open(mockEvent, { source: 'node-right-click', node });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
open(mockEvent, { source: 'node-right-click', nodeId: node.id });
expect(isOpen.value).toBe(true);
expect(actions.value).toMatchSnapshot();
expect(targetNodes.value).toEqual([node]);
expect(targetNodeIds.value).toEqual([node.id]);
});
it('should return the correct actions opening the menu from the button', () => {
const { open, isOpen, actions, targetNodes } = useContextMenu();
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const node = nodeFactory();
open(mockEvent, { source: 'node-button', node });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
open(mockEvent, { source: 'node-button', nodeId: node.id });
expect(isOpen.value).toBe(true);
expect(actions.value).toMatchSnapshot();
expect(targetNodes.value).toEqual([node]);
expect(targetNodeIds.value).toEqual([node.id]);
});
describe('Read-only mode', () => {
it('should return the correct actions when right clicking a sticky', () => {
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
const { open, isOpen, actions, targetNodes } = useContextMenu();
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
open(mockEvent, { source: 'node-right-click', node: sticky });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
open(mockEvent, { source: 'node-right-click', nodeId: sticky.id });
expect(isOpen.value).toBe(true);
expect(actions.value).toMatchSnapshot();
expect(targetNodes.value).toEqual([sticky]);
expect(targetNodeIds.value).toEqual([sticky.id]);
});
it('should return the correct actions when right clicking a Node', () => {
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
const { open, isOpen, actions, targetNodes } = useContextMenu();
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const node = nodeFactory();
open(mockEvent, { source: 'node-right-click', node });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node);
open(mockEvent, { source: 'node-right-click', nodeId: node.id });
expect(isOpen.value).toBe(true);
expect(actions.value).toMatchSnapshot();
expect(targetNodes.value).toEqual([node]);
expect(targetNodeIds.value).toEqual([node.id]);
});
});
});

View file

@ -0,0 +1,44 @@
import { renderComponent } from '@/__tests__/render';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { useKeybindings } from '../useKeybindings';
const renderTestComponent = async (...args: Parameters<typeof useKeybindings>) => {
return renderComponent(
defineComponent({
setup() {
useKeybindings(...args);
return () => h('div', [h('input')]);
},
}),
);
};
describe('useKeybindings', () => {
it('should trigger case-insensitive keyboard shortcuts', async () => {
const saveSpy = vi.fn();
const saveAllSpy = vi.fn();
await renderTestComponent({ Ctrl_s: saveSpy, ctrl_Shift_S: saveAllSpy });
await userEvent.keyboard('{Control>}s');
expect(saveSpy).toHaveBeenCalled();
expect(saveAllSpy).not.toHaveBeenCalled();
await userEvent.keyboard('{Control>}{Shift>}s');
expect(saveAllSpy).toHaveBeenCalled();
});
it('should not trigger shortcuts when an input element has focus', async () => {
const saveSpy = vi.fn();
const saveAllSpy = vi.fn();
const { getByRole } = await renderTestComponent({ Ctrl_s: saveSpy, ctrl_Shift_S: saveAllSpy });
getByRole('textbox').focus();
await userEvent.keyboard('{Control>}s');
await userEvent.keyboard('{Control>}{Shift>}s');
expect(saveSpy).not.toHaveBeenCalled();
expect(saveAllSpy).not.toHaveBeenCalled();
});
});

View file

@ -3,8 +3,6 @@
* @TODO Remove this notice when Canvas V2 is the only one in use
*/
import { CanvasConnectionMode } from '@/types';
import type { CanvasConnectionCreateData, CanvasNode, CanvasConnection } from '@/types';
import type {
AddedNodesAndConnections,
INodeUi,
@ -13,6 +11,14 @@ import type {
IWorkflowDb,
XYPosition,
} from '@/Interface';
import { useDataSchema } from '@/composables/useDataSchema';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { usePinnedData, type PinDataSource } from '@/composables/usePinnedData';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import {
FORM_TRIGGER_NODE_TYPE,
QUICKSTART_NOTE_NAME,
@ -20,11 +26,6 @@ import {
UPDATE_WEBHOOK_ID_NODE_TYPES,
WEBHOOK_NODE_TYPE,
} from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useHistoryStore } from '@/stores/history.store';
import { useUIStore } from '@/stores/ui.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { useExternalHooks } from '@/composables/useExternalHooks';
import {
AddNodeCommand,
MoveNodeCommand,
@ -32,7 +33,20 @@ import {
RemoveNodeCommand,
RenameNodeCommand,
} from '@/models/history';
import type { Connection } from '@vue-flow/core';
import { useCanvasStore } from '@/stores/canvas.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useExecutionsStore } from '@/stores/executions.store';
import { useHistoryStore } from '@/stores/history.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRootStore } from '@/stores/root.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useTagsStore } from '@/stores/tags.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { CanvasConnection, CanvasConnectionCreateData, CanvasNode } from '@/types';
import { CanvasConnectionMode } from '@/types';
import {
createCanvasConnectionHandleString,
getUniqueNodeName,
@ -40,10 +54,15 @@ import {
mapLegacyConnectionsToCanvasConnections,
parseCanvasConnectionHandleString,
} from '@/utils/canvasUtilsV2';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
import { isPresent } from '@/utils/typesUtils';
import type { Connection } from '@vue-flow/core';
import type {
ConnectionTypes,
IConnection,
IConnections,
INode,
INodeConnections,
INodeInputConfiguration,
INodeOutputConfiguration,
@ -51,31 +70,14 @@ import type {
INodeTypeNameVersion,
ITelemetryTrackProperties,
IWorkflowBase,
Workflow,
INode,
NodeParameterValueType,
Workflow,
} from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { v4 as uuid } from 'uuid';
import type { Ref } from 'vue';
import { computed } from 'vue';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import type { useRouter } from 'vue-router';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { usePinnedData } from '@/composables/usePinnedData';
import { useSettingsStore } from '@/stores/settings.store';
import { useTagsStore } from '@/stores/tags.store';
import { useRootStore } from '@/stores/root.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useExecutionsStore } from '@/stores/executions.store';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
type AddNodeData = Partial<INodeUi> & {
type: string;
@ -218,6 +220,12 @@ export function useCanvasOperations({
trackDeleteNode(id);
}
function deleteNodes(ids: string[]) {
historyStore.startRecordingUndo();
ids.forEach((id) => deleteNode(id, { trackHistory: true, trackBulk: false }));
historyStore.stopRecordingUndo();
}
function revertDeleteNode(node: INodeUi) {
workflowsStore.addNode(node);
}
@ -284,16 +292,33 @@ export function useCanvasOperations({
uiStore.lastSelectedNode = node.name;
}
function toggleNodeDisabled(
id: string,
function toggleNodesDisabled(
ids: string[],
{ trackHistory = true }: { trackHistory?: boolean } = {},
) {
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
nodeHelpers.disableNodes(nodes, trackHistory);
}
function toggleNodesPinned(ids: string[], source: PinDataSource) {
historyStore.startRecordingUndo();
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
const nextStatePinned = nodes.some((node) => !workflowsStore.pinDataByNodeName(node.name));
for (const node of nodes) {
const pinnedDataForNode = usePinnedData(node);
if (nextStatePinned) {
const dataToPin = useDataSchema().getInputDataWithPinned(node);
if (dataToPin.length !== 0) {
pinnedDataForNode.setData(dataToPin, source);
}
} else {
pinnedDataForNode.unsetData(source);
}
}
nodeHelpers.disableNodes([node], trackHistory);
historyStore.stopRecordingUndo();
}
async function addNodes(
@ -1403,11 +1428,13 @@ export function useCanvasOperations({
setNodeActive,
setNodeActiveByName,
setNodeSelected,
toggleNodesDisabled,
toggleNodesPinned,
setNodeParameters,
toggleNodeDisabled,
renameNode,
revertRenameNode,
deleteNode,
deleteNodes,
revertDeleteNode,
addConnections,
createConnection,

View file

@ -9,12 +9,14 @@ import { computed, ref, watch } from 'vue';
import { getMousePosition } from '../utils/nodeViewUtils';
import { useI18n } from './useI18n';
import { usePinnedData } from './usePinnedData';
import { isPresent } from '../utils/typesUtils';
export type ContextMenuTarget =
| { source: 'canvas' }
| { source: 'node-right-click'; node: INode }
| { source: 'node-button'; node: INode };
export type ContextMenuActionCallback = (action: ContextMenuAction, targets: INode[]) => void;
| { source: 'canvas'; nodeIds: string[] }
| { source: 'node-right-click'; nodeId: string }
| { source: 'node-button'; nodeId: string };
export type ContextMenuActionCallback = (action: ContextMenuAction, nodeIds: string[]) => void;
export type ContextMenuAction =
| 'open'
| 'copy'
@ -32,7 +34,7 @@ export type ContextMenuAction =
const position = ref<XYPosition>([0, 0]);
const isOpen = ref(false);
const target = ref<ContextMenuTarget>({ source: 'canvas' });
const target = ref<ContextMenuTarget>();
const actions = ref<ActionDropdownItem[]>([]);
const actionCallback = ref<ContextMenuActionCallback>(() => {});
@ -48,22 +50,17 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
() => 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];
}
const targetNodeIds = computed(() => {
if (!isOpen.value || !target.value) return [];
return [currentTarget.node];
const currentTarget = target.value;
return currentTarget.source === 'canvas' ? currentTarget.nodeIds : [currentTarget.nodeId];
});
const targetNodes = computed(() =>
targetNodeIds.value.map((nodeId) => workflowsStore.getNodeById(nodeId)).filter(isPresent),
);
const canAddNodeOfType = (nodeType: INodeTypeDescription) => {
const sameTypeNodes = workflowsStore.allNodes.filter((n) => n.type === nodeType.name);
return nodeType.maxNodes === undefined || sameTypeNodes.length < nodeType.maxNodes;
@ -80,17 +77,18 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
const hasPinData = (node: INode): boolean => {
return !!workflowsStore.pinDataByNodeName(node.name);
};
const close = () => {
target.value = { source: 'canvas' };
target.value = undefined;
isOpen.value = false;
actions.value = [];
position.value = [0, 0];
};
const open = (event: MouseEvent, menuTarget: ContextMenuTarget = { source: 'canvas' }) => {
const open = (event: MouseEvent, menuTarget: ContextMenuTarget) => {
event.stopPropagation();
if (isOpen.value && menuTarget.source === target.value.source) {
if (isOpen.value && menuTarget.source === target.value?.source) {
// Close context menu, let browser open native context menu
close();
return;
@ -225,8 +223,8 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
}
};
const _dispatchAction = (action: ContextMenuAction) => {
actionCallback.value(action, targetNodes.value);
const _dispatchAction = (a: ContextMenuAction) => {
actionCallback.value(a, targetNodeIds.value);
};
watch(
@ -241,7 +239,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
position,
target,
actions,
targetNodes,
targetNodeIds,
open,
close,
_dispatchAction,

View file

@ -0,0 +1,77 @@
import { useActiveElement, useEventListener } from '@vueuse/core';
import { useDeviceSupport } from 'n8n-design-system';
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
type KeyMap = Record<string, (event: KeyboardEvent) => void>;
export const useKeybindings = (keymap: MaybeRefOrGetter<KeyMap>) => {
const activeElement = useActiveElement();
const { isCtrlKeyPressed } = useDeviceSupport();
const ignoreKeyPresses = computed(() => {
if (!activeElement.value) return false;
const active = activeElement.value;
const isInput = ['INPUT', 'TEXTAREA'].includes(active.tagName);
const isContentEditable = active.closest('[contenteditable]') !== null;
const isIgnoreClass = active.closest('.ignore-key-press') !== null;
return isInput || isContentEditable || isIgnoreClass;
});
const normalizedKeymap = computed(() =>
Object.fromEntries(
Object.entries(toValue(keymap))
.map(([shortcut, handler]) => {
const shortcuts = shortcut.split('|');
return shortcuts.map((s) => [normalizeShortcutString(s), handler]);
})
.flat(),
),
);
function normalizeShortcutString(shortcut: string) {
return shortcut
.split(/[+_-]/)
.map((key) => key.toLowerCase())
.sort((a, b) => a.localeCompare(b))
.join('+');
}
function toShortcutString(event: KeyboardEvent) {
const { shiftKey, altKey } = event;
const ctrlKey = isCtrlKeyPressed(event);
const keys = [event.key];
const modifiers: string[] = [];
if (shiftKey) {
modifiers.push('shift');
}
if (ctrlKey) {
modifiers.push('ctrl');
}
if (altKey) {
modifiers.push('alt');
}
return normalizeShortcutString([...modifiers, ...keys].join('+'));
}
function onKeyDown(event: KeyboardEvent) {
if (ignoreKeyPresses.value) return;
const shortcutString = toShortcutString(event);
const handler = normalizedKeymap.value[shortcutString];
if (handler) {
event.preventDefault();
event.stopPropagation();
handler(event);
}
}
useEventListener(document, 'keydown', onKeyDown);
};

View file

@ -24,6 +24,7 @@ import type {
IWorkflowDataUpdate,
IWorkflowDb,
IWorkflowTemplate,
NodeCreatorOpenSource,
ToggleNodeCreatorOptions,
XYPosition,
} from '@/Interface';
@ -40,6 +41,7 @@ import {
NODE_CREATOR_OPEN_SOURCES,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
START_NODE_TYPE,
STICKY_NODE_TYPE,
VIEWS,
} from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
@ -80,6 +82,7 @@ import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { tryToParseNumber } from '@/utils/typesUtils';
import { useTemplatesStore } from '@/stores/templates.store';
import { createEventBus } from 'n8n-design-system';
import type { PinDataSource } from '@/composables/usePinnedData';
const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'),
@ -133,9 +136,11 @@ const {
revertRenameNode,
setNodeActive,
setNodeSelected,
toggleNodeDisabled,
toggleNodesDisabled,
toggleNodesPinned,
setNodeParameters,
deleteNode,
deleteNodes,
revertDeleteNode,
addNodes,
createConnection,
@ -438,6 +443,10 @@ function onDeleteNode(id: string) {
deleteNode(id, { trackHistory: true });
}
function onDeleteNodes(ids: string[]) {
deleteNodes(ids);
}
function onRevertDeleteNode({ node }: { node: INodeUi }) {
revertDeleteNode(node);
}
@ -447,7 +456,15 @@ function onToggleNodeDisabled(id: string) {
return;
}
toggleNodeDisabled(id);
toggleNodesDisabled([id]);
}
function onToggleNodesDisabled(ids: string[]) {
if (!checkIfEditingIsAllowed()) {
return;
}
toggleNodesDisabled(ids);
}
function onSetNodeActive(id: string) {
@ -458,12 +475,77 @@ function onSetNodeSelected(id?: string) {
setNodeSelected(id);
}
function onCopyNodes(_ids: string[]) {
// @TODO: implement this
}
function onCutNodes(_ids: string[]) {
// @TODO: implement this
}
function onDuplicateNodes(_ids: string[]) {
// @TODO: implement this
}
function onPinNodes(ids: string[], source: PinDataSource) {
if (!checkIfEditingIsAllowed()) {
return;
}
toggleNodesPinned(ids, source);
}
async function onSaveWorkflow() {
await workflowHelpers.saveCurrentWorkflow();
}
async function onCreateWorkflow() {
await router.push({ name: VIEWS.NEW_WORKFLOW });
}
function onRenameNode(parameterData: IUpdateInformation) {
if (parameterData.name === 'name' && parameterData.oldValue) {
void renameNode(parameterData.oldValue as string, parameterData.value as string);
}
}
async function onOpenRenameNodeModal(id: string) {
const currentName = workflowsStore.getNodeById(id)?.name ?? '';
try {
const promptResponsePromise = message.prompt(
i18n.baseText('nodeView.prompt.newName') + ':',
i18n.baseText('nodeView.prompt.renameNode') + `: ${currentName}`,
{
customClass: 'rename-prompt',
confirmButtonText: i18n.baseText('nodeView.prompt.rename'),
cancelButtonText: i18n.baseText('nodeView.prompt.cancel'),
inputErrorMessage: i18n.baseText('nodeView.prompt.invalidName'),
inputValue: currentName,
inputValidator: (value: string) => {
if (!value.trim()) {
return i18n.baseText('nodeView.prompt.invalidName');
}
return true;
},
},
);
// Wait till input is displayed
await nextTick();
// Focus and select input content
const nameInput = document.querySelector<HTMLInputElement>('.rename-prompt .el-input__inner');
nameInput?.focus();
nameInput?.select();
const promptResponse = await promptResponsePromise;
if (promptResponse.action === MODAL_CONFIRM) {
await renameNode(currentName, promptResponse.value, { trackHistory: true });
}
} catch (e) {}
}
async function onRevertRenameNode({
currentName,
newName,
@ -626,10 +708,18 @@ async function onOpenSelectiveNodeCreator(node: string, connectionType: NodeConn
nodeCreatorStore.openSelectiveNodeCreator({ node, connectionType });
}
function onOpenNodeCreatorFromCanvas(source: NodeCreatorOpenSource) {
onOpenNodeCreator({ createNodeActive: true, source });
}
function onOpenNodeCreator(options: ToggleNodeCreatorOptions) {
nodeCreatorStore.openNodeCreator(options);
}
function onCreateSticky() {
void onAddNodesAndConnections({ nodes: [{ type: STICKY_NODE_TYPE }], connections: [] });
}
function onClickConnectionAdd(connection: Connection) {
nodeCreatorStore.openNodeCreatorForConnectingNode({
connection,
@ -1156,6 +1246,7 @@ onBeforeUnmount(() => {
@update:node:active="onSetNodeActive"
@update:node:selected="onSetNodeSelected"
@update:node:enabled="onToggleNodeDisabled"
@update:node:name="onOpenRenameNodeModal"
@update:node:parameters="onUpdateNodeParameters"
@run:node="onRunWorkflowToNode"
@delete:node="onDeleteNode"
@ -1164,6 +1255,17 @@ onBeforeUnmount(() => {
@delete:connection="onDeleteConnection"
@click:connection:add="onClickConnectionAdd"
@click:pane="onClickPane"
@create:node="onOpenNodeCreatorFromCanvas"
@create:sticky="onCreateSticky"
@delete:nodes="onDeleteNodes"
@update:nodes:enabled="onToggleNodesDisabled"
@update:nodes:pin="onPinNodes"
@duplicate:nodes="onDuplicateNodes"
@copy:nodes="onCopyNodes"
@cut:nodes="onCutNodes"
@run:workflow="onRunWorkflow"
@save:workflow="onSaveWorkflow"
@create:workflow="onCreateWorkflow"
>
<div :class="$style.executionButtons">
<CanvasRunWorkflowButton

View file

@ -17,7 +17,7 @@
@touchmove="canvasPanning.onMouseMove"
@mousedown="mouseDown"
@mouseup="mouseUp"
@contextmenu="contextMenu.open"
@contextmenu="onContextMenu"
@wheel="canvasStore.wheelScroll"
>
<div
@ -374,7 +374,7 @@ import { useDeviceSupport } from 'n8n-design-system';
import { useDebounce } from '@/composables/useDebounce';
import { useExecutionsStore } from '@/stores/executions.store';
import { useCanvasPanning } from '@/composables/useCanvasPanning';
import { tryToParseNumber } from '@/utils/typesUtils';
import { isPresent, tryToParseNumber } from '@/utils/typesUtils';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useProjectsStore } from '@/stores/projects.store';
@ -4583,7 +4583,16 @@ export default defineComponent({
}
}
},
onContextMenuAction(action: ContextMenuAction, nodes: INode[]): void {
onContextMenu(event: MouseEvent) {
this.contextMenu.open(event, {
source: 'canvas',
nodeIds: this.uiStore.selectedNodes.map((node) => node.id),
});
},
onContextMenuAction(action: ContextMenuAction, nodeIds: string[]): void {
const nodes = nodeIds
.map((nodeId) => this.workflowsStore.getNodeById(nodeId))
.filter(isPresent);
switch (action) {
case 'copy':
this.copyNodes(nodes);