mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat(editor): Add context menu to canvas v2 (no-changelog) (#10088)
This commit is contained in:
parent
45affe5d89
commit
5b440a7679
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
77
packages/editor-ui/src/composables/useKeybindings.ts
Normal file
77
packages/editor-ui/src/composables/useKeybindings.ts
Normal 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);
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue