Skip to content

Commit

Permalink
fix(n8n Form Trigger Node): Do not rerun trigger when it has run data (
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-radency authored Sep 5, 2024
1 parent 37a8088 commit 3adbcab
Show file tree
Hide file tree
Showing 3 changed files with 230 additions and 59 deletions.
97 changes: 39 additions & 58 deletions packages/editor-ui/src/composables/useRunWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,27 @@ import type {
IWorkflowDb,
} from '@/Interface';
import type {
IDataObject,
IRunData,
IRunExecutionData,
ITaskData,
IPinData,
Workflow,
StartNodeData,
IRun,
INode,
} from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';

import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers';

import {
CHAT_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
WAIT_NODE_TYPE,
WORKFLOW_LM_CHAT_MODAL_KEY,
} from '@/constants';
import { CHAT_TRIGGER_NODE_TYPE, WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
import { useTitleChange } from '@/composables/useTitleChange';
import { useRootStore } from '@/stores/root.store';
import { useUIStore } from '@/stores/ui.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { openPopUpWindow } from '@/utils/executionUtils';
import { displayForm } from '@/utils/executionUtils';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import type { useRouter } from 'vue-router';
Expand Down Expand Up @@ -261,58 +256,44 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
const runWorkflowApiResponse = await runWorkflowApi(startRunData);
const pinData = workflowData.pinData ?? {};

for (const node of workflowData.nodes) {
if (pinData[node.name]) continue;

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) {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (nodeType?.webhooks?.length) {
testUrl = workflowHelpers.getWebhookUrl(nodeType.webhooks[0], node, 'test');
}
}

if (
node.type === WAIT_NODE_TYPE &&
node.parameters.resume === 'form' &&
runWorkflowApiResponse.executionId
) {
const workflowTriggerNodes = workflow
.getTriggerNodes()
.map((triggerNode) => triggerNode.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 && typeof webhookSuffix !== 'object' ? `/${webhookSuffix}` : '';
testUrl = `${rootStore.formWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
const getTestUrl = (() => {
return (node: INode) => {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (nodeType?.webhooks?.length) {
return workflowHelpers.getWebhookUrl(nodeType.webhooks[0], node, 'test');
}
return '';
};
})();

const shouldShowForm = (() => {
return (node: INode) => {
const workflowTriggerNodes = workflow
.getTriggerNodes()
.map((triggerNode) => triggerNode.name);

const showForm =
options.destinationNode === node.name ||
directParentNodes.includes(node.name) ||
workflowTriggerNodes.some((triggerNode) =>
workflowsStore.isNodeInOutgoingNodeConnections(triggerNode, node.name),
);
return showForm;
};
})();

if (testUrl && options.source !== 'RunData.ManualChatMessage') openPopUpWindow(testUrl);
}
}
displayForm({
nodes: workflowData.nodes,
runData: workflowsStore.getWorkflowExecution?.data?.resultData?.runData,
destinationNode: options.destinationNode,
pinData,
directParentNodes,
formWaitingUrl: rootStore.formWaitingUrl,
executionId: runWorkflowApiResponse.executionId,
source: options.source,
getTestUrl,
shouldShowForm,
});

await useExternalHooks().run('workflowRun.runWorkflow', {
nodeName: options.destinationNode,
Expand Down
128 changes: 128 additions & 0 deletions packages/editor-ui/src/utils/__tests__/executionUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { displayForm, openPopUpWindow } from '../executionUtils';
import type { INode, IRunData, IPinData } from 'n8n-workflow';

const FORM_TRIGGER_NODE_TYPE = 'formTrigger';
const WAIT_NODE_TYPE = 'waitNode';

vi.mock('../executionUtils', async () => {
const actual = await vi.importActual('../executionUtils');
return {
...actual,
openPopUpWindow: vi.fn(),
};
});

describe('displayForm', () => {
const getTestUrlMock = vi.fn();
const shouldShowFormMock = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
});

it('should not call openPopUpWindow if node has already run or is pinned', () => {
const nodes: INode[] = [
{
id: '1',
name: 'Node1',
typeVersion: 1,
type: FORM_TRIGGER_NODE_TYPE,
position: [0, 0],
parameters: {},
},
{
id: '2',
name: 'Node2',
typeVersion: 1,
type: WAIT_NODE_TYPE,
position: [0, 0],
parameters: {},
},
];

const runData: IRunData = { Node1: [] };
const pinData: IPinData = { Node2: [{ json: { data: {} } }] };

displayForm({
nodes,
runData,
pinData,
destinationNode: undefined,
directParentNodes: [],
formWaitingUrl: 'http://example.com',
executionId: undefined,
source: undefined,
getTestUrl: getTestUrlMock,
shouldShowForm: shouldShowFormMock,
});

expect(openPopUpWindow).not.toHaveBeenCalled();
});

it('should skip nodes if destinationNode does not match and node is not a directParentNode', () => {
const nodes: INode[] = [
{
id: '1',
name: 'Node1',
typeVersion: 1,
type: FORM_TRIGGER_NODE_TYPE,
position: [0, 0],
parameters: {},
},
{
id: '2',
name: 'Node2',
typeVersion: 1,
type: WAIT_NODE_TYPE,
position: [0, 0],
parameters: {},
},
];

displayForm({
nodes,
runData: undefined,
pinData: {},
destinationNode: 'Node3',
directParentNodes: ['Node4'],
formWaitingUrl: 'http://example.com',
executionId: '12345',
source: undefined,
getTestUrl: getTestUrlMock,
shouldShowForm: shouldShowFormMock,
});

expect(openPopUpWindow).not.toHaveBeenCalled();
});

it('should not open pop-up if source is "RunData.ManualChatMessage"', () => {
const nodes: INode[] = [
{
id: '1',
name: 'Node1',
typeVersion: 1,
type: FORM_TRIGGER_NODE_TYPE,
position: [0, 0],
parameters: {},
},
];

getTestUrlMock.mockReturnValue('http://test-url.com');

displayForm({
nodes,
runData: undefined,
pinData: {},
destinationNode: undefined,
directParentNodes: [],
formWaitingUrl: 'http://example.com',
executionId: undefined,
source: 'RunData.ManualChatMessage',
getTestUrl: getTestUrlMock,
shouldShowForm: shouldShowFormMock,
});

expect(openPopUpWindow).not.toHaveBeenCalled();
});
});
64 changes: 63 additions & 1 deletion packages/editor-ui/src/utils/executionUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ExecutionStatus, IDataObject } from 'n8n-workflow';
import type { ExecutionStatus, IDataObject, INode, IPinData, IRunData } from 'n8n-workflow';
import type { ExecutionFilterType, ExecutionsQueryFilter } from '@/Interface';
import { isEmpty } from '@/utils/typesUtils';
import { FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '../constants';

export function getDefaultExecutionFilters(): ExecutionFilterType {
return {
Expand Down Expand Up @@ -86,3 +87,64 @@ export const openPopUpWindow = (
window.open(url, '_blank', features);
}
};

export function displayForm({
nodes,
runData,
pinData,
destinationNode,
directParentNodes,
formWaitingUrl,
executionId,
source,
getTestUrl,
shouldShowForm,
}: {
nodes: INode[];
runData: IRunData | undefined;
pinData: IPinData;
destinationNode: string | undefined;
directParentNodes: string[];
formWaitingUrl: string;
executionId: string | undefined;
source: string | undefined;
getTestUrl: (node: INode) => string;
shouldShowForm: (node: INode) => boolean;
}) {
for (const node of nodes) {
const hasNodeRun = runData && runData?.hasOwnProperty(node.name);

if (hasNodeRun || pinData[node.name]) continue;

if (![FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE].includes(node.type)) {
continue;
}

if (
destinationNode &&
destinationNode !== node.name &&
!directParentNodes.includes(node.name)
) {
continue;
}

if (node.name === destinationNode || !node.disabled) {
let testUrl = '';

if (node.type === FORM_TRIGGER_NODE_TYPE) {
testUrl = getTestUrl(node);
}

if (node.type === WAIT_NODE_TYPE && node.parameters.resume === 'form' && executionId) {
if (!shouldShowForm(node)) continue;

const { webhookSuffix } = (node.parameters.options ?? {}) as IDataObject;
const suffix =
webhookSuffix && typeof webhookSuffix !== 'object' ? `/${webhookSuffix}` : '';
testUrl = `${formWaitingUrl}/${executionId}${suffix}`;
}

if (testUrl && source !== 'RunData.ManualChatMessage') openPopUpWindow(testUrl);
}
}
}

0 comments on commit 3adbcab

Please sign in to comment.