mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Change rename node keyboard shortcut to Space on new canvas (#11872)
This commit is contained in:
@@ -50,7 +50,7 @@
|
||||
"@vue-flow/minimap": "^1.5.2",
|
||||
"@vue-flow/node-resizer": "^1.4.0",
|
||||
"@vueuse/components": "^10.11.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/core": "catalog:frontend",
|
||||
"axios": "catalog:",
|
||||
"bowser": "2.11.0",
|
||||
"change-case": "^5.4.4",
|
||||
|
||||
@@ -55,7 +55,7 @@ export function createCanvasNodeData({
|
||||
|
||||
export function createCanvasNodeElement({
|
||||
id = '1',
|
||||
type = 'node',
|
||||
type = 'default',
|
||||
label = 'Node',
|
||||
position = { x: 100, y: 100 },
|
||||
data,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CanvasConnection, CanvasNode } from '@/types';
|
||||
import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/data';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||
import { useVueFlow } from '@vue-flow/core';
|
||||
|
||||
const matchMedia = global.window.matchMedia;
|
||||
// @ts-expect-error Initialize window object
|
||||
@@ -18,12 +19,21 @@ vi.mock('n8n-design-system', async (importOriginal) => {
|
||||
return { ...actual, useDeviceSupport: vi.fn(() => ({ isCtrlKeyPressed: vi.fn() })) };
|
||||
});
|
||||
|
||||
const canvasId = 'canvas';
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
beforeEach(() => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
renderComponent = createComponentRenderer(Canvas, { pinia });
|
||||
renderComponent = createComponentRenderer(Canvas, {
|
||||
pinia,
|
||||
props: {
|
||||
id: canvasId,
|
||||
nodes: [],
|
||||
connections: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,7 +96,7 @@ describe('Canvas', () => {
|
||||
expect(container.querySelector(`[data-id="${connections[0].id}"]`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle `update:nodes:position` event', async () => {
|
||||
it('should emit `update:nodes:position` event', async () => {
|
||||
const nodes = [createCanvasNodeElement()];
|
||||
const { container, emitted } = renderComponent({
|
||||
props: {
|
||||
@@ -122,6 +132,55 @@ describe('Canvas', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should emit `update:node:name` event', async () => {
|
||||
const nodes = [createCanvasNodeElement()];
|
||||
const { container, emitted } = renderComponent({
|
||||
props: {
|
||||
nodes,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
|
||||
|
||||
const node = container.querySelector(`[data-id="${nodes[0].id}"]`) as Element;
|
||||
|
||||
const { addSelectedNodes, nodes: graphNodes } = useVueFlow({ id: canvasId });
|
||||
addSelectedNodes(graphNodes.value);
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.selected')).toBeInTheDocument());
|
||||
|
||||
await fireEvent.keyDown(node, { key: ' ', view: window });
|
||||
await fireEvent.keyUp(node, { key: ' ', view: window });
|
||||
|
||||
expect(emitted()['update:node:name']).toEqual([['1']]);
|
||||
});
|
||||
|
||||
it('should not emit `update:node:name` event if long key press', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const nodes = [createCanvasNodeElement()];
|
||||
const { container, emitted } = renderComponent({
|
||||
props: {
|
||||
nodes,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(1));
|
||||
|
||||
const node = container.querySelector(`[data-id="${nodes[0].id}"]`) as Element;
|
||||
|
||||
const { addSelectedNodes, nodes: graphNodes } = useVueFlow({ id: canvasId });
|
||||
addSelectedNodes(graphNodes.value);
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.selected')).toBeInTheDocument());
|
||||
|
||||
await fireEvent.keyDown(node, { key: ' ', view: window });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await fireEvent.keyUp(node, { key: ' ', view: window });
|
||||
|
||||
expect(emitted()['update:node:name']).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('minimap', () => {
|
||||
const minimapVisibilityDelay = 1000;
|
||||
const minimapTransitionDuration = 300;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { computed, onMounted, onUnmounted, provide, ref, toRef, useCssModule, wa
|
||||
import type { EventBus } from 'n8n-design-system';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
|
||||
import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu';
|
||||
import { useKeybindings } from '@/composables/useKeybindings';
|
||||
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
|
||||
@@ -145,29 +146,55 @@ const classes = computed(() => ({
|
||||
}));
|
||||
|
||||
/**
|
||||
* Key bindings
|
||||
*/
|
||||
|
||||
const disableKeyBindings = computed(() => !props.keyBindings);
|
||||
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#whitespace_keys
|
||||
* Panning and Selection key bindings
|
||||
*/
|
||||
|
||||
// @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#whitespace_keys
|
||||
const panningKeyCode = ref<string[] | true>(isMobileDevice ? true : [' ', controlKeyCode]);
|
||||
const panningMouseButton = ref<number[] | true>(isMobileDevice ? true : [1]);
|
||||
const selectionKeyCode = ref<string | true | null>(isMobileDevice ? 'Shift' : true);
|
||||
|
||||
onKeyDown(panningKeyCode.value, () => {
|
||||
selectionKeyCode.value = null;
|
||||
panningMouseButton.value = [0, 1];
|
||||
});
|
||||
onKeyDown(
|
||||
panningKeyCode.value,
|
||||
() => {
|
||||
selectionKeyCode.value = null;
|
||||
panningMouseButton.value = [0, 1];
|
||||
},
|
||||
{
|
||||
dedupe: true,
|
||||
},
|
||||
);
|
||||
|
||||
onKeyUp(panningKeyCode.value, () => {
|
||||
selectionKeyCode.value = true;
|
||||
panningMouseButton.value = [1];
|
||||
});
|
||||
|
||||
/**
|
||||
* Rename node key bindings
|
||||
* We differentiate between short and long press because the space key is also used for activating panning
|
||||
*/
|
||||
|
||||
const renameKeyCode = ' ';
|
||||
|
||||
useShortKeyPress(
|
||||
renameKeyCode,
|
||||
() => {
|
||||
if (lastSelectedNode.value) {
|
||||
emit('update:node:name', lastSelectedNode.value.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: toRef(props, 'readOnly'),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Key bindings
|
||||
*/
|
||||
|
||||
const disableKeyBindings = computed(() => !props.keyBindings);
|
||||
|
||||
function selectLeftNode(id: string) {
|
||||
const incomingNodes = getIncomingNodes(id);
|
||||
const previousNode = incomingNodes[0];
|
||||
|
||||
@@ -745,6 +745,9 @@ function onRenameNode(parameterData: IUpdateInformation) {
|
||||
|
||||
async function onOpenRenameNodeModal(id: string) {
|
||||
const currentName = workflowsStore.getNodeById(id)?.name ?? '';
|
||||
|
||||
if (!keyBindingsEnabled.value || document.querySelector('.rename-prompt')) return;
|
||||
|
||||
try {
|
||||
const promptResponsePromise = message.prompt(
|
||||
i18n.baseText('nodeView.prompt.newName') + ':',
|
||||
|
||||
@@ -24,9 +24,6 @@
|
||||
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
|
||||
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "catalog:frontend"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/frontend-eslint-config": "workspace:*",
|
||||
"@n8n/frontend-typescript-config": "workspace:*",
|
||||
@@ -36,6 +33,8 @@
|
||||
"@testing-library/vue": "catalog:frontend",
|
||||
"@vitejs/plugin-vue": "catalog:frontend",
|
||||
"@vue/tsconfig": "catalog:frontend",
|
||||
"@vueuse/core": "catalog:frontend",
|
||||
"vue": "catalog:frontend",
|
||||
"tsup": "catalog:frontend",
|
||||
"typescript": "catalog:frontend",
|
||||
"vite": "catalog:frontend",
|
||||
@@ -43,5 +42,9 @@
|
||||
"vitest": "catalog:frontend",
|
||||
"vue-tsc": "catalog:frontend"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vueuse/core": "catalog:frontend",
|
||||
"vue": "catalog:frontend"
|
||||
},
|
||||
"license": "See LICENSE.md file in the root of the repository"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { onKeyDown, onKeyUp } from '@vueuse/core';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useShortKeyPress } from './useShortKeyPress';
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
onKeyDown: vi.fn(),
|
||||
onKeyUp: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useShortKeyPress', () => {
|
||||
it('should call the function on short key press', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn();
|
||||
const key = 'a';
|
||||
const threshold = 300;
|
||||
const disabled = ref(false);
|
||||
|
||||
useShortKeyPress(key, fn, { threshold, disabled });
|
||||
|
||||
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
|
||||
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
|
||||
|
||||
keyDownHandler(new KeyboardEvent('keydown', { key }));
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
keyUpHandler(new KeyboardEvent('keydown', { key }));
|
||||
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call the function if key press duration exceeds threshold', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn();
|
||||
const key = 'a';
|
||||
const threshold = 300;
|
||||
const disabled = ref(false);
|
||||
|
||||
useShortKeyPress(key, fn, { threshold, disabled });
|
||||
|
||||
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
|
||||
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
|
||||
|
||||
keyDownHandler(new KeyboardEvent('keydown', { key }));
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
keyUpHandler(new KeyboardEvent('keydown', { key }));
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call the function if disabled is true', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn();
|
||||
const key = 'a';
|
||||
const threshold = 300;
|
||||
const disabled = ref(true);
|
||||
|
||||
useShortKeyPress(key, fn, { threshold, disabled });
|
||||
|
||||
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
|
||||
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
|
||||
|
||||
keyDownHandler(new KeyboardEvent('keydown', { key }));
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
keyUpHandler(new KeyboardEvent('keydown', { key }));
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
41
packages/frontend/@n8n/composables/src/useShortKeyPress.ts
Normal file
41
packages/frontend/@n8n/composables/src/useShortKeyPress.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { onKeyDown, onKeyUp } from '@vueuse/core';
|
||||
import type { KeyFilter } from '@vueuse/core';
|
||||
import { ref, unref } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export function useShortKeyPress(
|
||||
key: KeyFilter,
|
||||
fn: () => void,
|
||||
{
|
||||
dedupe = true,
|
||||
threshold = 300,
|
||||
disabled = false,
|
||||
}: {
|
||||
dedupe?: boolean;
|
||||
threshold?: number;
|
||||
disabled?: MaybeRefOrGetter<boolean>;
|
||||
},
|
||||
) {
|
||||
const keyDownTime = ref<number | null>(null);
|
||||
|
||||
onKeyDown(
|
||||
key,
|
||||
() => {
|
||||
if (unref(disabled)) return;
|
||||
|
||||
keyDownTime.value = Date.now();
|
||||
},
|
||||
{
|
||||
dedupe,
|
||||
},
|
||||
);
|
||||
|
||||
onKeyUp(key, () => {
|
||||
if (unref(disabled) || !keyDownTime.value) return;
|
||||
|
||||
const isShortPress = Date.now() - keyDownTime.value < threshold;
|
||||
if (isShortPress) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
}
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -97,6 +97,9 @@ catalogs:
|
||||
'@vue/tsconfig':
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
'@vueuse/core':
|
||||
specifier: ^10.11.0
|
||||
version: 10.11.0
|
||||
highlight.js:
|
||||
specifier: ^11.8.0
|
||||
version: 11.9.0
|
||||
@@ -1490,7 +1493,7 @@ importers:
|
||||
specifier: ^10.11.0
|
||||
version: 10.11.0(vue@3.5.13(typescript@5.7.2))
|
||||
'@vueuse/core':
|
||||
specifier: ^10.11.0
|
||||
specifier: catalog:frontend
|
||||
version: 10.11.0(vue@3.5.13(typescript@5.7.2))
|
||||
axios:
|
||||
specifier: 'catalog:'
|
||||
@@ -1702,10 +1705,6 @@ importers:
|
||||
version: 2.1.10(patch_hash=e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f)(typescript@5.7.2)
|
||||
|
||||
packages/frontend/@n8n/composables:
|
||||
dependencies:
|
||||
vue:
|
||||
specifier: catalog:frontend
|
||||
version: 3.5.13(typescript@5.7.2)
|
||||
devDependencies:
|
||||
'@n8n/frontend-eslint-config':
|
||||
specifier: workspace:*
|
||||
@@ -1731,6 +1730,9 @@ importers:
|
||||
'@vue/tsconfig':
|
||||
specifier: catalog:frontend
|
||||
version: 0.7.0(typescript@5.7.2)(vue@3.5.13(typescript@5.7.2))
|
||||
'@vueuse/core':
|
||||
specifier: catalog:frontend
|
||||
version: 10.11.0(vue@3.5.13(typescript@5.7.2))
|
||||
tsup:
|
||||
specifier: catalog:frontend
|
||||
version: 8.3.6(@microsoft/api-extractor@7.48.0(@types/node@18.16.16))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.7.2)
|
||||
@@ -1746,6 +1748,9 @@ importers:
|
||||
vitest:
|
||||
specifier: catalog:frontend
|
||||
version: 3.0.5(@types/debug@4.1.12)(@types/node@18.16.16)(jiti@1.21.0)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1)
|
||||
vue:
|
||||
specifier: catalog:frontend
|
||||
version: 3.5.13(typescript@5.7.2)
|
||||
vue-tsc:
|
||||
specifier: ^2.1.10
|
||||
version: 2.1.10(patch_hash=e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f)(typescript@5.7.2)
|
||||
@@ -13329,8 +13334,8 @@ packages:
|
||||
vue-component-type-helpers@2.1.10:
|
||||
resolution: {integrity: sha512-lfgdSLQKrUmADiSV6PbBvYgQ33KF3Ztv6gP85MfGaGaSGMTXORVaHT1EHfsqCgzRNBstPKYDmvAV9Do5CmJ07A==}
|
||||
|
||||
vue-component-type-helpers@2.2.0:
|
||||
resolution: {integrity: sha512-cYrAnv2me7bPDcg9kIcGwjJiSB6Qyi08+jLDo9yuvoFQjzHiPTzML7RnkJB1+3P6KMsX/KbCD4QE3Tv/knEllw==}
|
||||
vue-component-type-helpers@2.2.2:
|
||||
resolution: {integrity: sha512-6lLY+n2xz2kCYshl59mL6gy8OUUTmkscmDFMO8i7Lj+QKwgnIFUZmM1i/iTYObtrczZVdw7UakPqDTGwVSGaRg==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -18482,7 +18487,7 @@ snapshots:
|
||||
ts-dedent: 2.2.0
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.7.2)
|
||||
vue-component-type-helpers: 2.2.0
|
||||
vue-component-type-helpers: 2.2.2
|
||||
|
||||
'@supabase/auth-js@2.65.0':
|
||||
dependencies:
|
||||
@@ -19509,7 +19514,7 @@ snapshots:
|
||||
'@vue/test-utils@2.4.6':
|
||||
dependencies:
|
||||
js-beautify: 1.14.9
|
||||
vue-component-type-helpers: 2.2.0
|
||||
vue-component-type-helpers: 2.2.2
|
||||
|
||||
'@vue/tsconfig@0.7.0(typescript@5.7.2)(vue@3.5.13(typescript@5.7.2))':
|
||||
optionalDependencies:
|
||||
@@ -27795,7 +27800,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@2.1.10: {}
|
||||
|
||||
vue-component-type-helpers@2.2.0: {}
|
||||
vue-component-type-helpers@2.2.2: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.7.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -37,6 +37,7 @@ catalogs:
|
||||
'@testing-library/user-event': ^14.6.0
|
||||
'@testing-library/vue': ^8.1.0
|
||||
'@vue/tsconfig': ^0.7.0
|
||||
'@vueuse/core': ^10.11.0
|
||||
'@vitest/coverage-v8': ^3.0.5
|
||||
'@vitejs/plugin-vue': ^5.2.1
|
||||
'@sentry/vue': ^8.33.1
|
||||
|
||||
Reference in New Issue
Block a user