feat(editor): Add ‘execute workflow’ buttons below triggers on the canvas (#12769)

Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
autologie
2025-02-10 09:14:39 +01:00
committed by GitHub
parent e92556260f
commit b17cbec3af
28 changed files with 957 additions and 168 deletions

View File

@@ -46,5 +46,26 @@ describe('ManualExecutionService', () => {
name: 'node2',
});
});
it('Should return triggerToStartFrom trigger node', () => {
const data = {
pinData: {
node1: {},
node2: {},
},
triggerToStartFrom: { name: 'node3' },
} as unknown as IWorkflowExecutionDataProcess;
const workflow = {
getNode(nodeName: string) {
return {
name: nodeName,
};
},
} as unknown as Workflow;
const executionStartNode = manualExecutionService.getExecutionStartNode(data, workflow);
expect(executionStartNode).toEqual({
name: 'node3',
});
});
});
});

View File

@@ -23,6 +23,13 @@ export class ManualExecutionService {
getExecutionStartNode(data: IWorkflowExecutionDataProcess, workflow: Workflow) {
let startNode;
// If the user chose a trigger to start from we honor this.
if (data.triggerToStartFrom?.name) {
startNode = workflow.getNode(data.triggerToStartFrom.name) ?? undefined;
}
// Old logic for partial executions v1
if (
data.startNodes?.length === 1 &&
Object.keys(data.pinData ?? {}).includes(data.startNodes[0].name)

View File

@@ -1,11 +1,15 @@
import { mock } from 'jest-mock-extended';
import type { INode } from 'n8n-workflow';
import type { INode, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import type { User } from '@/databases/entities/user';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { IWorkflowDb } from '@/interfaces';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import type { WorkflowRunner } from '@/workflow-runner';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import type { WorkflowRequest } from '../workflow.request';
const webhookNode: INode = {
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
@@ -63,6 +67,9 @@ describe('WorkflowExecutionService', () => {
mock(),
);
const additionalData = mock<IWorkflowExecuteAdditionalData>({});
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData);
describe('runWorkflow()', () => {
test('should call `WorkflowRunner.run()`', async () => {
const node = mock<INode>();
@@ -76,6 +83,222 @@ describe('WorkflowExecutionService', () => {
});
});
describe('executeManually()', () => {
test('should call `WorkflowRunner.run()` with correct parameters with default partial execution logic', async () => {
const executionId = 'fake-execution-id';
const userId = 'user-id';
const user = mock<User>({ id: userId });
const runPayload = mock<WorkflowRequest.ManualRunPayload>({ startNodes: [] });
workflowRunner.run.mockResolvedValue(executionId);
const result = await workflowExecutionService.executeManually(runPayload, user);
expect(workflowRunner.run).toHaveBeenCalledWith({
destinationNode: runPayload.destinationNode,
executionMode: 'manual',
runData: runPayload.runData,
pinData: undefined,
pushRef: undefined,
workflowData: runPayload.workflowData,
userId,
partialExecutionVersion: 1,
startNodes: runPayload.startNodes,
dirtyNodeNames: runPayload.dirtyNodeNames,
triggerToStartFrom: runPayload.triggerToStartFrom,
});
expect(result).toEqual({ executionId });
});
[
{
name: 'trigger',
type: 'n8n-nodes-base.airtableTrigger',
// Avoid mock constructor evaluated as true
disabled: undefined,
},
{
name: 'webhook',
type: 'n8n-nodes-base.webhook',
disabled: undefined,
},
].forEach((triggerNode: Partial<INode>) => {
test(`should call WorkflowRunner.run() with pinned trigger with type ${triggerNode.name}`, async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({});
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData);
const executionId = 'fake-execution-id';
const userId = 'user-id';
const user = mock<User>({ id: userId });
const runPayload = mock<WorkflowRequest.ManualRunPayload>({
startNodes: [],
workflowData: {
pinData: {
trigger: [{}],
},
nodes: [triggerNode],
},
triggerToStartFrom: undefined,
});
workflowRunner.run.mockResolvedValue(executionId);
const result = await workflowExecutionService.executeManually(runPayload, user);
expect(workflowRunner.run).toHaveBeenCalledWith({
destinationNode: runPayload.destinationNode,
executionMode: 'manual',
runData: runPayload.runData,
pinData: runPayload.workflowData.pinData,
pushRef: undefined,
workflowData: runPayload.workflowData,
userId,
partialExecutionVersion: 1,
startNodes: [
{
name: triggerNode.name,
sourceData: null,
},
],
dirtyNodeNames: runPayload.dirtyNodeNames,
triggerToStartFrom: runPayload.triggerToStartFrom,
});
expect(result).toEqual({ executionId });
});
});
test('should start from pinned trigger', async () => {
const executionId = 'fake-execution-id';
const userId = 'user-id';
const user = mock<User>({ id: userId });
const pinnedTrigger: INode = {
id: '1',
typeVersion: 1,
position: [1, 2],
parameters: {},
name: 'pinned',
type: 'n8n-nodes-base.airtableTrigger',
};
const unexecutedTrigger: INode = {
id: '1',
typeVersion: 1,
position: [1, 2],
parameters: {},
name: 'to-start-from',
type: 'n8n-nodes-base.airtableTrigger',
};
const runPayload: WorkflowRequest.ManualRunPayload = {
startNodes: [],
workflowData: {
id: 'abc',
name: 'test',
active: false,
pinData: {
[pinnedTrigger.name]: [{ json: {} }],
},
nodes: [unexecutedTrigger, pinnedTrigger],
connections: {},
createdAt: new Date(),
updatedAt: new Date(),
},
runData: {},
};
workflowRunner.run.mockResolvedValue(executionId);
const result = await workflowExecutionService.executeManually(runPayload, user);
expect(workflowRunner.run).toHaveBeenCalledWith({
destinationNode: runPayload.destinationNode,
executionMode: 'manual',
runData: runPayload.runData,
pinData: runPayload.workflowData.pinData,
pushRef: undefined,
workflowData: runPayload.workflowData,
userId,
partialExecutionVersion: 1,
startNodes: [
{
// Start from pinned trigger
name: pinnedTrigger.name,
sourceData: null,
},
],
dirtyNodeNames: runPayload.dirtyNodeNames,
// no trigger to start from
triggerToStartFrom: undefined,
});
expect(result).toEqual({ executionId });
});
test('should ignore pinned trigger and start from unexecuted trigger', async () => {
const executionId = 'fake-execution-id';
const userId = 'user-id';
const user = mock<User>({ id: userId });
const pinnedTrigger: INode = {
id: '1',
typeVersion: 1,
position: [1, 2],
parameters: {},
name: 'pinned',
type: 'n8n-nodes-base.airtableTrigger',
};
const unexecutedTrigger: INode = {
id: '1',
typeVersion: 1,
position: [1, 2],
parameters: {},
name: 'to-start-from',
type: 'n8n-nodes-base.airtableTrigger',
};
const runPayload: WorkflowRequest.ManualRunPayload = {
startNodes: [],
workflowData: {
id: 'abc',
name: 'test',
active: false,
pinData: {
[pinnedTrigger.name]: [{ json: {} }],
},
nodes: [unexecutedTrigger, pinnedTrigger],
connections: {},
createdAt: new Date(),
updatedAt: new Date(),
},
runData: {},
triggerToStartFrom: {
name: unexecutedTrigger.name,
},
};
workflowRunner.run.mockResolvedValue(executionId);
const result = await workflowExecutionService.executeManually(runPayload, user);
expect(workflowRunner.run).toHaveBeenCalledWith({
destinationNode: runPayload.destinationNode,
executionMode: 'manual',
runData: runPayload.runData,
pinData: runPayload.workflowData.pinData,
pushRef: undefined,
workflowData: runPayload.workflowData,
userId,
partialExecutionVersion: 1,
// ignore pinned trigger
startNodes: [],
dirtyNodeNames: runPayload.dirtyNodeNames,
// pass unexecuted trigger to start from
triggerToStartFrom: runPayload.triggerToStartFrom,
});
expect(result).toEqual({ executionId });
});
});
describe('selectPinnedActivatorStarter()', () => {
const workflow = mock<IWorkflowDb>({
nodes: [],

View File

@@ -100,12 +100,18 @@ export class WorkflowExecutionService {
partialExecutionVersion: 1 | 2 = 1,
) {
const pinData = workflowData.pinData;
const pinnedTrigger = this.selectPinnedActivatorStarter(
let pinnedTrigger = this.selectPinnedActivatorStarter(
workflowData,
startNodes?.map((nodeData) => nodeData.name),
pinData,
);
// if we have a trigger to start from and it's not the pinned trigger
// ignore the pinned trigger
if (pinnedTrigger && triggerToStartFrom && pinnedTrigger.name !== triggerToStartFrom.name) {
pinnedTrigger = null;
}
// If webhooks nodes exist and are active we have to wait for till we receive a call
if (
pinnedTrigger === null &&