feat(core): Disable keyboard shortcuts for save/publish/unpublish/create new in canvas-only mode (no-changelog) (#36001)

This commit is contained in:
Konstantin Tieber 2026-08-11 10:09:47 +02:00 committed by GitHub
parent 72235d32f9
commit fd701761dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 81 additions and 14 deletions

View File

@ -5,6 +5,7 @@ import MainHeader from '@/app/components/MainHeader/MainHeader.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useCollaborationStore } from '@/features/collaboration/collaboration/collaboration.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { STORES } from '@n8n/stores';
import { WorkflowIdKey, WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import { computed, shallowRef } from 'vue';
@ -95,11 +96,14 @@ describe('MainHeader', () => {
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let sourceControlStore: MockedStore<typeof useSourceControlStore>;
let collaborationStore: MockedStore<typeof useCollaborationStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
beforeEach(() => {
workflowsStore = mockedStore(useWorkflowsStore);
sourceControlStore = mockedStore(useSourceControlStore);
collaborationStore = mockedStore(useCollaborationStore);
settingsStore = mockedStore(useSettingsStore);
settingsStore.settings.canvasOnly = false;
workflowsStore.setWorkflowId('1');
workflowDocumentStore.hydrate({
@ -132,6 +136,16 @@ describe('MainHeader', () => {
expect(workflowDetails).toBeInTheDocument();
});
// Especially important because WorkflowHeaderDraftPublishActions.vue child component of MainHeader
// registers keyboard shortcuts like Cmd+S for saving, Cmd+U for unpublish and Cmd+P for publish.
it('should not mount WorkflowDetails when canvas-only mode is on', () => {
settingsStore.settings.canvasOnly = true;
const { queryByTestId } = renderComponent();
expect(queryByTestId('workflow-details-stub')).not.toBeInTheDocument();
});
// Regression: the header renders before the workflow document store is set
// (e.g. the blank-canvas boot window). It must not throw when no NDV store is
// available — it uses injectNDVStoreIfProvided() and guards the access.

View File

@ -259,18 +259,20 @@ async function onWorkflowDeactivated() {
[$style['canvas-only']]: settingsStore.isCanvasOnly,
}"
>
<div v-show="!hideMenuBar && !settingsStore.isCanvasOnly" :class="$style['top-menu']">
<WorkflowDetails
v-if="workflowName"
:id="workflowId"
:tags="workflowTags"
:name="workflowName"
:current-folder="parentFolderForBreadcrumbs"
:is-archived="workflowIsArchived"
:description="workflowDescription"
@workflow:deactivated="onWorkflowDeactivated"
/>
</div>
<template v-if="!settingsStore.isCanvasOnly">
<div v-show="!hideMenuBar" :class="$style['top-menu']">
<WorkflowDetails
v-if="workflowName"
:id="workflowId"
:tags="workflowTags"
:name="workflowName"
:current-folder="parentFolderForBreadcrumbs"
:is-archived="workflowIsArchived"
:description="workflowDescription"
@workflow:deactivated="onWorkflowDeactivated"
/>
</div>
</template>
<TabBar
v-if="onWorkflowPage"
:items="tabBarItems"

View File

@ -39,6 +39,9 @@ import { useUIStore } from '@/app/stores/ui.store';
import { createTestNode } from '@/__tests__/mocks';
import { MESSAGE_AN_AGENT_NODE_TYPE } from '@/app/constants/nodeTypes';
import { useAgentNodeCanvasGeometryStore } from '@/features/agents/agentNodeCanvasGeometry.store';
import { mockedStore } from '@/__tests__/utils';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { defaultSettings } from '@/__tests__/defaults';
// Instantiates a store that derives the workflow id from the route. These tests run
// without a router, so resolve the id directly.
@ -1008,6 +1011,46 @@ describe('Canvas', () => {
expect(emitted()['copy:nodes']).toEqual([[['node-1', 'node-2']]]);
});
describe('shortcuts disabled in canvas-only mode', () => {
const shortcuts = [
{ name: 'save:workflow', event: { key: 's', ctrlKey: true, metaKey: true } },
{
name: 'create:workflow',
event: { key: 'n', ctrlKey: true, metaKey: true, altKey: true },
},
];
const setCanvasOnly = (canvasOnly: boolean) => {
const settingsStore = mockedStore(useSettingsStore);
settingsStore.settings = { ...defaultSettings, canvasOnly };
};
it.each(shortcuts)('emits `$name` when canvas-only mode is off', async ({ name, event }) => {
setCanvasOnly(false);
const { container, emitted } = renderComponent();
await waitFor(() => expect(container.querySelector('.vue-flow')).toBeInTheDocument());
await fireEvent.keyDown(document, event);
expect(emitted()[name]).toEqual([[]]);
});
it.each(shortcuts)(
'does not emit `$name` when canvas-only mode is on',
async ({ name, event }) => {
setCanvasOnly(true);
const { container, emitted } = renderComponent();
await waitFor(() => expect(container.querySelector('.vue-flow')).toBeInTheDocument());
await fireEvent.keyDown(document, event);
expect(emitted()[name]).toBeUndefined();
},
);
});
it('should emit `update:nodes:position` event', async () => {
const nodes = [createCanvasNodeElement()];
const { container, emitted } = renderComponent({

View File

@ -18,6 +18,7 @@ import { useSelectionValidation } from '@/app/composables/useSelectionValidation
import { useToast } from '@n8n/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { useUsersStore } from '@n8n/stores/users.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { NODE_CREATOR_SHORTCUT_COACHMARK_KEY } from '@/features/shared/nodeCreator/composables/useNodeCreatorShortcutCoachmark';
import type { NodeCreatorOpenSource } from '@/Interface';
@ -206,6 +207,7 @@ const props = withDefaults(
const { isMobileDevice, controlKeyCode } = useDeviceSupport();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const message = useMessage();
const toast = useToast();
@ -533,11 +535,17 @@ const keyMap = computed(() => {
},
shift_s: () => emit('create:sticky'),
shift_f: () => emit('toggle:focus-panel'),
ctrl_alt_n: () => emit('create:workflow'),
ctrl_alt_n: {
disabled: () => settingsStore.isCanvasOnly,
run: () => emit('create:workflow'),
},
ctrl_enter: () => emit('run:workflow'),
// override the default cmd+s which saves the page html as file
// also triggers manual save when autosave is disabled
ctrl_s: () => emit('save:workflow'),
ctrl_s: {
disabled: () => settingsStore.isCanvasOnly,
run: () => emit('save:workflow'),
},
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
alt_x: emitWithSelectedNodes((ids) => emit('extract-workflow', ids)),
c: () => emit('start-chat'),