fix(editor): Deactivate workflow on save if trigger is missing (#15642)

This commit is contained in:
Suguru Inoue
2025-06-02 11:31:59 +02:00
committed by GitHub
parent d0b42d6339
commit 3ba6419710
3 changed files with 114 additions and 9 deletions

View File

@@ -2572,6 +2572,7 @@
"workflows.create.project.toast.title": "Workflow successfully created in {projectName}",
"workflows.create.folder.toast.title": "Workflow successfully created in \"{projectName}\", within \"{folderName}\"",
"workflows.create.project.toast.text": "All members from {projectName} will have access to this workflow.",
"workflows.deactivated": "Workflow deactivated",
"workflowSelectorParameterInput.createNewSubworkflow.name": "My Sub-Workflow",
"importCurlModal.title": "Import cURL command",
"importCurlModal.input.label": "cURL Command",

View File

@@ -12,10 +12,13 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
import { useTagsStore } from '@/stores/tags.store';
import { useUIStore } from '@/stores/ui.store';
import { createTestWorkflow } from '@/__tests__/mocks';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { WEBHOOK_NODE_TYPE, type AssignmentCollectionValue } from 'n8n-workflow';
import * as apiWebhooks from '../api/webhooks';
import { mockedStore } from '@/__tests__/utils';
import { nodeTypes } from '@/components/CanvasChat/__test__/data';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { CHAT_TRIGGER_NODE_TYPE } from '@/constants';
const getDuplicateTestWorkflow = (): IWorkflowDataUpdate => ({
name: 'Duplicate webhook test',
@@ -68,6 +71,7 @@ describe('useWorkflowHelpers', () => {
let workflowsEEStore: ReturnType<typeof useWorkflowsEEStore>;
let tagsStore: ReturnType<typeof useTagsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let nodeTypesStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
beforeAll(() => {
setActivePinia(createTestingPinia());
@@ -460,6 +464,7 @@ describe('useWorkflowHelpers', () => {
expect(await workflowHelpers.checkConflictingWebhooks('12345')).toEqual(null);
});
});
describe('executeData', () => {
it('should return empty execute data if no parent nodes', () => {
const { executeData } = useWorkflowHelpers({ router });
@@ -830,4 +835,58 @@ describe('useWorkflowHelpers', () => {
expect(result.source).toBeNull();
});
});
describe('saveCurrentWorkflow', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
workflowsStore = mockedStore(useWorkflowsStore);
nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.setNodeTypes(nodeTypes);
});
it('should save the current workflow', async () => {
const workflow = createTestWorkflow({
id: 'w0',
nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })],
active: true,
});
vi.spyOn(workflowsStore, 'fetchWorkflow').mockResolvedValue(workflow);
vi.spyOn(workflowsStore, 'updateWorkflow').mockResolvedValue(workflow);
workflowsStore.setWorkflow(workflow);
const { saveCurrentWorkflow } = useWorkflowHelpers({ router });
await saveCurrentWorkflow({ id: 'w0' });
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
'w0',
expect.objectContaining({ id: 'w0', active: true }),
false,
);
});
it('should include active=false in the request if the workflow has no activatable trigger node', async () => {
const workflow = createTestWorkflow({
id: 'w1',
nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: true })],
active: true,
});
vi.spyOn(workflowsStore, 'fetchWorkflow').mockResolvedValue(workflow);
vi.spyOn(workflowsStore, 'updateWorkflow').mockResolvedValue(workflow);
workflowsStore.setWorkflow(workflow);
const { saveCurrentWorkflow } = useWorkflowHelpers({ router });
await saveCurrentWorkflow({ id: 'w1' });
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
'w1',
expect.objectContaining({ id: 'w1', active: false }),
false,
);
expect(workflowsStore.setWorkflowInactive).toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,7 @@
import {
HTTP_REQUEST_NODE_TYPE,
MODAL_CONFIRM,
NON_ACTIVATABLE_TRIGGER_NODE_TYPES,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
VIEWS,
@@ -36,6 +37,7 @@ import type {
IWorkflowDataCreate,
IWorkflowDataUpdate,
IWorkflowDb,
NotificationOptions,
TargetItem,
WorkflowTitleStatus,
XYPosition,
@@ -812,6 +814,50 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
}
}
function isNodeActivatable(node: INode): boolean {
if (node.disabled) {
return false;
}
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
return (
nodeType !== null &&
nodeType.group.includes('trigger') &&
!NON_ACTIVATABLE_TRIGGER_NODE_TYPES.includes(node.type)
);
}
async function getWorkflowDeactivationInfo(
workflowId: string,
request: IWorkflowDataUpdate,
): Promise<Partial<NotificationOptions> | undefined> {
const missingActivatableTriggerNode =
request.nodes !== undefined && !request.nodes.some(isNodeActivatable);
if (missingActivatableTriggerNode) {
// Automatically deactivate if all activatable triggers are removed
return {
title: i18n.baseText('workflows.deactivated'),
message: i18n.baseText('workflowActivator.thisWorkflowHasNoTriggerNodes'),
type: 'info',
};
}
const conflictData = await checkConflictingWebhooks(workflowId);
if (conflictData) {
// Workflow should not be active if there is live webhook with the same path
return {
title: 'Conflicting Webhook Path',
message: `Workflow set to inactive: Workflow set to inactive: Live webhook in another workflow uses same path as node '${conflictData.trigger.name}'.`,
type: 'error',
};
}
return undefined;
}
async function saveCurrentWorkflow(
{ id, name, tags }: { id?: string; name?: string; tags?: string[] } = {},
redirect = true,
@@ -855,17 +901,16 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
workflowDataRequest.versionId = workflowsStore.workflowVersionId;
// workflow should not be active if there is live webhook with the same path
const conflictData = await checkConflictingWebhooks(currentWorkflow);
if (conflictData) {
const deactivateReason = await getWorkflowDeactivationInfo(
currentWorkflow,
workflowDataRequest,
);
if (deactivateReason !== undefined) {
workflowDataRequest.active = false;
if (workflowsStore.isWorkflowActive) {
toast.showMessage({
title: 'Conflicting Webhook Path',
message: `Workflow set to inactive: Live webhook in another workflow uses same path as node '${conflictData.trigger.name}'.`,
type: 'error',
});
toast.showMessage(deactivateReason);
workflowsStore.setWorkflowInactive(currentWorkflow);
}