feat(editor): Hide convert to sub-workflow when Execute Workflow nodes are excluded (#36499)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ali Elkhateeb
2026-08-18 12:45:30 +00:00
committed by GitHub
parent 47f4bc8d1e
commit 697d53c8d0
16 changed files with 207 additions and 10 deletions
@@ -100,6 +100,7 @@ export interface FrontendSettings {
executionTimeout: number;
maxExecutionTimeout: number;
workflowCallerPolicyDefaultOption: WorkflowSettings.CallerPolicy;
excludeNodes: string[];
oauthCallbackUrls: {
oauth1: string;
oauth2: string;
@@ -276,6 +276,15 @@ describe('FrontendService', () => {
);
});
it('should expose excluded node types from NODES_EXCLUDE', async () => {
globalConfig.nodes.exclude = ['n8n-nodes-base.executeWorkflow'];
const { service } = createMockService();
const settings = await service.getSettings();
expect(settings.excludeNodes).toEqual(['n8n-nodes-base.executeWorkflow']);
});
it('should enable the AI Gateway when configured and licensed', async () => {
globalConfig.aiAssistant.baseUrl = 'https://ai-assistant.n8n.io';
globalConfig.aiGateway.enabled = true;
@@ -11,6 +11,10 @@ import { BinaryDataConfig, InstanceSettings } from 'n8n-core';
import type { ICredentialType, INodeTypeBaseDescription, INodeTypeDescription } from 'n8n-workflow';
import path from 'path';
import { AiUsageService } from './ai-usage.service';
import { UrlService } from './url.service';
import { WorkflowReviewPolicyService } from './workflow-review-policy.service';
import config from '@/config';
import { inE2ETests, N8N_VERSION } from '@/constants';
import { isWorkflowReviewsFeatureAvailable } from '@/constants/workflow-reviews';
@@ -33,10 +37,6 @@ import {
getWorkflowHistoryPruneTime,
} from '@/workflows/workflow-history/workflow-history-helper';
import { AiUsageService } from './ai-usage.service';
import { UrlService } from './url.service';
import { WorkflowReviewPolicyService } from './workflow-review-policy.service';
const DYNAMIC_BANNER_FILTERS_CACHE_TTL = 30 * Time.seconds.toMilliseconds;
/**
@@ -220,6 +220,7 @@ export class FrontendService {
executionTimeout: this.globalConfig.executions.timeout,
maxExecutionTimeout: this.globalConfig.executions.maxTimeout,
workflowCallerPolicyDefaultOption: this.globalConfig.workflows.callerPolicyDefaultOption,
excludeNodes: this.globalConfig.nodes.exclude,
timezone: this.globalConfig.generic.timezone,
urlBaseWebhook: this.urlService.getWebhookBaseUrl(),
urlBaseEditor: instanceBaseUrl,
@@ -146,6 +146,74 @@ describe('settings.store', () => {
});
});
describe('isExecuteWorkflowNodeExcluded', () => {
it('should return true when executeWorkflow is in excludeNodes', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
excludeNodes: ['n8n-nodes-base.executeWorkflow'],
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
expect(settingsStore.isExecuteWorkflowNodeExcluded).toBe(true);
});
it('should return false when executeWorkflow is not excluded', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
excludeNodes: ['n8n-nodes-base.executeCommand'],
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
expect(settingsStore.isExecuteWorkflowNodeExcluded).toBe(false);
});
it('should return false when only executeWorkflowTrigger is excluded', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
excludeNodes: ['n8n-nodes-base.executeWorkflowTrigger'],
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
expect(settingsStore.isExecuteWorkflowNodeExcluded).toBe(false);
});
});
describe('isSubworkflowConversionDisabled', () => {
it.each([
[['n8n-nodes-base.executeWorkflow']],
[['n8n-nodes-base.executeWorkflowTrigger']],
[['n8n-nodes-base.executeWorkflow', 'n8n-nodes-base.executeWorkflowTrigger']],
])('should return true when %j is excluded', async (excludeNodes) => {
getSettings.mockResolvedValueOnce({
...mockSettings,
excludeNodes,
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
expect(settingsStore.isSubworkflowConversionDisabled).toBe(true);
});
it('should return false when both sub-workflow nodes are available', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
excludeNodes: ['n8n-nodes-base.executeCommand'],
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
expect(settingsStore.isSubworkflowConversionDisabled).toBe(false);
});
});
describe('isCrdtCollaborationEnabled', () => {
it('should return true when collaboration.crdt is local', async () => {
getSettings.mockResolvedValueOnce({
@@ -13,7 +13,12 @@ import * as moduleSettingsApi from '@n8n/rest-api-client/api/module-settings';
import * as settingsApi from '@n8n/rest-api-client/api/settings';
import { testHealthEndpoint } from '@n8n/rest-api-client/api/templates';
import Bowser from 'bowser';
import type { IDataObject, WorkflowSettings } from 'n8n-workflow';
import {
EXECUTE_WORKFLOW_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
type IDataObject,
type WorkflowSettings,
} from 'n8n-workflow';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
@@ -258,6 +263,20 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
() => settings.value.workflowCallerPolicyDefaultOption,
);
const isNodeTypeExcluded = (nodeType: string) => {
const excludeNodes = settings.value.excludeNodes;
return Array.isArray(excludeNodes) && excludeNodes.includes(nodeType);
};
const isExecuteWorkflowNodeExcluded = computed(() =>
isNodeTypeExcluded(EXECUTE_WORKFLOW_NODE_TYPE),
);
const isSubworkflowConversionDisabled = computed(
() =>
isExecuteWorkflowNodeExcluded.value || isNodeTypeExcluded(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE),
);
const permanentlyDismissedBanners = computed(() => settings.value.banners?.dismissed ?? []);
const isCommunityPlan = computed(() => planName.value.toLowerCase() === 'community');
@@ -490,6 +509,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isMultiMain,
isWorkerViewAvailable,
workflowCallerPolicyDefaultOption,
isExecuteWorkflowNodeExcluded,
isSubworkflowConversionDisabled,
permanentlyDismissedBanners,
saveDataErrorExecution,
saveDataSuccessExecution,
@@ -134,6 +134,7 @@ export const defaultSettings: FrontendSettings = {
maxSize: 0,
},
workflowCallerPolicyDefaultOption: 'any',
excludeNodes: [],
workflowTagsDisabled: false,
workflowsAutosaveDisabled: false,
variables: {
@@ -126,6 +126,7 @@ vi.mock('vue-router', () => ({
import { useWorkflowExtraction } from '@/app/composables/useWorkflowExtraction';
import { RemoveNodeGroupCommand, UpdateNodeGroupCommand } from '@/app/models/history';
import { useSettingsStore } from '@n8n/stores/settings.store';
function makeNode(name: string, position: [number, number] = [0, 0]): INodeUi {
return {
@@ -203,6 +204,17 @@ describe('useWorkflowExtraction', () => {
expect(mockTelemetry.track).not.toHaveBeenCalled();
});
it('does not start extraction when executeWorkflow is excluded', () => {
const settingsStore = useSettingsStore();
vi.spyOn(settingsStore, 'isSubworkflowConversionDisabled', 'get').mockReturnValue(true);
const { extractWorkflow } = useWorkflowExtraction();
extractWorkflow(['id-A']);
expect(mockTelemetry.track).not.toHaveBeenCalled();
expect(mockUIStore.openModalWithData).not.toHaveBeenCalled();
});
it('includes attached sub-nodes when starting extraction', () => {
const nodeA = makeNode('A', [0, 0]);
const agentB = makeNode('Agent B', [200, 0]);
@@ -27,6 +27,7 @@ import { useSelectionValidation } from './useSelectionValidation';
import type { AddedNode, INodeUi, IWorkflowDb } from '@/Interface';
import type { WorkflowDataCreate } from '@n8n/rest-api-client/api/workflows';
import { useI18n } from '@n8n/i18n';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { PUSH_NODES_OFFSET } from '@/app/utils/nodeViewUtils';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
@@ -47,6 +48,7 @@ export function useWorkflowExtraction() {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const nodeTypesStore = useNodeTypesStore();
const settingsStore = useSettingsStore();
const toast = useToast();
const router = useRouter();
const historyStore = useHistoryStore();
@@ -689,7 +691,7 @@ export function useWorkflowExtraction() {
* @param nodeIds the ids to be extracted from the current workflow into a sub-workflow
*/
function extractWorkflow(nodeIds: string[]) {
if (nodeIds.length === 0) return;
if (nodeIds.length === 0 || settingsStore.isSubworkflowConversionDisabled) return;
const success = tryExtractNodesIntoSubworkflow(nodeIds);
trackStartExtractWorkflow(nodeIds.length, success);
@@ -23,6 +23,7 @@ import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useFocusedNodesStore } from '@/features/ai/assistant/focusedNodes.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
@@ -184,6 +185,29 @@ describe('useContextMenu', () => {
});
});
describe('extract_sub_workflow gating', () => {
it('hides convert to sub-workflow when executeWorkflow is excluded', () => {
const settingsStore = useSettingsStore();
vi.spyOn(settingsStore, 'isSubworkflowConversionDisabled', 'get').mockReturnValue(true);
const { open, actions } = useContextMenu();
open(mockEvent, { source: 'canvas', nodeIds: selectedNodes.map((n) => n.id) });
expect(actions.value.some((action) => action.id === 'extract_sub_workflow')).toBe(false);
});
it('hides convert to sub-workflow on a group target when executeWorkflow is excluded', () => {
const settingsStore = useSettingsStore();
vi.spyOn(settingsStore, 'isSubworkflowConversionDisabled', 'get').mockReturnValue(true);
const group = workflowDocumentStore.createGroup([nodes[0].id, nodes[1].id], 'My group');
const { open, actions } = useContextMenu();
open(mockEvent, { source: 'group', groupId: group.id, nodeIds: group.nodeIds });
expect(actions.value.some((action) => action.id === 'extract_sub_workflow')).toBe(false);
});
});
describe('group_nodes gating', () => {
beforeEach(() => {
// Connect the first two nodes so they form a groupable subgraph
@@ -11,6 +11,7 @@ import { useCollaborationStore } from '@/features/collaboration/collaboration/co
import { useFocusedNodesStore } from '@/features/ai/assistant/focusedNodes.store';
import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import { useSettingsStore } from '@n8n/stores/settings.store';
import type { INode, INodeTypeDescription } from 'n8n-workflow';
import { NodeHelpers, WEBHOOK_NODE_TYPE } from 'n8n-workflow';
import { computed, type ComputedRef } from 'vue';
@@ -81,6 +82,7 @@ export function useContextMenuItems(
): ComputedRef<Item[]> {
const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const settingsStore = useSettingsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const sourceControlStore = useSourceControlStore();
const collaborationStore = useCollaborationStore();
@@ -262,7 +264,10 @@ export function useContextMenuItems(
}
const onlyStickies = nodes.every((node) => node.type === STICKY_NODE_TYPE);
const canExtract = nodes.some(isExecutable) && !nodes.every(isAiSubNode);
const canExtract =
!settingsStore.isSubworkflowConversionDisabled &&
nodes.some(isExecutable) &&
!nodes.every(isAiSubNode);
const i18nOptions = isGroupTarget
? {
@@ -805,6 +805,14 @@ describe('Canvas', () => {
expect(rendered.queryByTestId('canvas-node-group-extract')).toBeNull();
});
it('hides the convert button when executeWorkflow is excluded', async () => {
vi.spyOn(useSettingsStore(), 'isSubworkflowConversionDisabled', 'get').mockReturnValue(true);
const rendered = await setupExpandedGroupWithLooseNodes();
expect(rendered.queryByTestId('canvas-node-group-extract')).toBeNull();
});
});
describe('expanded group selection', () => {
@@ -440,6 +440,7 @@ const { isSelectionExtractable } = useSelectionValidation();
// Groups that can be extracted to sub-workflows
const extractableGroupIds = computed(() => {
const ids = new Set<string>();
if (settingsStore.isSubworkflowConversionDisabled) return ids;
for (const group of workflowDocumentStore.value.allGroups) {
if (isSelectionExtractable(group.nodeIds).valid) {
ids.add(group.id);
@@ -547,7 +548,10 @@ const keyMap = computed(() => {
run: () => emit('save:workflow'),
},
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
alt_x: emitWithSelectedNodes((ids) => emit('extract-workflow', ids)),
alt_x: {
disabled: () => settingsStore.isSubworkflowConversionDisabled,
run: emitWithSelectedNodes((ids) => emit('extract-workflow', ids)),
},
c: () => emit('start-chat'),
r: emitWithLastSelectedNode((id) => emit('replace:node', id)),
shift_alt_u: emitWithLastSelectedNode((id) => emit('copy:test:url', id)),
@@ -10,6 +10,7 @@ import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
const isSelectionGroupableMock = vi.fn();
const isSelectionExtractableMock = vi.fn();
@@ -187,6 +188,15 @@ describe('CanvasSelectionToolbar', () => {
expect(wrapper.queryByTestId('canvas-selection-toolbar')).toBeNull();
});
it('hides Extract when executeWorkflow is excluded', () => {
vi.spyOn(useSettingsStore(), 'isSubworkflowConversionDisabled', 'get').mockReturnValue(true);
const wrapper = render({ selectedNodes: [makeNode('a'), makeNode('b')] });
expect(wrapper.getByTestId('canvas-selection-toolbar-group')).toBeTruthy();
expect(wrapper.queryByTestId('canvas-selection-toolbar-extract')).toBeNull();
});
it('creates a group when Group is clicked', async () => {
const store = workflowDocumentStore;
const wrapper = render({ selectedNodes: [makeNode('a'), makeNode('b')] });
@@ -9,6 +9,7 @@ import type { GraphNode } from '@vue-flow/core';
import { useVueFlowTransformPaneTeleport } from '../../../composables/useVueFlowTransformPaneTeleport';
import { useCanvasNodeGroupActions } from '../../../composables/useCanvasNodeGroupActions';
import { useSelectionValidation } from '@/app/composables/useSelectionValidation';
import { useSettingsStore } from '@n8n/stores/settings.store';
import type { BoundingBox } from '../../../canvas.types';
const TOOLBAR_OFFSET_PX = 12;
@@ -33,6 +34,7 @@ const props = withDefaults(
);
const i18n = useI18n();
const settingsStore = useSettingsStore();
const { teleportTarget } = useVueFlowTransformPaneTeleport();
const { isSelectionExtractable } = useSelectionValidation();
const { canGroup, groupSelection } = useCanvasNodeGroupActions(() => props.selectedNodes, {
@@ -47,7 +49,10 @@ const emit = defineEmits<{
const selectedNodeIds = computed(() => props.selectedNodes.map((node) => node.id));
const canExtractWorkflow = computed(
() => !props.readOnly && isSelectionExtractable(selectedNodeIds.value).valid,
() =>
!props.readOnly &&
!settingsStore.isSubworkflowConversionDisabled &&
isSelectionExtractable(selectedNodeIds.value).valid,
);
const isToolbarVisible = computed(
@@ -236,6 +236,24 @@ describe('WorkflowSettingsVue', () => {
expect(getByTestId('workflow-caller-policy')).toBeVisible();
});
it('should lock caller policy to none when executeWorkflow is excluded', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Sharing] = true;
vi.spyOn(settingsStore, 'isExecuteWorkflowNodeExcluded', 'get').mockReturnValue(true);
workflowDocumentStore.setSettings({
callerPolicy: 'workflowsFromAList',
callerIds: 'abc',
executionOrder: 'v1',
});
const { getByTestId, queryByTestId } = createComponent({ pinia });
await flushPromises();
expect(
within(getByTestId('workflow-caller-policy-select')).getByRole('combobox'),
).toBeDisabled();
expect(queryByTestId('workflow-caller-policy-workflow-ids')).not.toBeInTheDocument();
});
describe('Custom span attributes', () => {
beforeEach(() => {
settingsStore.settings.activeModules = ['dynamic-credentials', 'otel'];
@@ -913,6 +913,9 @@ onMounted(async () => {
workflowSettingsData.callerPolicy = defaultValues.value
.workflowCallerPolicy as WorkflowSettings.CallerPolicy;
}
if (settingsStore.isExecuteWorkflowNodeExcluded) {
workflowSettingsData.callerPolicy = 'none';
}
if (workflowSettingsData.executionTimeout === undefined) {
workflowSettingsData.executionTimeout = rootStore.executionTimeout;
}
@@ -1156,10 +1159,15 @@ onBeforeUnmount(() => {
<ElCol :span="14" class="ignore-key-press-canvas">
<N8nSelect
v-model="workflowSettings.callerPolicy"
:disabled="readOnlyEnv || !workflowPermissions.update"
:disabled="
readOnlyEnv ||
!workflowPermissions.update ||
settingsStore.isExecuteWorkflowNodeExcluded
"
:placeholder="i18n.baseText('workflowSettings.selectOption')"
filterable
:limit-popper-width="true"
data-test-id="workflow-caller-policy-select"
>
<N8nOption
v-for="option of workflowCallerPolicyOptions"