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
16 changed files with 573 additions and 168 deletions

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