feat(editor): Migrate workflowRun mixin to useRunWorkflow composable (no-changelog) (#8711)

This commit is contained in:
Alex Grozav 2024-02-27 11:39:45 +02:00 committed by GitHub
parent f9a99ec029
commit 3cbe1e2136
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 737 additions and 438 deletions

View file

@ -71,7 +71,7 @@ export default defineComponent({
props: ['modalName', 'isActive', 'data'],
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
...useToast(),

View file

@ -64,7 +64,7 @@ export default defineComponent({
},
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
workflowHelpers,

View file

@ -84,7 +84,7 @@ export default defineComponent({
setup() {
const externalHooks = useExternalHooks();
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
const { callDebounced } = useDebounce();
return {

View file

@ -217,7 +217,7 @@ export default defineComponent({
},
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
workflowHelpers,

View file

@ -209,7 +209,7 @@ export default defineComponent({
const { activeNode } = storeToRefs(ndvStore);
const pinnedData = usePinnedData(activeNode);
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
externalHooks,

View file

@ -34,7 +34,6 @@ import {
} from '@/constants';
import type { INodeUi } from '@/Interface';
import type { INodeTypeDescription } from 'n8n-workflow';
import { workflowRun } from '@/mixins/workflowRun';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -43,9 +42,11 @@ import { useToast } from '@/composables/useToast';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { nodeViewEventBus } from '@/event-bus';
import { usePinnedData } from '@/composables/usePinnedData';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useUIStore } from '@/stores/ui.store';
import { useRouter } from 'vue-router';
export default defineComponent({
mixins: [workflowRun],
inheritAttrs: false,
props: {
nodeName: {
@ -76,23 +77,24 @@ export default defineComponent({
type: Boolean,
},
},
setup(props, ctx) {
setup(props) {
const router = useRouter();
const workflowsStore = useWorkflowsStore();
const node = workflowsStore.getNodeByName(props.nodeName);
const pinnedData = usePinnedData(node);
const externalHooks = useExternalHooks();
const { runWorkflow } = useRunWorkflow({ router });
return {
externalHooks,
pinnedData,
runWorkflow,
...useToast(),
...useMessage(),
// eslint-disable-next-line @typescript-eslint/no-misused-promises
...workflowRun.setup?.(props, ctx),
};
},
computed: {
...mapStores(useNodeTypesStore, useNDVStore, useWorkflowsStore),
...mapStores(useNodeTypesStore, useNDVStore, useWorkflowsStore, useUIStore),
node(): INodeUi | null {
return this.workflowsStore.getNodeByName(this.nodeName);
},

View file

@ -75,7 +75,7 @@ export default defineComponent({
setup() {
const router = useRouter();
const clipboard = useClipboard();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
clipboard,
workflowHelpers,

View file

@ -619,7 +619,7 @@ export default defineComponent({
const nodeHelpers = useNodeHelpers();
const { callDebounced } = useDebounce();
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
externalHooks,

View file

@ -251,7 +251,7 @@ export default defineComponent({
const nodeHelpers = useNodeHelpers();
const asyncLoadingError = ref(false);
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
// This will catch errors in async components
onErrorCaptured((e, component) => {

View file

@ -151,7 +151,7 @@ export default defineComponent({
},
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
workflowHelpers,

View file

@ -259,7 +259,7 @@ export default defineComponent({
},
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
const { callDebounced } = useDebounce();

View file

@ -148,7 +148,7 @@ export default defineComponent({
},
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
workflowHelpers,

View file

@ -63,7 +63,7 @@ export default defineComponent({
props: ['path', 'redactValues'],
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
workflowHelpers,

View file

@ -135,7 +135,6 @@ import {
WORKFLOW_LM_CHAT_MODAL_KEY,
} from '@/constants';
import { workflowRun } from '@/mixins/workflowRun';
import { get, last } from 'lodash-es';
import { useUIStore } from '@/stores/ui.store';
@ -152,6 +151,7 @@ import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRouter } from 'vue-router';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
const RunDataAi = defineAsyncComponent(
async () => await import('@/components/RunDataAi/RunDataAi.vue'),
@ -181,18 +181,17 @@ export default defineComponent({
MessageTyping,
RunDataAi,
},
mixins: [workflowRun],
setup(props, ctx) {
setup() {
const router = useRouter();
const externalHooks = useExternalHooks();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
const { runWorkflow } = useRunWorkflow({ router });
return {
runWorkflow,
externalHooks,
workflowHelpers,
...useToast(),
// eslint-disable-next-line @typescript-eslint/no-misused-promises
...workflowRun.setup?.(props, ctx),
};
},
data() {
@ -350,7 +349,7 @@ export default defineComponent({
if (!memoryConnection) return [];
const nodeResultData = this.workflowsStore?.getWorkflowResultDataByNodeName(
const nodeResultData = this.workflowsStore.getWorkflowResultDataByNodeName(
memoryConnection.node,
);

View file

@ -0,0 +1,289 @@
import { useRootStore } from '@/stores/n8nRoot.store';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import type { IStartRunData, IWorkflowData } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useToast } from '@/composables/useToast';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRouter } from 'vue-router';
import type { IPinData, IRunData, Workflow } from 'n8n-workflow';
vi.mock('@/stores/n8nRoot.store', () => ({
useRootStore: vi.fn().mockReturnValue({ pushConnectionActive: true }),
}));
vi.mock('@/stores/workflows.store', () => ({
useWorkflowsStore: vi.fn().mockReturnValue({
runWorkflow: vi.fn(),
subWorkflowExecutionError: null,
getWorkflowRunData: null,
setWorkflowExecutionData: vi.fn(),
activeExecutionId: null,
nodesIssuesExist: false,
executionWaitingForWebhook: false,
getCurrentWorkflow: vi.fn().mockReturnValue({ id: '123' }),
getNodeByName: vi.fn(),
}),
}));
vi.mock('@/stores/ui.store', () => ({
useUIStore: vi.fn().mockReturnValue({
isActionActive: vi.fn().mockReturnValue(false),
addActiveAction: vi.fn(),
removeActiveAction: vi.fn(),
}),
}));
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn().mockReturnValue({ track: vi.fn() }),
}));
vi.mock('@/composables/useI18n', () => ({
useI18n: vi.fn().mockReturnValue({ baseText: vi.fn().mockImplementation((key) => key) }),
}));
vi.mock('@/composables/useExternalHooks', () => ({
useExternalHooks: vi.fn().mockReturnValue({
run: vi.fn(),
}),
}));
vi.mock('@/composables/useToast', () => ({
useToast: vi.fn().mockReturnValue({
clearAllStickyNotifications: vi.fn(),
showMessage: vi.fn(),
showError: vi.fn(),
}),
}));
vi.mock('@/composables/useWorkflowHelpers', () => ({
useWorkflowHelpers: vi.fn().mockReturnValue({
getCurrentWorkflow: vi.fn(),
checkReadyForExecution: vi.fn(),
saveCurrentWorkflow: vi.fn(),
getWorkflowDataToSave: vi.fn(),
}),
}));
vi.mock('@/composables/useNodeHelpers', () => ({
useNodeHelpers: vi.fn().mockReturnValue({
refreshNodeIssues: vi.fn(),
updateNodesExecutionIssues: vi.fn(),
}),
}));
vi.mock('@/composables/useTitleChange', () => ({
useTitleChange: vi.fn().mockReturnValue({ titleSet: vi.fn() }),
}));
vi.mock('vue-router', async (importOriginal) => {
const { RouterLink } = await importOriginal();
return {
RouterLink,
useRouter: vi.fn().mockReturnValue({
push: vi.fn(),
}),
};
});
describe('useRunWorkflow({ router })', () => {
let rootStore: ReturnType<typeof useRootStore>;
let uiStore: ReturnType<typeof useUIStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let router: ReturnType<typeof useRouter>;
let toast: ReturnType<typeof useToast>;
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
let nodeHelpers: ReturnType<typeof useNodeHelpers>;
beforeAll(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
rootStore = useRootStore();
uiStore = useUIStore();
workflowsStore = useWorkflowsStore();
router = useRouter();
toast = useToast();
workflowHelpers = useWorkflowHelpers({ router });
nodeHelpers = useNodeHelpers();
});
describe('runWorkflowApi()', () => {
it('should throw an error if push connection is not active', async () => {
const { runWorkflowApi } = useRunWorkflow({ router });
rootStore.pushConnectionActive = false;
await expect(runWorkflowApi({} as IStartRunData)).rejects.toThrow(
'workflowRun.noActiveConnectionToTheServer',
);
});
it('should successfully run a workflow', async () => {
const { runWorkflowApi } = useRunWorkflow({ router });
rootStore.pushConnectionActive = true;
const mockResponse = { executionId: '123', waitingForWebhook: false };
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockResponse);
const response = await runWorkflowApi({} as IStartRunData);
expect(response).toEqual(mockResponse);
expect(workflowsStore.activeExecutionId).toBe('123');
expect(workflowsStore.executionWaitingForWebhook).toBe(false);
expect(uiStore.addActiveAction).toHaveBeenCalledWith('workflowRunning');
});
it('should handle workflow run failure', async () => {
const { runWorkflowApi } = useRunWorkflow({ router });
rootStore.pushConnectionActive = true;
vi.mocked(workflowsStore).runWorkflow.mockRejectedValue(new Error('Failed to run workflow'));
await expect(runWorkflowApi({} as IStartRunData)).rejects.toThrow('Failed to run workflow');
expect(uiStore.removeActiveAction).toHaveBeenCalledWith('workflowRunning');
});
it('should set waitingForWebhook if response indicates waiting', async () => {
const { runWorkflowApi } = useRunWorkflow({ router });
rootStore.pushConnectionActive = true;
const mockResponse = { executionId: '123', waitingForWebhook: true };
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockResponse);
const response = await runWorkflowApi({} as IStartRunData);
expect(response).toEqual(mockResponse);
expect(workflowsStore.executionWaitingForWebhook).toBe(true);
});
});
describe('runWorkflow()', () => {
it('should return undefined if UI action "workflowRunning" is active', async () => {
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(uiStore).isActionActive.mockReturnValue(true);
const result = await runWorkflow({});
expect(result).toBeUndefined();
});
it('should handle workflow issues correctly', async () => {
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(uiStore).isActionActive.mockReturnValue(false);
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
name: 'Test Workflow',
} as unknown as Workflow);
vi.mocked(workflowsStore).nodesIssuesExist = true;
vi.mocked(nodeHelpers).refreshNodeIssues.mockImplementation(() => {});
vi.mocked(workflowHelpers).checkReadyForExecution.mockReturnValue({
someNode: { issues: { input: ['issue'] } },
});
const result = await runWorkflow({});
expect(result).toBeUndefined();
expect(toast.showMessage).toHaveBeenCalled();
});
it('should execute workflow successfully', async () => {
const mockExecutionResponse = { executionId: '123' };
const { runWorkflow } = useRunWorkflow({ router });
vi.mocked(rootStore).pushConnectionActive = true;
vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
vi.mocked(workflowsStore).nodesIssuesExist = false;
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({
name: 'Test Workflow',
} as Workflow);
vi.mocked(nodeHelpers).refreshNodeIssues.mockImplementation(() => {});
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({
id: 'workflowId',
nodes: [],
} as unknown as IWorkflowData);
vi.mocked(workflowsStore).getWorkflowRunData = {
NodeName: [],
};
const result = await runWorkflow({});
expect(result).toEqual(mockExecutionResponse);
});
});
describe('consolidateRunDataAndStartNodes()', () => {
it('should return empty runData and startNodeNames if runData is null', () => {
const { consolidateRunDataAndStartNodes } = useRunWorkflow({ router });
const workflowMock = {
getParentNodes: vi.fn(),
nodes: {},
} as unknown as Workflow;
const result = consolidateRunDataAndStartNodes([], null, undefined, workflowMock);
expect(result).toEqual({ runData: undefined, startNodeNames: [] });
});
it('should return correct startNodeNames and newRunData for given directParentNodes and runData', () => {
const { consolidateRunDataAndStartNodes } = useRunWorkflow({ router });
const directParentNodes = ['node1', 'node2'];
const runData = {
node2: [{ data: { main: [[{ json: { value: 'data2' } }]] } }],
node3: [{ data: { main: [[{ json: { value: 'data3' } }]] } }],
} as unknown as IRunData;
const pinData: IPinData = {
node2: [{ json: { value: 'data2' } }],
};
const workflowMock = {
getParentNodes: vi.fn().mockImplementation((node) => {
if (node === 'node1') return ['node3'];
return [];
}),
nodes: {
node1: { disabled: false },
node2: { disabled: false },
node3: { disabled: true },
},
} as unknown as Workflow;
const result = consolidateRunDataAndStartNodes(
directParentNodes,
runData,
pinData,
workflowMock,
);
expect(result.startNodeNames).toContain('node1');
expect(result.startNodeNames).not.toContain('node3');
expect(result.runData).toEqual(runData);
});
it('should include directParentNode in startNodeNames if it has no runData or pinData', () => {
const { consolidateRunDataAndStartNodes } = useRunWorkflow({ router });
const directParentNodes = ['node1'];
const runData = {
node2: [
{
data: {
main: [[{ json: { value: 'data2' } }]],
},
},
],
} as unknown as IRunData;
const workflowMock = {
getParentNodes: vi.fn().mockReturnValue([]),
nodes: { node1: { disabled: false } },
} as unknown as Workflow;
const result = consolidateRunDataAndStartNodes(
directParentNodes,
runData,
undefined,
workflowMock,
);
expect(result.startNodeNames).toContain('node1');
expect(result.runData).toBeUndefined();
});
});
});

View file

@ -0,0 +1,397 @@
import type {
IExecutionPushResponse,
IExecutionResponse,
IStartRunData,
IWorkflowDb,
} from '@/Interface';
import type {
IDataObject,
IRunData,
IRunExecutionData,
ITaskData,
IPinData,
IWorkflowBase,
Workflow,
StartNodeData,
} from 'n8n-workflow';
import {
NodeHelpers,
NodeConnectionType,
TelemetryHelpers,
FORM_TRIGGER_PATH_IDENTIFIER,
} from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
import { useTitleChange } from '@/composables/useTitleChange';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { openPopUpWindow } from '@/utils/executionUtils';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import type { useRouter } from 'vue-router';
import { isEmpty } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { get } from 'lodash-es';
export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }) {
const nodeHelpers = useNodeHelpers();
const workflowHelpers = useWorkflowHelpers({ router: options.router });
const i18n = useI18n();
const telemetry = useTelemetry();
const toast = useToast();
const { titleSet } = useTitleChange();
const rootStore = useRootStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
// Starts to execute a workflow on server
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
if (!rootStore.pushConnectionActive) {
// Do not start if the connection to server is not active
// because then it can not receive the data as it executes.
throw new Error(i18n.baseText('workflowRun.noActiveConnectionToTheServer'));
}
workflowsStore.subWorkflowExecutionError = null;
uiStore.addActiveAction('workflowRunning');
let response: IExecutionPushResponse;
try {
response = await workflowsStore.runWorkflow(runData);
} catch (error) {
uiStore.removeActiveAction('workflowRunning');
throw error;
}
if (response.executionId !== undefined) {
workflowsStore.activeExecutionId = response.executionId;
}
if (response.waitingForWebhook === true) {
workflowsStore.executionWaitingForWebhook = true;
}
return response;
}
async function runWorkflow(options: {
destinationNode?: string;
triggerNode?: string;
nodeData?: ITaskData;
source?: string;
}): Promise<IExecutionPushResponse | undefined> {
const workflow = workflowHelpers.getCurrentWorkflow();
if (uiStore.isActionActive('workflowRunning')) {
return;
}
titleSet(workflow.name as string, 'EXECUTING');
toast.clearAllStickyNotifications();
try {
// Check first if the workflow has any issues before execute it
nodeHelpers.refreshNodeIssues();
const issuesExist = workflowsStore.nodesIssuesExist;
if (issuesExist) {
// If issues exist get all of the issues of all nodes
const workflowIssues = workflowHelpers.checkReadyForExecution(
workflow,
options.destinationNode,
);
if (workflowIssues !== null) {
const errorMessages = [];
let nodeIssues: string[];
const trackNodeIssues: Array<{
node_type: string;
error: string;
}> = [];
const trackErrorNodeTypes: string[] = [];
for (const nodeName of Object.keys(workflowIssues)) {
nodeIssues = NodeHelpers.nodeIssuesToString(workflowIssues[nodeName]);
let issueNodeType = 'UNKNOWN';
const issueNode = workflowsStore.getNodeByName(nodeName);
if (issueNode) {
issueNodeType = issueNode.type;
}
trackErrorNodeTypes.push(issueNodeType);
const trackNodeIssue = {
node_type: issueNodeType,
error: '',
caused_by_credential: !!workflowIssues[nodeName].credentials,
};
for (const nodeIssue of nodeIssues) {
errorMessages.push(
`<a data-action='openNodeDetail' data-action-parameter-node='${nodeName}'>${nodeName}</a>: ${nodeIssue}`,
);
trackNodeIssue.error = trackNodeIssue.error.concat(', ', nodeIssue);
}
trackNodeIssues.push(trackNodeIssue);
}
toast.showMessage({
title: i18n.baseText('workflowRun.showMessage.title'),
message: errorMessages.join('<br />'),
type: 'error',
duration: 0,
});
titleSet(workflow.name as string, 'ERROR');
void useExternalHooks().run('workflowRun.runError', {
errorMessages,
nodeName: options.destinationNode,
});
await workflowHelpers.getWorkflowDataToSave().then((workflowData) => {
telemetry.track('Workflow execution preflight failed', {
workflow_id: workflow.id,
workflow_name: workflow.name,
execution_type: options.destinationNode || options.triggerNode ? 'node' : 'workflow',
node_graph_string: JSON.stringify(
TelemetryHelpers.generateNodesGraph(
workflowData as IWorkflowBase,
workflowHelpers.getNodeTypes(),
).nodeGraph,
),
error_node_types: JSON.stringify(trackErrorNodeTypes),
errors: JSON.stringify(trackNodeIssues),
});
});
return;
}
}
// Get the direct parents of the node
let directParentNodes: string[] = [];
if (options.destinationNode !== undefined) {
directParentNodes = workflow.getParentNodes(
options.destinationNode,
NodeConnectionType.Main,
1,
);
}
const runData = workflowsStore.getWorkflowRunData;
if (workflowsStore.isNewWorkflow) {
await workflowHelpers.saveCurrentWorkflow();
}
const workflowData = await workflowHelpers.getWorkflowDataToSave();
const consolidatedData = consolidateRunDataAndStartNodes(
directParentNodes,
runData,
workflowData.pinData,
workflow,
);
const { startNodeNames } = consolidatedData;
let { runData: newRunData } = consolidatedData;
let executedNode: string | undefined;
if (
startNodeNames.length === 0 &&
'destinationNode' in options &&
options.destinationNode !== undefined
) {
executedNode = options.destinationNode;
startNodeNames.push(options.destinationNode);
} else if ('triggerNode' in options && 'nodeData' in options) {
startNodeNames.push(
...workflow.getChildNodes(options.triggerNode as string, NodeConnectionType.Main, 1),
);
newRunData = {
[options.triggerNode as string]: [options.nodeData],
} as IRunData;
executedNode = options.triggerNode;
}
const startNodes: StartNodeData[] = startNodeNames.map((name) => {
// Find for each start node the source data
let sourceData = get(runData, [name, 0, 'source', 0], null);
if (sourceData === null) {
const parentNodes = workflow.getParentNodes(name, NodeConnectionType.Main, 1);
const executeData = workflowHelpers.executeData(
parentNodes,
name,
NodeConnectionType.Main,
0,
);
sourceData = get(executeData, ['source', NodeConnectionType.Main, 0], null);
}
return {
name,
sourceData,
};
});
const startRunData: IStartRunData = {
workflowData,
runData: newRunData,
pinData: workflowData.pinData,
startNodes,
};
if ('destinationNode' in options) {
startRunData.destinationNode = options.destinationNode;
}
// Init the execution data to represent the start of the execution
// that data which gets reused is already set and data of newly executed
// nodes can be added as it gets pushed in
const executionData: IExecutionResponse = {
id: '__IN_PROGRESS__',
finished: false,
mode: 'manual',
status: 'running',
startedAt: new Date(),
stoppedAt: undefined,
workflowId: workflow.id,
executedNode,
data: {
resultData: {
runData: newRunData || {},
pinData: workflowData.pinData,
workflowData,
},
} as IRunExecutionData,
workflowData: {
id: workflowsStore.workflowId,
name: workflowData.name!,
active: workflowData.active!,
createdAt: 0,
updatedAt: 0,
...workflowData,
} as IWorkflowDb,
};
workflowsStore.setWorkflowExecutionData(executionData);
nodeHelpers.updateNodesExecutionIssues();
const runWorkflowApiResponse = await runWorkflowApi(startRunData);
for (const node of workflowData.nodes) {
if (![FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE].includes(node.type)) {
continue;
}
if (
options.destinationNode &&
options.destinationNode !== node.name &&
!directParentNodes.includes(node.name)
) {
continue;
}
if (node.name === options.destinationNode || !node.disabled) {
let testUrl = '';
if (node.type === FORM_TRIGGER_NODE_TYPE && node.typeVersion === 1) {
const webhookPath = (node.parameters.path as string) || node.webhookId;
testUrl = `${rootStore.getWebhookTestUrl}/${webhookPath}/${FORM_TRIGGER_PATH_IDENTIFIER}`;
}
if (node.type === FORM_TRIGGER_NODE_TYPE && node.typeVersion > 1) {
const webhookPath = (node.parameters.path as string) || node.webhookId;
testUrl = `${rootStore.getFormTestUrl}/${webhookPath}`;
}
if (
node.type === WAIT_NODE_TYPE &&
node.parameters.resume === 'form' &&
runWorkflowApiResponse.executionId
) {
const workflowTriggerNodes = workflow.getTriggerNodes().map((node) => node.name);
const showForm =
options.destinationNode === node.name ||
directParentNodes.includes(node.name) ||
workflowTriggerNodes.some((triggerNode) =>
workflowsStore.isNodeInOutgoingNodeConnections(triggerNode, node.name),
);
if (!showForm) continue;
const { webhookSuffix } = (node.parameters.options || {}) as IDataObject;
const suffix = webhookSuffix ? `/${webhookSuffix}` : '';
testUrl = `${rootStore.getFormWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
}
if (testUrl) openPopUpWindow(testUrl);
}
}
await useExternalHooks().run('workflowRun.runWorkflow', {
nodeName: options.destinationNode,
source: options.source,
});
return runWorkflowApiResponse;
} catch (error) {
titleSet(workflow.name as string, 'ERROR');
toast.showError(error, i18n.baseText('workflowRun.showError.title'));
return undefined;
}
}
function consolidateRunDataAndStartNodes(
directParentNodes: string[],
runData: IRunData | null,
pinData: IPinData | undefined,
workflow: Workflow,
): { runData: IRunData | undefined; startNodeNames: string[] } {
const startNodeNames: string[] = [];
let newRunData: IRunData | undefined;
if (runData !== null && Object.keys(runData).length !== 0) {
newRunData = {};
// Go over the direct parents of the node
for (const directParentNode of directParentNodes) {
// Go over the parents of that node so that we can get a start
// node for each of the branches
const parentNodes = workflow.getParentNodes(directParentNode, NodeConnectionType.Main);
// Add also the enabled direct parent to be checked
if (workflow.nodes[directParentNode].disabled) continue;
parentNodes.push(directParentNode);
for (const parentNode of parentNodes) {
if (!runData[parentNode]?.length && !pinData?.[parentNode]?.length) {
// When we hit a node which has no data we stop and set it
// as a start node the execution from and then go on with other
// direct input nodes
startNodeNames.push(parentNode);
break;
}
if (runData[parentNode]) {
newRunData[parentNode] = runData[parentNode]?.slice(0, 1);
}
}
}
if (isEmpty(newRunData)) {
// If there is no data for any of the parent nodes make sure
// that run data is empty that it runs regularly
newRunData = undefined;
}
}
return { runData: newRunData, startNodeNames };
}
return {
consolidateRunDataAndStartNodes,
runWorkflow,
runWorkflowApi,
};
}

View file

@ -30,12 +30,17 @@ export function useToast() {
const externalHooks = useExternalHooks();
const i18n = useI18n();
function showMessage(messageData: NotificationOptions, track = true) {
function showMessage(messageData: Partial<NotificationOptions>, track = true) {
messageData = { ...messageDefaults, ...messageData };
messageData.message =
typeof messageData.message === 'string'
? sanitizeHtml(messageData.message)
: messageData.message;
Object.defineProperty(messageData, 'message', {
value:
typeof messageData.message === 'string'
? sanitizeHtml(messageData.message)
: messageData.message,
writable: true,
enumerable: true,
});
const notification = Notification(messageData);
@ -116,7 +121,7 @@ export function useToast() {
}
function showError(e: Error | unknown, title: string, message?: string) {
const error = e as Error;
const error = e as NotificationErrorWithNodeAndDescription;
const messageLine = message ? `${message}<br/>` : '';
showMessage(
{
@ -124,7 +129,7 @@ export function useToast() {
message: `
${messageLine}
<i>${error.message}</i>
${collapsableDetails(error as NotificationErrorWithNodeAndDescription)}`,
${collapsableDetails(error)}`,
type: 'error',
duration: 0,
},
@ -170,7 +175,7 @@ export function useToast() {
function showNotificationForViews(views: VIEWS[]) {
const notifications: NotificationOptions[] = [];
views.forEach((view) => {
notifications.push(...uiStore.getNotificationsForView(view));
notifications.push(...(uiStore.getNotificationsForView(view) as NotificationOptions[]));
});
if (notifications.length) {
notifications.forEach(async (notification) => {

View file

@ -71,7 +71,7 @@ import { useCanvasStore } from '@/stores/canvas.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { tryToParseNumber } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
import type { Router } from 'vue-router';
import type { useRouter } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry';
export function resolveParameter(
@ -451,7 +451,8 @@ export function executeData(
return executeData;
}
export function useWorkflowHelpers(router: Router) {
export function useWorkflowHelpers(options: { router: ReturnType<typeof useRouter> }) {
const router = options.router;
const nodeTypesStore = useNodeTypesStore();
const rootStore = useRootStore();
const templatesStore = useTemplatesStore();

View file

@ -204,7 +204,7 @@ export const expressionManager = defineComponent({
try {
const ndvStore = useNDVStore();
const workflowHelpers = useWorkflowHelpers(this.$router);
const workflowHelpers = useWorkflowHelpers({ router: this.$router });
if (!ndvStore.activeNode) {
// e.g. credential modal
result.resolved = Expression.resolveWithoutWorkflow(resolvable, this.additionalData);

View file

@ -44,7 +44,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
export const pushConnection = defineComponent({
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
const nodeHelpers = useNodeHelpers();
return {
...useTitleChange(),

View file

@ -19,7 +19,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
export const workflowActivate = defineComponent({
setup() {
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
return {
workflowHelpers,
...useToast(),

View file

@ -1,393 +0,0 @@
import type { IExecutionPushResponse, IExecutionResponse, IStartRunData } from '@/Interface';
import { mapStores } from 'pinia';
import { defineComponent } from 'vue';
import { get } from 'lodash-es';
import type {
IDataObject,
IRunData,
IRunExecutionData,
ITaskData,
IPinData,
IWorkflowBase,
Workflow,
StartNodeData,
} from 'n8n-workflow';
import {
NodeHelpers,
NodeConnectionType,
TelemetryHelpers,
FORM_TRIGGER_PATH_IDENTIFIER,
} from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
import { useTitleChange } from '@/composables/useTitleChange';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { openPopUpWindow } from '@/utils/executionUtils';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRouter } from 'vue-router';
import { isEmpty } from '@/utils/typesUtils';
export const consolidateRunDataAndStartNodes = (
directParentNodes: string[],
runData: IRunData | null,
pinData: IPinData | undefined,
workflow: Workflow,
): { runData: IRunData | undefined; startNodeNames: string[] } => {
const startNodeNames: string[] = [];
let newRunData: IRunData | undefined;
if (runData !== null && Object.keys(runData).length !== 0) {
newRunData = {};
// Go over the direct parents of the node
for (const directParentNode of directParentNodes) {
// Go over the parents of that node so that we can get a start
// node for each of the branches
const parentNodes = workflow.getParentNodes(directParentNode, NodeConnectionType.Main);
// Add also the enabled direct parent to be checked
if (workflow.nodes[directParentNode].disabled) continue;
parentNodes.push(directParentNode);
for (const parentNode of parentNodes) {
if (!runData[parentNode]?.length && !pinData?.[parentNode]?.length) {
// When we hit a node which has no data we stop and set it
// as a start node the execution from and then go on with other
// direct input nodes
startNodeNames.push(parentNode);
break;
}
if (runData[parentNode]) {
newRunData[parentNode] = runData[parentNode]?.slice(0, 1);
}
}
}
if (isEmpty(newRunData)) {
// If there is no data for any of the parent nodes make sure
// that run data is empty that it runs regularly
newRunData = undefined;
}
}
return { runData: newRunData, startNodeNames };
};
export const workflowRun = defineComponent({
setup() {
const nodeHelpers = useNodeHelpers();
const router = useRouter();
const workflowHelpers = useWorkflowHelpers(router);
return {
...useTitleChange(),
...useToast(),
nodeHelpers,
workflowHelpers,
};
},
computed: {
...mapStores(useRootStore, useUIStore, useWorkflowsStore),
},
methods: {
// Starts to executes a workflow on server.
async runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
if (!this.rootStore.pushConnectionActive) {
// Do not start if the connection to server is not active
// because then it can not receive the data as it executes.
throw new Error(this.$locale.baseText('workflowRun.noActiveConnectionToTheServer'));
}
this.workflowsStore.subWorkflowExecutionError = null;
this.uiStore.addActiveAction('workflowRunning');
let response: IExecutionPushResponse;
try {
response = await this.workflowsStore.runWorkflow(runData);
} catch (error) {
this.uiStore.removeActiveAction('workflowRunning');
throw error;
}
if (response.executionId !== undefined) {
this.workflowsStore.activeExecutionId = response.executionId;
}
if (response.waitingForWebhook === true) {
this.workflowsStore.executionWaitingForWebhook = true;
}
return response;
},
async runWorkflow(
options:
| { destinationNode: string; source?: string }
| { triggerNode: string; nodeData: ITaskData; source?: string }
| { source?: string },
): Promise<IExecutionPushResponse | undefined> {
const workflow = this.workflowHelpers.getCurrentWorkflow();
if (this.uiStore.isActionActive('workflowRunning')) {
return;
}
this.titleSet(workflow.name as string, 'EXECUTING');
this.clearAllStickyNotifications();
try {
// Check first if the workflow has any issues before execute it
this.nodeHelpers.refreshNodeIssues();
const issuesExist = this.workflowsStore.nodesIssuesExist;
if (issuesExist) {
// If issues exist get all of the issues of all nodes
const workflowIssues = this.workflowHelpers.checkReadyForExecution(
workflow,
options.destinationNode,
);
if (workflowIssues !== null) {
const errorMessages = [];
let nodeIssues: string[];
const trackNodeIssues: Array<{
node_type: string;
error: string;
}> = [];
const trackErrorNodeTypes: string[] = [];
for (const nodeName of Object.keys(workflowIssues)) {
nodeIssues = NodeHelpers.nodeIssuesToString(workflowIssues[nodeName]);
let issueNodeType = 'UNKNOWN';
const issueNode = this.workflowsStore.getNodeByName(nodeName);
if (issueNode) {
issueNodeType = issueNode.type;
}
trackErrorNodeTypes.push(issueNodeType);
const trackNodeIssue = {
node_type: issueNodeType,
error: '',
caused_by_credential: !!workflowIssues[nodeName].credentials,
};
for (const nodeIssue of nodeIssues) {
errorMessages.push(
`<a data-action='openNodeDetail' data-action-parameter-node='${nodeName}'>${nodeName}</a>: ${nodeIssue}`,
);
trackNodeIssue.error = trackNodeIssue.error.concat(', ', nodeIssue);
}
trackNodeIssues.push(trackNodeIssue);
}
this.showMessage({
title: this.$locale.baseText('workflowRun.showMessage.title'),
message: errorMessages.join('<br />'),
type: 'error',
duration: 0,
});
this.titleSet(workflow.name as string, 'ERROR');
void useExternalHooks().run('workflowRun.runError', {
errorMessages,
nodeName: options.destinationNode,
});
await this.workflowHelpers.getWorkflowDataToSave().then((workflowData) => {
this.$telemetry.track('Workflow execution preflight failed', {
workflow_id: workflow.id,
workflow_name: workflow.name,
execution_type:
options.destinationNode || options.triggerNode ? 'node' : 'workflow',
node_graph_string: JSON.stringify(
TelemetryHelpers.generateNodesGraph(
workflowData as IWorkflowBase,
this.workflowHelpers.getNodeTypes(),
).nodeGraph,
),
error_node_types: JSON.stringify(trackErrorNodeTypes),
errors: JSON.stringify(trackNodeIssues),
});
});
return;
}
}
// Get the direct parents of the node
let directParentNodes: string[] = [];
if (options.destinationNode !== undefined) {
directParentNodes = workflow.getParentNodes(
options.destinationNode,
NodeConnectionType.Main,
1,
);
}
const runData = this.workflowsStore.getWorkflowRunData;
if (this.workflowsStore.isNewWorkflow) {
await this.workflowHelpers.saveCurrentWorkflow();
}
const workflowData = await this.workflowHelpers.getWorkflowDataToSave();
const consolidatedData = consolidateRunDataAndStartNodes(
directParentNodes,
runData,
workflowData.pinData,
workflow,
);
const { startNodeNames } = consolidatedData;
let { runData: newRunData } = consolidatedData;
let executedNode: string | undefined;
if (
startNodeNames.length === 0 &&
'destinationNode' in options &&
options.destinationNode !== undefined
) {
executedNode = options.destinationNode;
startNodeNames.push(options.destinationNode);
} else if ('triggerNode' in options && 'nodeData' in options) {
startNodeNames.push(
...workflow.getChildNodes(options.triggerNode, NodeConnectionType.Main, 1),
);
newRunData = {
[options.triggerNode]: [options.nodeData],
};
executedNode = options.triggerNode;
}
const startNodes: StartNodeData[] = startNodeNames.map((name) => {
// Find for each start node the source data
let sourceData = get(runData, [name, 0, 'source', 0], null);
if (sourceData === null) {
const parentNodes = workflow.getParentNodes(name, NodeConnectionType.Main, 1);
const executeData = this.workflowHelpers.executeData(
parentNodes,
name,
NodeConnectionType.Main,
0,
);
sourceData = get(executeData, ['source', NodeConnectionType.Main, 0], null);
}
return {
name,
sourceData,
};
});
const startRunData: IStartRunData = {
workflowData,
runData: newRunData,
pinData: workflowData.pinData,
startNodes,
};
if ('destinationNode' in options) {
startRunData.destinationNode = options.destinationNode;
}
// Init the execution data to represent the start of the execution
// that data which gets reused is already set and data of newly executed
// nodes can be added as it gets pushed in
const executionData: IExecutionResponse = {
id: '__IN_PROGRESS__',
finished: false,
mode: 'manual',
startedAt: new Date(),
stoppedAt: undefined,
workflowId: workflow.id,
executedNode,
data: {
resultData: {
runData: newRunData || {},
pinData: workflowData.pinData,
workflowData,
},
} as IRunExecutionData,
workflowData: {
id: this.workflowsStore.workflowId,
name: workflowData.name!,
active: workflowData.active!,
createdAt: 0,
updatedAt: 0,
...workflowData,
},
};
this.workflowsStore.setWorkflowExecutionData(executionData);
this.nodeHelpers.updateNodesExecutionIssues();
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
for (const node of workflowData.nodes) {
if (![FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE].includes(node.type)) {
continue;
}
if (
options.destinationNode &&
options.destinationNode !== node.name &&
!directParentNodes.includes(node.name)
) {
continue;
}
if (node.name === options.destinationNode || !node.disabled) {
let testUrl = '';
if (node.type === FORM_TRIGGER_NODE_TYPE && node.typeVersion === 1) {
const webhookPath = (node.parameters.path as string) || node.webhookId;
testUrl = `${this.rootStore.getWebhookTestUrl}/${webhookPath}/${FORM_TRIGGER_PATH_IDENTIFIER}`;
}
if (node.type === FORM_TRIGGER_NODE_TYPE && node.typeVersion > 1) {
const webhookPath = (node.parameters.path as string) || node.webhookId;
testUrl = `${this.rootStore.getFormTestUrl}/${webhookPath}`;
}
if (
node.type === WAIT_NODE_TYPE &&
node.parameters.resume === 'form' &&
runWorkflowApiResponse.executionId
) {
const workflowTriggerNodes = workflow.getTriggerNodes().map((node) => node.name);
const showForm =
options.destinationNode === node.name ||
directParentNodes.includes(node.name) ||
workflowTriggerNodes.some((triggerNode) =>
this.workflowsStore.isNodeInOutgoingNodeConnections(triggerNode, node.name),
);
if (!showForm) continue;
const { webhookSuffix } = (node.parameters.options || {}) as IDataObject;
const suffix = webhookSuffix ? `/${webhookSuffix}` : '';
testUrl = `${this.rootStore.getFormWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
}
if (testUrl) openPopUpWindow(testUrl);
}
}
await useExternalHooks().run('workflowRun.runWorkflow', {
nodeName: options.destinationNode,
source: options.source,
});
return runWorkflowApiResponse;
} catch (error) {
this.titleSet(workflow.name as string, 'ERROR');
this.showError(error, this.$locale.baseText('workflowRun.showError.title'));
return undefined;
}
},
},
});

View file

@ -259,7 +259,6 @@ import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { workflowRun } from '@/mixins/workflowRun';
import NodeDetailsView from '@/components/NodeDetailsView.vue';
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
@ -382,6 +381,7 @@ import { useDebounce } from '@/composables/useDebounce';
import { useCanvasPanning } from '@/composables/useCanvasPanning';
import { tryToParseNumber } from '@/utils/typesUtils';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
interface AddNodeOptions {
position?: XYPosition;
@ -413,7 +413,6 @@ export default defineComponent({
ContextMenu,
SetupWorkflowCredentialsButton,
},
mixins: [workflowRun],
async beforeRouteLeave(to, from, next) {
if (
getNodeViewTab(to) === MAIN_HEADER_TABS.EXECUTIONS ||
@ -474,7 +473,7 @@ export default defineComponent({
next();
}
},
setup(props, ctx) {
setup() {
const nodeViewRootRef = ref(null);
const nodeViewRef = ref(null);
const onMouseMoveEnd = ref(null);
@ -492,7 +491,8 @@ export default defineComponent({
const deviceSupport = useDeviceSupport();
const { callDebounced } = useDebounce();
const canvasPanning = useCanvasPanning(nodeViewRootRef, { onMouseMoveEnd });
const workflowHelpers = useWorkflowHelpers(router);
const workflowHelpers = useWorkflowHelpers({ router });
const { runWorkflow } = useRunWorkflow({ router });
return {
locale,
@ -508,6 +508,7 @@ export default defineComponent({
nodeViewRef,
onMouseMoveEnd,
workflowHelpers,
runWorkflow,
callDebounced,
...useCanvasMouseSelect(),
...useGlobalLinkActions(),
@ -516,8 +517,6 @@ export default defineComponent({
...useMessage(),
...useUniqueNodeName(),
...useExecutionDebugging(),
// eslint-disable-next-line @typescript-eslint/no-misused-promises
...workflowRun.setup?.(props, ctx),
};
},
watch: {
@ -1907,7 +1906,7 @@ export default defineComponent({
const nodeData = JSON.stringify(workflowToCopy, null, 2);
this.clipboard.copy(nodeData);
void this.clipboard.copy(nodeData);
if (data.nodes.length > 0) {
if (!isCut) {
this.showMessage({