diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index 9d59a49531..2698d6015f 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -6,7 +6,6 @@ import type { IWorkflowDb, } from '@/Interface'; import type { - IDataObject, IRunData, IRunExecutionData, ITaskData, @@ -14,24 +13,20 @@ import type { 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'; @@ -261,58 +256,44 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { + return (node: INode) => { + const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion); + if (nodeType?.webhooks?.length) { + return workflowHelpers.getWebhookUrl(nodeType.webhooks[0], node, 'test'); } + return ''; + }; + })(); - if ( - node.type === WAIT_NODE_TYPE && - node.parameters.resume === 'form' && - runWorkflowApiResponse.executionId - ) { - const workflowTriggerNodes = workflow - .getTriggerNodes() - .map((triggerNode) => triggerNode.name); + 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), - ); + const showForm = + options.destinationNode === node.name || + directParentNodes.includes(node.name) || + workflowTriggerNodes.some((triggerNode) => + workflowsStore.isNodeInOutgoingNodeConnections(triggerNode, node.name), + ); + return showForm; + }; + })(); - if (!showForm) continue; - - const { webhookSuffix } = (node.parameters.options ?? {}) as IDataObject; - const suffix = - webhookSuffix && typeof webhookSuffix !== 'object' ? `/${webhookSuffix}` : ''; - testUrl = `${rootStore.formWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`; - } - - 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, diff --git a/packages/editor-ui/src/utils/__tests__/executionUtils.spec.ts b/packages/editor-ui/src/utils/__tests__/executionUtils.spec.ts new file mode 100644 index 0000000000..68f8456d28 --- /dev/null +++ b/packages/editor-ui/src/utils/__tests__/executionUtils.spec.ts @@ -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(); + }); +}); diff --git a/packages/editor-ui/src/utils/executionUtils.ts b/packages/editor-ui/src/utils/executionUtils.ts index ad2712572c..74458ecff8 100644 --- a/packages/editor-ui/src/utils/executionUtils.ts +++ b/packages/editor-ui/src/utils/executionUtils.ts @@ -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 { @@ -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); + } + } +}