mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
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:
@@ -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);
|
||||
|
||||
+24
@@ -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
|
||||
|
||||
+6
-1
@@ -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
@@ -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')] });
|
||||
|
||||
+6
-1
@@ -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(
|
||||
|
||||
+18
@@ -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'];
|
||||
|
||||
+9
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user