From 983022a10d5b9a6fd2ff964400e6d0a76699ca26 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Mon, 5 Aug 2024 07:44:17 +0300 Subject: [PATCH 01/56] setup --- packages/nodes-base/nodes/Form/Form.node.json | 15 +++ packages/nodes-base/nodes/Form/Form.node.ts | 109 ++++++++++++++++++ packages/nodes-base/package.json | 1 + 3 files changed, 125 insertions(+) create mode 100644 packages/nodes-base/nodes/Form/Form.node.json create mode 100644 packages/nodes-base/nodes/Form/Form.node.ts diff --git a/packages/nodes-base/nodes/Form/Form.node.json b/packages/nodes-base/nodes/Form/Form.node.json new file mode 100644 index 0000000000000..b51358f0a5397 --- /dev/null +++ b/packages/nodes-base/nodes/Form/Form.node.json @@ -0,0 +1,15 @@ +{ + "node": "n8n-nodes-base.form", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Core Nodes"], + "alias": ["_Form", "form", "table", "submit", "post", "page", "step", "stage", "multi"], + "resources": { + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/" + } + ], + "generic": [] + } +} diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts new file mode 100644 index 0000000000000..20624dc7c369f --- /dev/null +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -0,0 +1,109 @@ +import type { + IExecuteFunctions, + INodeExecutionData, + INodeTypeDescription, + IWebhookFunctions, +} from 'n8n-workflow'; +import { WAIT_TIME_UNLIMITED } from 'n8n-workflow'; + +import { + formDescription, + formFields, + respondWithOptions, + formRespondMode, + formTitle, +} from '../Form/common.descriptions'; +import { formWebhook } from '../Form/utils'; + +import { Webhook } from '../Webhook/Webhook.node'; + +export class Form extends Webhook { + authPropertyName = 'authentication'; + + description: INodeTypeDescription = { + displayName: 'n8n Form Page', + name: 'form', + icon: 'file:form.svg', + group: ['input'], + version: 1, + subtitle: '=type: {{ $parameter["operation"] }}', + description: 'Create a multi-step webform by adding pages to a n8n form', + defaults: { + name: 'Form Page', + }, + inputs: ['main'], + outputs: ['main'], + webhooks: [ + { + name: 'default', + httpMethod: 'GET', + responseMode: 'onReceived', + path: '', + restartWebhook: true, + isFullPath: true, + isForm: true, + }, + { + name: 'default', + httpMethod: 'POST', + responseMode: '={{$parameter["responseMode"]}}', + responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}', + path: '', + restartWebhook: true, + isFullPath: true, + isForm: true, + }, + ], + properties: [ + formTitle, + formDescription, + formFields, + formRespondMode, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add option', + default: {}, + options: [ + { + ...respondWithOptions, + displayOptions: { + hide: { + '/responseMode': ['responseNode'], + }, + }, + }, + { + displayName: 'Webhook Suffix', + name: 'webhookSuffix', + type: 'string', + default: '', + placeholder: 'webhook', + noDataExpression: true, + description: + 'This suffix path will be appended to the restart URL. Helpful when using multiple wait nodes.', + }, + ], + }, + ], + }; + + async webhook(context: IWebhookFunctions) { + return await formWebhook(context, this.authPropertyName); + } + + async execute(context: IExecuteFunctions): Promise { + return await this.configureAndPutToWait(context); + } + + private async configureAndPutToWait(context: IExecuteFunctions) { + const waitTill = new Date(WAIT_TIME_UNLIMITED); + return await this.putToWait(context, waitTill); + } + + private async putToWait(context: IExecuteFunctions, waitTill: Date) { + await context.putExecutionToWait(waitTill); + return [context.getInputData()]; + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e9da85e9faf47..7c4f456ad948d 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -492,6 +492,7 @@ "dist/nodes/Filter/Filter.node.js", "dist/nodes/Flow/Flow.node.js", "dist/nodes/Flow/FlowTrigger.node.js", + "dist/nodes/Form/Form.node.js", "dist/nodes/Form/FormTrigger.node.js", "dist/nodes/FormIo/FormIoTrigger.node.js", "dist/nodes/Formstack/FormstackTrigger.node.js", From d0f8d79fc10584e28f8428abc3e0a8ed4a15b2a9 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Mon, 5 Aug 2024 10:40:17 +0300 Subject: [PATCH 02/56] baseUrl fix --- packages/cli/src/WorkflowExecuteAdditionalData.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 74c469284a7eb..ddb8c24818667 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1000,10 +1000,10 @@ export async function getBase( executeWorkflow, restApiUrl: urlBaseWebhook + globalConfig.endpoints.rest, instanceBaseUrl: urlBaseWebhook, - formWaitingBaseUrl: globalConfig.endpoints.formWaiting, - webhookBaseUrl: globalConfig.endpoints.webhook, - webhookWaitingBaseUrl: globalConfig.endpoints.webhookWaiting, - webhookTestBaseUrl: globalConfig.endpoints.webhookTest, + formWaitingBaseUrl: urlBaseWebhook + globalConfig.endpoints.formWaiting, + webhookBaseUrl: urlBaseWebhook + globalConfig.endpoints.webhook, + webhookWaitingBaseUrl: urlBaseWebhook + globalConfig.endpoints.webhookWaiting, + webhookTestBaseUrl: urlBaseWebhook + globalConfig.endpoints.webhookTest, currentNodeParameters, executionTimeoutTimestamp, userId, From c9f3fb918fe9956322a110244aa51faaa4c2d667 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 6 Aug 2024 10:18:22 +0300 Subject: [PATCH 03/56] form page node fix --- .../src/composables/useRunWorkflow.ts | 7 +++--- packages/nodes-base/nodes/Form/Form.node.ts | 23 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index 9d59a49531abb..d270eb0f60e97 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -264,7 +264,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { - return await this.configureAndPutToWait(context); - } - - private async configureAndPutToWait(context: IExecuteFunctions) { const waitTill = new Date(WAIT_TIME_UNLIMITED); - return await this.putToWait(context, waitTill); - } - - private async putToWait(context: IExecuteFunctions, waitTill: Date) { await context.putExecutionToWait(waitTill); return [context.getInputData()]; } From fbad7a6ad7b5d647aa68da6a90a29c2b83030b39 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Mon, 12 Aug 2024 07:50:04 +0300 Subject: [PATCH 04/56] redirect to next form page --- packages/cli/src/AbstractServer.ts | 2 +- packages/cli/src/{ => webhooks}/WaitingForms.ts | 0 packages/cli/src/webhooks/WaitingWebhooks.ts | 12 +++++++++++- packages/cli/src/webhooks/WebhookHelpers.ts | 8 +++++++- packages/cli/templates/form-trigger.handlebars | 3 +++ .../nodes-base/nodes/Form/common.descriptions.ts | 5 +++++ packages/workflow/src/Interfaces.ts | 2 +- 7 files changed, 28 insertions(+), 4 deletions(-) rename packages/cli/src/{ => webhooks}/WaitingForms.ts (100%) diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index 676db8db0aa03..f8bf906b02f8f 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -13,7 +13,7 @@ import { N8nInstanceType } from '@/Interfaces'; import { ExternalHooks } from '@/ExternalHooks'; import { send, sendErrorResponse } from '@/ResponseHelper'; import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares'; -import { WaitingForms } from '@/WaitingForms'; +import { WaitingForms } from '@/webhooks/WaitingForms'; import { TestWebhooks } from '@/webhooks/TestWebhooks'; import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; import { createWebhookHandlerFor } from '@/webhooks/WebhookRequestHandler'; diff --git a/packages/cli/src/WaitingForms.ts b/packages/cli/src/webhooks/WaitingForms.ts similarity index 100% rename from packages/cli/src/WaitingForms.ts rename to packages/cli/src/webhooks/WaitingForms.ts diff --git a/packages/cli/src/webhooks/WaitingWebhooks.ts b/packages/cli/src/webhooks/WaitingWebhooks.ts index 367635c45ec64..36ab555405717 100644 --- a/packages/cli/src/webhooks/WaitingWebhooks.ts +++ b/packages/cli/src/webhooks/WaitingWebhooks.ts @@ -62,7 +62,17 @@ export class WaitingWebhooks implements IWebhookManager { } if (execution.status === 'running') { - throw new ConflictError(`The execution "${executionId} is running already.`); + // throw new ConflictError(`The execution "${executionId} is running already.`); + res.send(` + + `); + return { + noWebhookResponse: true, + }; } if (execution.finished || execution.data.resultData.error) { diff --git a/packages/cli/src/webhooks/WebhookHelpers.ts b/packages/cli/src/webhooks/WebhookHelpers.ts index 1b8bc2b7e986d..1952c56a15167 100644 --- a/packages/cli/src/webhooks/WebhookHelpers.ts +++ b/packages/cli/src/webhooks/WebhookHelpers.ts @@ -174,7 +174,7 @@ export async function executeWebhook( 'firstEntryJson', ); - if (!['onReceived', 'lastNode', 'responseNode'].includes(responseMode)) { + if (!['onReceived', 'lastNode', 'responseNode', 'formPage'].includes(responseMode)) { // If the mode is not known we error. Is probably best like that instead of using // the default that people know as early as possible (probably already testing phase) // that something does not resolve properly. @@ -504,6 +504,12 @@ export async function executeWebhook( responsePromise, ); + if (responseMode === 'formPage' && !didSendResponse) { + res.redirect(`${additionalData.formWaitingBaseUrl}/${executionId}`); + process.nextTick(() => res.end()); + didSendResponse = true; + } + Container.get(Logger).verbose( `Started execution of workflow "${workflow.name}" from webhook with execution ID ${executionId}`, { executionId }, diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 67818629f57db..ce758905b491a 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -719,6 +719,9 @@ } if (response.status === 200) { + if(response.redirected) { + window.location.replace(response.url); + } const redirectUrl = document.getElementById("redirectUrl"); if (redirectUrl) { window.location.replace(redirectUrl.href); diff --git a/packages/nodes-base/nodes/Form/common.descriptions.ts b/packages/nodes-base/nodes/Form/common.descriptions.ts index c3505e9509f86..c6a63e0671ce8 100644 --- a/packages/nodes-base/nodes/Form/common.descriptions.ts +++ b/packages/nodes-base/nodes/Form/common.descriptions.ts @@ -221,6 +221,11 @@ export const formRespondMode: INodeProperties = { value: 'onReceived', description: 'As soon as this node receives the form submission', }, + { + name: 'Form Page', + value: 'formPage', + description: 'Next form page', + }, { name: 'Workflow Finishes', value: 'lastNode', diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index c4607563db7f4..3280d6c4d62a7 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1978,7 +1978,7 @@ export interface IWebhookResponseData { } export type WebhookResponseData = 'allEntries' | 'firstEntryJson' | 'firstEntryBinary' | 'noData'; -export type WebhookResponseMode = 'onReceived' | 'lastNode' | 'responseNode'; +export type WebhookResponseMode = 'onReceived' | 'lastNode' | 'responseNode' | 'formPage'; export interface INodeTypes { getByName(nodeType: string): INodeType | IVersionedNodeType; From 8af98aa07b98bffec9af15841ac0cb2217c02e4e Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 13 Aug 2024 08:49:14 +0300 Subject: [PATCH 05/56] refactor formWebhook --- packages/cli/src/webhooks/WaitingWebhooks.ts | 24 +- packages/nodes-base/nodes/Form/utils.ts | 244 +++++++++++-------- 2 files changed, 159 insertions(+), 109 deletions(-) diff --git a/packages/cli/src/webhooks/WaitingWebhooks.ts b/packages/cli/src/webhooks/WaitingWebhooks.ts index 36ab555405717..87c166c8c010b 100644 --- a/packages/cli/src/webhooks/WaitingWebhooks.ts +++ b/packages/cli/src/webhooks/WaitingWebhooks.ts @@ -62,17 +62,19 @@ export class WaitingWebhooks implements IWebhookManager { } if (execution.status === 'running') { - // throw new ConflictError(`The execution "${executionId} is running already.`); - res.send(` - - `); - return { - noWebhookResponse: true, - }; + if (this.includeForms) { + res.send(` + + `); + return { + noWebhookResponse: true, + }; + } + throw new ConflictError(`The execution "${executionId} is running already.`); } if (execution.finished || execution.data.resultData.error) { diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 2e79c5699ce5f..41fdc4af5ddab 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -14,6 +14,7 @@ import { validateWebhookAuthentication } from '../Webhook/utils'; import { DateTime } from 'luxon'; import isbot from 'isbot'; +import type { Response } from 'express'; export function prepareFormData({ formTitle, @@ -140,99 +141,12 @@ const checkResponseModeConfiguration = (context: IWebhookFunctions) => { } }; -export async function formWebhook( +export async function prepareFormReturnItem( context: IWebhookFunctions, - authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY, + formFields: FormField[], + mode: 'test' | 'production', + useWorkflowTimezone: boolean = false, ) { - const node = context.getNode(); - const options = context.getNodeParameter('options', {}) as { - ignoreBots?: boolean; - respondWithOptions?: { - values: { - respondWith: 'text' | 'redirect'; - formSubmittedText: string; - redirectUrl: string; - }; - }; - formSubmittedText?: string; - useWorkflowTimezone?: boolean; - appendAttribution?: boolean; - }; - const res = context.getResponseObject(); - const req = context.getRequestObject(); - - try { - if (options.ignoreBots && isbot(req.headers['user-agent'])) { - throw new WebhookAuthorizationError(403); - } - await validateWebhookAuthentication(context, authProperty); - } catch (error) { - if (error instanceof WebhookAuthorizationError) { - res.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' }); - res.end(error.message); - return { noWebhookResponse: true }; - } - throw error; - } - - const mode = context.getMode() === 'manual' ? 'test' : 'production'; - const formFields = context.getNodeParameter('formFields.values', []) as FormField[]; - const method = context.getRequestObject().method; - - checkResponseModeConfiguration(context); - - //Show the form on GET request - if (method === 'GET') { - const formTitle = context.getNodeParameter('formTitle', '') as string; - const formDescription = (context.getNodeParameter('formDescription', '') as string) - .replace(/\\n/g, '\n') - .replace(/
/g, '\n'); - const instanceId = context.getInstanceId(); - const responseMode = context.getNodeParameter('responseMode', '') as string; - - let formSubmittedText; - let redirectUrl; - let appendAttribution = true; - - if (options.respondWithOptions) { - const values = (options.respondWithOptions as IDataObject).values as IDataObject; - if (values.respondWith === 'text') { - formSubmittedText = values.formSubmittedText as string; - } - if (values.respondWith === 'redirect') { - redirectUrl = values.redirectUrl as string; - } - } else { - formSubmittedText = options.formSubmittedText as string; - } - - if (options.appendAttribution === false) { - appendAttribution = false; - } - - const useResponseData = responseMode === 'responseNode'; - - const query = context.getRequestObject().query as IDataObject; - - const data = prepareFormData({ - formTitle, - formDescription, - formSubmittedText, - redirectUrl, - formFields, - testRun: mode === 'test', - query, - instanceId, - useResponseData, - appendAttribution, - }); - - res.render('form-trigger', data); - return { - noWebhookResponse: true, - }; - } - const bodyData = (context.getBodyData().data as IDataObject) ?? {}; const files = (context.getBodyData().files as IDataObject) ?? {}; @@ -312,21 +226,155 @@ export async function formWebhook( returnItem.json[field.fieldLabel] = value; } + const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC'; + returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO(); + + returnItem.json.formMode = mode; + + return returnItem; +} + +export function renderForm({ + context, + res, + formTitle, + formDescription, + formFields, + responseMode, + mode, + formSubmittedText, + redirectUrl, + appendAttribution, +}: { + context: IWebhookFunctions; + res: Response; + formTitle: string; + formDescription: string; + formFields: FormField[]; + responseMode: string; + mode: 'test' | 'production'; + formSubmittedText?: string; + redirectUrl?: string; + appendAttribution?: boolean; +}) { + formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/
/g, '\n'); + const instanceId = context.getInstanceId(); + + const useResponseData = responseMode === 'responseNode'; + + const query = context.getRequestObject().query as IDataObject; + + const data = prepareFormData({ + formTitle, + formDescription, + formSubmittedText, + redirectUrl, + formFields, + testRun: mode === 'test', + query, + instanceId, + useResponseData, + appendAttribution, + }); + + res.render('form-trigger', data); +} + +export async function formWebhook( + context: IWebhookFunctions, + authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY, +) { + const node = context.getNode(); + const options = context.getNodeParameter('options', {}) as { + ignoreBots?: boolean; + respondWithOptions?: { + values: { + respondWith: 'text' | 'redirect'; + formSubmittedText: string; + redirectUrl: string; + }; + }; + formSubmittedText?: string; + useWorkflowTimezone?: boolean; + appendAttribution?: boolean; + }; + const res = context.getResponseObject(); + const req = context.getRequestObject(); + + try { + if (options.ignoreBots && isbot(req.headers['user-agent'])) { + throw new WebhookAuthorizationError(403); + } + await validateWebhookAuthentication(context, authProperty); + } catch (error) { + if (error instanceof WebhookAuthorizationError) { + res.writeHead(error.responseCode, { 'WWW-Authenticate': 'Basic realm="Webhook"' }); + res.end(error.message); + return { noWebhookResponse: true }; + } + throw error; + } + + const mode = context.getMode() === 'manual' ? 'test' : 'production'; + const formFields = context.getNodeParameter('formFields.values', []) as FormField[]; + const method = context.getRequestObject().method; + + checkResponseModeConfiguration(context); + + //Show the form on GET request + if (method === 'GET') { + const formTitle = context.getNodeParameter('formTitle', '') as string; + const formDescription = context.getNodeParameter('formDescription', '') as string; + const responseMode = context.getNodeParameter('responseMode', '') as string; + + let formSubmittedText; + let redirectUrl; + let appendAttribution = true; + + if (options.respondWithOptions) { + const values = (options.respondWithOptions as IDataObject).values as IDataObject; + if (values.respondWith === 'text') { + formSubmittedText = values.formSubmittedText as string; + } + if (values.respondWith === 'redirect') { + redirectUrl = values.redirectUrl as string; + } + } else { + formSubmittedText = options.formSubmittedText as string; + } + + if (options.appendAttribution === false) { + appendAttribution = false; + } + + renderForm({ + context, + res, + formTitle, + formDescription, + formFields, + responseMode, + mode, + formSubmittedText, + redirectUrl, + appendAttribution, + }); + + return { + noWebhookResponse: true, + }; + } + let { useWorkflowTimezone } = options; if (useWorkflowTimezone === undefined && node.typeVersion > 2) { useWorkflowTimezone = true; } - const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC'; - returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO(); - - returnItem.json.formMode = mode; - - const webhookResponse: IDataObject = { status: 200 }; + const returnItem = await prepareFormReturnItem(context, formFields, mode, useWorkflowTimezone); return { - webhookResponse, + webhookResponse: { status: 200 }, workflowData: [[returnItem]], }; } From 70fe188cb7b462f985e0954cef4f27f970ca4ff1 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 13 Aug 2024 08:54:43 +0300 Subject: [PATCH 06/56] import fix --- packages/cli/test/integration/webhooks.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/integration/webhooks.test.ts b/packages/cli/test/integration/webhooks.test.ts index 44bc32c1b5a62..04098b98e40c7 100644 --- a/packages/cli/test/integration/webhooks.test.ts +++ b/packages/cli/test/integration/webhooks.test.ts @@ -7,7 +7,7 @@ import { ActiveWebhooks } from '@/webhooks/ActiveWebhooks'; import { ExternalHooks } from '@/ExternalHooks'; import { TestWebhooks } from '@/webhooks/TestWebhooks'; import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; -import { WaitingForms } from '@/WaitingForms'; +import { WaitingForms } from '@/webhooks/WaitingForms'; import type { IWebhookResponseCallbackData } from '@/webhooks/webhook.types'; import { mockInstance } from '@test/mocking'; From 496b3a0dfbcc161789a005d39a7109d054975eef Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 13 Aug 2024 12:23:24 +0300 Subject: [PATCH 07/56] form page webhook update --- packages/nodes-base/nodes/Form/Form.node.ts | 111 +++++++++++--------- 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 64de5a5af6404..f8e53119611a5 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -4,24 +4,14 @@ import type { INodeTypeDescription, IWebhookFunctions, } from 'n8n-workflow'; -import { WAIT_TIME_UNLIMITED } from 'n8n-workflow'; +import { WAIT_TIME_UNLIMITED, Node } from 'n8n-workflow'; -import { - formDescription, - formFields, - respondWithOptions, - formRespondMode, - formTitle, -} from '../Form/common.descriptions'; -import { formWebhook } from '../Form/utils'; +import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; +import { prepareFormReturnItem, renderForm } from '../Form/utils'; -import { Webhook } from '../Webhook/Webhook.node'; - -const webhookPath = '={{$parameter["options"]["webhookSuffix"] || ""}}'; - -export class Form extends Webhook { - authPropertyName = 'authentication'; +import type { FormField } from './interfaces'; +export class Form extends Node { description: INodeTypeDescription = { displayName: 'n8n Form Page', name: 'form', @@ -39,7 +29,7 @@ export class Form extends Webhook { name: 'default', httpMethod: 'GET', responseMode: 'onReceived', - path: webhookPath, + path: '', restartWebhook: true, isFullPath: true, isForm: true, @@ -47,57 +37,78 @@ export class Form extends Webhook { { name: 'default', httpMethod: 'POST', - responseMode: '={{$parameter["responseMode"]}}', - responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}', - path: webhookPath, + responseMode: 'onReceived', + path: '', restartWebhook: true, isFullPath: true, isForm: true, }, ], properties: [ - //TODO check if form trigger is authenticated { - displayName: 'Authentication', - name: 'authentication', - type: 'hidden', - default: 'none', + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + displayName: 'n8n Form Trigger node must be set before this node', + name: 'triggerNotice', + type: 'notice', + default: '', }, formTitle, formDescription, formFields, - formRespondMode, { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add option', - default: {}, - options: [ - { - ...respondWithOptions, - displayOptions: { - hide: { - '/responseMode': ['responseNode'], - }, - }, - }, - { - displayName: 'Webhook Suffix', - name: 'webhookSuffix', - type: 'string', - default: '', - placeholder: 'webhook', - description: - 'This suffix path will be appended to the restart URL. Helpful when using multiple wait nodes.', - }, - ], + displayName: 'Resume Form Url', + name: 'resumeFormUrl', + type: 'hidden', + default: '={{ $execution.resumeFormUrl }}', }, ], }; async webhook(context: IWebhookFunctions) { - return await formWebhook(context, this.authPropertyName); + const res = context.getResponseObject(); + + const mode = context.getMode() === 'manual' ? 'test' : 'production'; + const fields = context.getNodeParameter('formFields.values', []) as FormField[]; + const method = context.getRequestObject().method; + + if (method === 'GET') { + const title = context.getNodeParameter('formTitle', '') as string; + const description = context.getNodeParameter('formDescription', '') as string; + const responseMode = 'onReceived'; + + let redirectUrl; + + const connectedNodes = context.getChildNodes(context.getNode().name); + + const hasNextPage = connectedNodes.some((node) => node.type === 'n8n-nodes-base.form'); + + if (hasNextPage) { + redirectUrl = context.getNodeParameter('resumeFormUrl', '') as string; + } + + renderForm({ + context, + res, + formTitle: title, + formDescription: description, + formFields: fields, + responseMode, + mode, + redirectUrl, + appendAttribution: true, + }); + + return { + noWebhookResponse: true, + }; + } + + const returnItem = await prepareFormReturnItem(context, fields, mode, true); + + return { + webhookResponse: { status: 200 }, + workflowData: [[returnItem]], + }; } async execute(context: IExecuteFunctions): Promise { From f1d894e9a10fe924d428124daf776aab62ce5006 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 13 Aug 2024 15:00:53 +0300 Subject: [PATCH 08/56] added evaluateExpression to WebhookFunctions --- packages/core/src/NodeExecuteFunctions.ts | 30 +++++++++++ packages/nodes-base/nodes/Form/Form.node.ts | 58 +++++++++++++++++---- packages/workflow/src/Interfaces.ts | 1 + 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index f97d86805911f..3c561e8de7c0e 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -4272,6 +4272,36 @@ export function getExecuteWebhookFunctions( ); }, getMode: () => mode, + evaluateExpression: (expression: string, evaluateItemIndex?: number) => { + const itemIndex = evaluateItemIndex === undefined ? 0 : evaluateItemIndex; + const runIndex = 0; + + let connectionInputData: INodeExecutionData[] = []; + let executionData: IExecuteData | undefined; + + if (runExecutionData?.executionData !== undefined) { + executionData = runExecutionData.executionData.nodeExecutionStack[0]; + + if (executionData !== undefined) { + connectionInputData = executionData.data.main[0]!; + } + } + + const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData); + + return workflow.expression.resolveSimpleParameterValue( + `=${expression}`, + {}, + runExecutionData, + runIndex, + itemIndex, + node.name, + connectionInputData, + mode, + additionalKeys, + executionData, + ); + }, getNodeParameter: ( parameterName: string, fallbackValue?: any, diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index f8e53119611a5..20c3c8e3ac0ca 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -4,13 +4,22 @@ import type { INodeTypeDescription, IWebhookFunctions, } from 'n8n-workflow'; -import { WAIT_TIME_UNLIMITED, Node } from 'n8n-workflow'; +import { WAIT_TIME_UNLIMITED, Node, updateDisplayOptions, NodeOperationError } from 'n8n-workflow'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; import { prepareFormReturnItem, renderForm } from '../Form/utils'; import type { FormField } from './interfaces'; +const pageProperties = updateDisplayOptions( + { + show: { + operation: ['page'], + }, + }, + [formTitle, formDescription, formFields], +); + export class Form extends Node { description: INodeTypeDescription = { displayName: 'n8n Form Page', @@ -52,15 +61,24 @@ export class Form extends Node { type: 'notice', default: '', }, - formTitle, - formDescription, - formFields, { - displayName: 'Resume Form Url', - name: 'resumeFormUrl', - type: 'hidden', - default: '={{ $execution.resumeFormUrl }}', + displayName: 'Page Type', + name: 'operation', + type: 'options', + default: 'page', + noDataExpression: true, + options: [ + { + name: 'Form Page', + value: 'page', + }, + { + name: 'Form Completion Screen', + value: 'completion', + }, + ], }, + ...pageProperties, ], }; @@ -83,7 +101,7 @@ export class Form extends Node { const hasNextPage = connectedNodes.some((node) => node.type === 'n8n-nodes-base.form'); if (hasNextPage) { - redirectUrl = context.getNodeParameter('resumeFormUrl', '') as string; + redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string; } renderForm({ @@ -112,6 +130,28 @@ export class Form extends Node { } async execute(context: IExecuteFunctions): Promise { + const operation = context.getNodeParameter('operation', 0); + + const parentNodes = context.getParentNodes(context.getNode().name); + const hasFormTrigger = parentNodes.some((node) => node.type === 'n8n-nodes-base.formTrigger'); + + if (!hasFormTrigger) { + throw new NodeOperationError( + context.getNode(), + 'Form Trigger node must be set before this node', + ); + } + + const childNodes = context.getChildNodes(context.getNode().name); + const hasNextPage = childNodes.some((node) => node.type === 'n8n-nodes-base.form'); + + if (operation === 'completion' && hasNextPage) { + throw new NodeOperationError( + context.getNode(), + 'Completion has to be the last Form node in the workflow', + ); + } + const waitTill = new Date(WAIT_TIME_UNLIMITED); await context.putExecutionToWait(waitTill); return [context.getInputData()]; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index ea8c6fd634cc6..ae9a89e859801 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1056,6 +1056,7 @@ export interface IWebhookFunctions extends FunctionsBaseWithRequiredKeys<'getMod options?: IGetNodeParameterOptions, ): NodeParameterValueType | object; getNodeWebhookUrl: (name: string) => string | undefined; + evaluateExpression(expression: string, itemIndex?: number): NodeParameterValueType; getParamsData(): object; getQueryData(): object; getRequestObject(): express.Request; From 4b6783946fcf6b451748f491f4d491a7cbafcfa6 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 14 Aug 2024 07:10:55 +0300 Subject: [PATCH 09/56] autoselect formPage response mode, inherit trigger parameters in page, button label customization --- packages/cli/src/webhooks/WebhookHelpers.ts | 59 +++++++++++++-- .../cli/templates/form-trigger.handlebars | 2 +- packages/nodes-base/nodes/Form/Form.node.ts | 75 +++++++++++++++++-- .../nodes/Form/common.descriptions.ts | 7 +- packages/nodes-base/nodes/Form/interfaces.ts | 1 + packages/nodes-base/nodes/Form/utils.ts | 14 ++++ .../nodes/Form/v2/FormTriggerV2.node.ts | 6 ++ 7 files changed, 144 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/webhooks/WebhookHelpers.ts b/packages/cli/src/webhooks/WebhookHelpers.ts index 1952c56a15167..eb44565e02fd8 100644 --- a/packages/cli/src/webhooks/WebhookHelpers.ts +++ b/packages/cli/src/webhooks/WebhookHelpers.ts @@ -148,14 +148,57 @@ export async function executeWebhook( } // Get the responseMode - const responseMode = workflow.expression.getSimpleParameterValue( - workflowStartNode, - webhookData.webhookDescription.responseMode, - executionMode, - additionalKeys, - undefined, - 'onReceived', - ) as WebhookResponseMode; + let responseMode; + + //if formTrigger node, check if there is a next page, if so set responseMode to formPage to redirect to next page later + if (nodeType.description.name === 'formTrigger') { + let hasNextPage = false; + for (const node of Object.keys(workflow.nodes)) { + if (workflow.nodes[node].type === 'n8n-nodes-base.form') { + hasNextPage = true; + break; + } + } + + if (hasNextPage) { + responseMode = 'formPage'; + } + } + + // if (nodeType.description.name === 'form' && req.method === 'POST') { + // let isLastPage = true; + // for (const node of workflow.getChildNodes(workflowStartNode.name)) { + // if (workflow.nodes[node].type === 'n8n-nodes-base.form') { + // isLastPage = false; + // break; + // } + // } + + // if (isLastPage) { + // const triggerName = workflow.getParentNodes(workflowStartNode.name)[0]; + + // responseMode = workflow.expression.getSimpleParameterValue( + // workflow.nodes[triggerName], + // '={{$parameter["responseMode"]}}', + // executionMode, + // additionalKeys, + // undefined, + // 'onReceived', + // ) as WebhookResponseMode; + // } + // } + + if (!responseMode) { + responseMode = workflow.expression.getSimpleParameterValue( + workflowStartNode, + webhookData.webhookDescription.responseMode, + executionMode, + additionalKeys, + undefined, + 'onReceived', + ) as WebhookResponseMode; + } + const responseCode = workflow.expression.getSimpleParameterValue( workflowStartNode, webhookData.webhookDescription.responseCode as string, diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index ce758905b491a..cd2e809558f8a 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -428,7 +428,7 @@ d='M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z' /> - Submit form + {{ buttonLabel }} {{else}} diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 20c3c8e3ac0ca..dbe1892a994f3 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -3,6 +3,7 @@ import type { INodeExecutionData, INodeTypeDescription, IWebhookFunctions, + NodeTypeAndVersion, } from 'n8n-workflow'; import { WAIT_TIME_UNLIMITED, Node, updateDisplayOptions, NodeOperationError } from 'n8n-workflow'; @@ -17,7 +18,26 @@ const pageProperties = updateDisplayOptions( operation: ['page'], }, }, - [formTitle, formDescription, formFields], + [ + formFields, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add option', + default: {}, + options: [ + { ...formTitle, required: false }, + formDescription, + { + displayName: 'Button Label', + name: 'buttonLabel', + type: 'string', + default: 'Submit form', + }, + ], + }, + ], ); export class Form extends Node { @@ -87,11 +107,43 @@ export class Form extends Node { const mode = context.getMode() === 'manual' ? 'test' : 'production'; const fields = context.getNodeParameter('formFields.values', []) as FormField[]; + + const parentNodes = context.getParentNodes(context.getNode().name); + const trigger = parentNodes.find( + (node) => node.type === 'n8n-nodes-base.formTrigger', + ) as NodeTypeAndVersion; + const method = context.getRequestObject().method; if (method === 'GET') { - const title = context.getNodeParameter('formTitle', '') as string; - const description = context.getNodeParameter('formDescription', '') as string; + const options = context.getNodeParameter('options', {}) as { + formTitle: string; + formDescription: string; + buttonLabel: string; + }; + + let title = options.formTitle; + if (!title) { + title = context.evaluateExpression( + `{{ $('${trigger?.name}').params.formTitle }}`, + ) as string; + } + + let description = options.formDescription; + if (!description) { + description = context.evaluateExpression( + `{{ $('${trigger?.name}').params.formDescription }}`, + ) as string; + } + + let buttonLabel = options.buttonLabel; + if (!buttonLabel) { + buttonLabel = + (context.evaluateExpression( + `{{ $('${trigger?.name}').params.options?.buttonLabel }}`, + ) as string) || 'Submit form'; + } + const responseMode = 'onReceived'; let redirectUrl; @@ -104,6 +156,10 @@ export class Form extends Node { redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string; } + const appendAttribution = context.evaluateExpression( + `{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`, + ) as boolean; + renderForm({ context, res, @@ -113,7 +169,8 @@ export class Form extends Node { responseMode, mode, redirectUrl, - appendAttribution: true, + appendAttribution, + buttonLabel, }); return { @@ -121,7 +178,15 @@ export class Form extends Node { }; } - const returnItem = await prepareFormReturnItem(context, fields, mode, true); + let useWorkflowTimezone = context.evaluateExpression( + `{{ $('${trigger?.name}').params.options?.useWorkflowTimezone }}`, + ) as boolean; + + if (useWorkflowTimezone === undefined && trigger?.typeVersion > 2) { + useWorkflowTimezone = true; + } + + const returnItem = await prepareFormReturnItem(context, fields, mode, useWorkflowTimezone); return { webhookResponse: { status: 200 }, diff --git a/packages/nodes-base/nodes/Form/common.descriptions.ts b/packages/nodes-base/nodes/Form/common.descriptions.ts index c6a63e0671ce8..ae3a1eeeac8f0 100644 --- a/packages/nodes-base/nodes/Form/common.descriptions.ts +++ b/packages/nodes-base/nodes/Form/common.descriptions.ts @@ -212,7 +212,7 @@ export const formFields: INodeProperties = { }; export const formRespondMode: INodeProperties = { - displayName: 'Respond When', + displayName: 'Respond', name: 'responseMode', type: 'options', options: [ @@ -221,11 +221,6 @@ export const formRespondMode: INodeProperties = { value: 'onReceived', description: 'As soon as this node receives the form submission', }, - { - name: 'Form Page', - value: 'formPage', - description: 'Next form page', - }, { name: 'Workflow Finishes', value: 'lastNode', diff --git a/packages/nodes-base/nodes/Form/interfaces.ts b/packages/nodes-base/nodes/Form/interfaces.ts index 330a7584dcacb..14320d027c8ec 100644 --- a/packages/nodes-base/nodes/Form/interfaces.ts +++ b/packages/nodes-base/nodes/Form/interfaces.ts @@ -40,6 +40,7 @@ export type FormTriggerData = { formFields: FormTriggerInput[]; useResponseData?: boolean; appendAttribution?: boolean; + buttonLabel?: string; }; export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication'; diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 0a290a00ef45a..e1d567ffcf9da 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -27,6 +27,7 @@ export function prepareFormData({ instanceId, useResponseData, appendAttribution = true, + buttonLabel, }: { formTitle: string; formDescription: string; @@ -38,6 +39,7 @@ export function prepareFormData({ instanceId?: string; useResponseData?: boolean; appendAttribution?: boolean; + buttonLabel?: string; }) { const validForm = formFields.length > 0; const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : ''; @@ -57,6 +59,7 @@ export function prepareFormData({ formFields: [], useResponseData, appendAttribution, + buttonLabel, }; if (redirectUrl) { @@ -245,6 +248,7 @@ export function renderForm({ formSubmittedText, redirectUrl, appendAttribution, + buttonLabel, }: { context: IWebhookFunctions; res: Response; @@ -256,6 +260,7 @@ export function renderForm({ formSubmittedText?: string; redirectUrl?: string; appendAttribution?: boolean; + buttonLabel?: string; }) { formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/
/g, '\n'); const instanceId = context.getInstanceId(); @@ -275,6 +280,7 @@ export function renderForm({ instanceId, useResponseData, appendAttribution, + buttonLabel, }); res.render('form-trigger', data); @@ -297,6 +303,7 @@ export async function formWebhook( formSubmittedText?: string; useWorkflowTimezone?: boolean; appendAttribution?: boolean; + buttonLabel?: string; }; const res = context.getResponseObject(); const req = context.getRequestObject(); @@ -349,6 +356,12 @@ export async function formWebhook( appendAttribution = false; } + let buttonLabel = 'Submit form'; + + if (options.buttonLabel) { + buttonLabel = options.buttonLabel; + } + renderForm({ context, res, @@ -360,6 +373,7 @@ export async function formWebhook( formSubmittedText, redirectUrl, appendAttribution, + buttonLabel, }); return { diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index 90d1242c933a9..020b49dffcf97 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -124,6 +124,12 @@ const descriptionV2: INodeTypeDescription = { description: 'Whether to include the link “Form automated with n8n” at the bottom of the form', }, + { + displayName: 'Button Label', + name: 'buttonLabel', + type: 'string', + default: 'Submit form', + }, { ...respondWithOptions, displayOptions: { From 0d93bde72cd6503a12dff6e87ed7aab1f638019b Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 14 Aug 2024 10:02:15 +0300 Subject: [PATCH 10/56] complition mode setup, tests fix --- .../form-trigger-completion.handlebars | 74 +++++++++++++++++++ packages/nodes-base/nodes/Form/Form.node.ts | 71 ++++++++++++++++++ .../nodes-base/nodes/Form/test/utils.test.ts | 5 ++ 3 files changed, 150 insertions(+) create mode 100644 packages/cli/templates/form-trigger-completion.handlebars diff --git a/packages/cli/templates/form-trigger-completion.handlebars b/packages/cli/templates/form-trigger-completion.handlebars new file mode 100644 index 0000000000000..761d09937be33 --- /dev/null +++ b/packages/cli/templates/form-trigger-completion.handlebars @@ -0,0 +1,74 @@ + + + + + + + + + {{formTitle}} + + + + + +
+
+
+
+

{{title}}

+

{{message}}

+
+
+ {{#if appendAttribution}} + + {{/if}} +
+
+ + + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index dbe1892a994f3..3597e1cdb8464 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -40,7 +40,43 @@ const pageProperties = updateDisplayOptions( ], ); +const completionProperties = updateDisplayOptions( + { + show: { + operation: ['completion'], + }, + }, + [ + { + displayName: 'Completion Title', + name: 'completionTitle', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Completion Message', + name: 'completionMessage', + type: 'string', + default: '', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add option', + default: {}, + options: [{ ...formTitle, required: false }], + }, + ], +); + export class Form extends Node { + nodeInputData: INodeExecutionData[] = []; + description: INodeTypeDescription = { displayName: 'n8n Form Page', name: 'form', @@ -99,6 +135,7 @@ export class Form extends Node { ], }, ...pageProperties, + ...completionProperties, ], }; @@ -107,6 +144,7 @@ export class Form extends Node { const mode = context.getMode() === 'manual' ? 'test' : 'production'; const fields = context.getNodeParameter('formFields.values', []) as FormField[]; + const operation = context.getNodeParameter('operation', '') as string; const parentNodes = context.getParentNodes(context.getNode().name); const trigger = parentNodes.find( @@ -115,6 +153,35 @@ export class Form extends Node { const method = context.getRequestObject().method; + if (operation === 'completion') { + const completionTitle = context.getNodeParameter('completionTitle', '') as string; + const completionMessage = context.getNodeParameter('completionMessage', '') as string; + const options = context.getNodeParameter('options', {}) as { + formTitle: string; + }; + let title = options.formTitle; + if (!title) { + title = context.evaluateExpression( + `{{ $('${trigger?.name}').params.formTitle }}`, + ) as string; + } + const appendAttribution = context.evaluateExpression( + `{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`, + ) as boolean; + + res.render('form-trigger-completion', { + title: completionTitle, + message: completionMessage, + formTitle: title, + appendAttribution, + }); + + return { + webhookResponse: { status: 200 }, + workflowData: [this.nodeInputData], + }; + } + if (method === 'GET') { const options = context.getNodeParameter('options', {}) as { formTitle: string; @@ -197,6 +264,10 @@ export class Form extends Node { async execute(context: IExecuteFunctions): Promise { const operation = context.getNodeParameter('operation', 0); + if (operation === 'completion') { + this.nodeInputData = context.getInputData(); + } + const parentNodes = context.getParentNodes(context.getNode().name); const hasFormTrigger = parentNodes.some((node) => node.type === 'n8n-nodes-base.formTrigger'); diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index bb44a354e6c0f..4117b57491aea 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -49,6 +49,7 @@ describe('FormTrigger, formWebhook', () => { expect(mockRender).toHaveBeenCalledWith('form-trigger', { appendAttribution: true, + buttonLabel: 'Submit form', formDescription: 'Test Description', formFields: [ { @@ -191,6 +192,7 @@ describe('FormTrigger, prepareFormData', () => { query, instanceId: 'test-instance', useResponseData: true, + buttonLabel: 'Submit form', }); expect(result).toEqual({ @@ -246,6 +248,7 @@ describe('FormTrigger, prepareFormData', () => { ], useResponseData: true, appendAttribution: true, + buttonLabel: 'Submit form', redirectUrl: 'https://example.com/thank-you', }); }); @@ -268,6 +271,7 @@ describe('FormTrigger, prepareFormData', () => { formFields, testRun: true, query: {}, + buttonLabel: 'Submit form', }); expect(result).toEqual({ @@ -291,6 +295,7 @@ describe('FormTrigger, prepareFormData', () => { ], useResponseData: undefined, appendAttribution: true, + buttonLabel: 'Submit form', }); }); From 6bee31081067302a2ed5136a9f62e4b1f5687776 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Mon, 19 Aug 2024 10:55:06 +0300 Subject: [PATCH 11/56] show completion page --- packages/cli/src/webhooks/WaitingWebhooks.ts | 39 ++++++++++++++++++-- packages/cli/src/webhooks/WebhookHelpers.ts | 23 ------------ packages/nodes-base/nodes/Form/Form.node.ts | 10 +++-- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/webhooks/WaitingWebhooks.ts b/packages/cli/src/webhooks/WaitingWebhooks.ts index 87c166c8c010b..c793bf91bb6c3 100644 --- a/packages/cli/src/webhooks/WaitingWebhooks.ts +++ b/packages/cli/src/webhooks/WaitingWebhooks.ts @@ -77,11 +77,44 @@ export class WaitingWebhooks implements IWebhookManager { throw new ConflictError(`The execution "${executionId} is running already.`); } - if (execution.finished || execution.data.resultData.error) { - throw new ConflictError(`The execution "${executionId} has finished already.`); + if (execution.data.resultData.error) { + throw new ConflictError(`The execution "${executionId} has finished with error.`); } - const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; + let completionPage; + if (execution.finished) { + const { workflowData } = execution; + const workflow = new Workflow({ + id: workflowData.id, + name: workflowData.name, + nodes: workflowData.nodes, + connections: workflowData.connections, + active: workflowData.active, + nodeTypes: this.nodeTypes, + staticData: workflowData.staticData, + settings: workflowData.settings, + }); + + const connectedNodes = workflow.getParentNodes( + execution.data.resultData.lastNodeExecuted as string, + ); + + completionPage = Object.keys(workflow.nodes).find((nodeName) => { + const node = workflow.nodes[nodeName]; + return ( + connectedNodes.includes(nodeName) && + node.type === 'n8n-nodes-base.form' && + node.parameters.operation === 'completion' + ); + }); + + if (!completionPage) { + throw new ConflictError(`The execution "${executionId} has finished already.`); + } + } + + const lastNodeExecuted = + completionPage || (execution.data.resultData.lastNodeExecuted as string); // Set the node as disabled so that the data does not get executed again as it would result // in starting the wait all over again diff --git a/packages/cli/src/webhooks/WebhookHelpers.ts b/packages/cli/src/webhooks/WebhookHelpers.ts index eb44565e02fd8..080e471646a78 100644 --- a/packages/cli/src/webhooks/WebhookHelpers.ts +++ b/packages/cli/src/webhooks/WebhookHelpers.ts @@ -165,29 +165,6 @@ export async function executeWebhook( } } - // if (nodeType.description.name === 'form' && req.method === 'POST') { - // let isLastPage = true; - // for (const node of workflow.getChildNodes(workflowStartNode.name)) { - // if (workflow.nodes[node].type === 'n8n-nodes-base.form') { - // isLastPage = false; - // break; - // } - // } - - // if (isLastPage) { - // const triggerName = workflow.getParentNodes(workflowStartNode.name)[0]; - - // responseMode = workflow.expression.getSimpleParameterValue( - // workflow.nodes[triggerName], - // '={{$parameter["responseMode"]}}', - // executionMode, - // additionalKeys, - // undefined, - // 'onReceived', - // ) as WebhookResponseMode; - // } - // } - if (!responseMode) { responseMode = workflow.expression.getSimpleParameterValue( workflowStartNode, diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 3597e1cdb8464..943dbe93a04d4 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -177,8 +177,7 @@ export class Form extends Node { }); return { - webhookResponse: { status: 200 }, - workflowData: [this.nodeInputData], + noWebhookResponse: true, }; } @@ -288,8 +287,11 @@ export class Form extends Node { ); } - const waitTill = new Date(WAIT_TIME_UNLIMITED); - await context.putExecutionToWait(waitTill); + if (operation !== 'completion') { + const waitTill = new Date(WAIT_TIME_UNLIMITED); + await context.putExecutionToWait(waitTill); + } + return [context.getInputData()]; } } From a549b17890f6019e15984edde097a2d36d72f30e Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 20 Aug 2024 08:26:53 +0300 Subject: [PATCH 12/56] waiting forms refactoring --- packages/cli/src/webhooks/WaitingForms.ts | 94 +++++++++++- packages/cli/src/webhooks/WaitingWebhooks.ts | 136 ++++++++---------- .../cli/templates/form-trigger.handlebars | 1 + 3 files changed, 154 insertions(+), 77 deletions(-) diff --git a/packages/cli/src/webhooks/WaitingForms.ts b/packages/cli/src/webhooks/WaitingForms.ts index bf0ab7dedb161..e235ef6356153 100644 --- a/packages/cli/src/webhooks/WaitingForms.ts +++ b/packages/cli/src/webhooks/WaitingForms.ts @@ -1,7 +1,14 @@ +import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; + +import { sleep, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; +import type express from 'express'; + +import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from './webhook.types'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { IExecutionResponse } from '@/Interfaces'; -import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; @Service() export class WaitingForms extends WaitingWebhooks { @@ -16,4 +23,89 @@ export class WaitingForms extends WaitingWebhooks { execution.data.executionData!.nodeExecutionStack[0].node.disabled = true; } } + + async executeWebhook( + req: WaitingWebhookRequest, + res: express.Response, + ): Promise { + const { path: executionId, suffix } = req.params; + + this.logReceivedWebhook(req.method, executionId); + + // Reset request parameters + req.params = {} as WaitingWebhookRequest['params']; + + const execution = await this.getExecution(executionId); + + if (!execution) { + throw new NotFoundError(`The execution "${executionId} does not exist.`); + } + + if (execution.data.resultData.error) { + throw new ConflictError(`The execution "${executionId} has finished with error.`); + } + + if (execution.status === 'running') { + if (this.includeForms) { + await sleep(1000); + + res.send(` + + `); + + return { + noWebhookResponse: true, + }; + } + throw new ConflictError(`The execution "${executionId} is running already.`); + } + + let completionPage; + if (execution.finished) { + const { workflowData } = execution; + const workflow = new Workflow({ + id: workflowData.id, + name: workflowData.name, + nodes: workflowData.nodes, + connections: workflowData.connections, + active: workflowData.active, + nodeTypes: this.nodeTypes, + staticData: workflowData.staticData, + settings: workflowData.settings, + }); + + const connectedNodes = workflow.getParentNodes( + execution.data.resultData.lastNodeExecuted as string, + ); + + completionPage = Object.keys(workflow.nodes).find((nodeName) => { + const node = workflow.nodes[nodeName]; + return ( + connectedNodes.includes(nodeName) && + node.type === 'n8n-nodes-base.form' && + node.parameters.operation === 'completion' + ); + }); + + if (!completionPage) { + throw new ConflictError(`The execution "${executionId} has finished already.`); + } + } + + const lastNodeExecuted = + completionPage || (execution.data.resultData.lastNodeExecuted as string); + + return await this.getWebhookExecutionData({ + execution, + req, + res, + lastNodeExecuted, + executionId, + suffix, + }); + } } diff --git a/packages/cli/src/webhooks/WaitingWebhooks.ts b/packages/cli/src/webhooks/WaitingWebhooks.ts index c793bf91bb6c3..b4bad7adbb22a 100644 --- a/packages/cli/src/webhooks/WaitingWebhooks.ts +++ b/packages/cli/src/webhooks/WaitingWebhooks.ts @@ -27,7 +27,7 @@ export class WaitingWebhooks implements IWebhookManager { constructor( protected readonly logger: Logger, - private readonly nodeTypes: NodeTypes, + protected readonly nodeTypes: NodeTypes, private readonly executionRepository: ExecutionRepository, ) {} @@ -41,81 +41,21 @@ export class WaitingWebhooks implements IWebhookManager { execution.data.executionData!.nodeExecutionStack[0].node.disabled = true; } - async executeWebhook( - req: WaitingWebhookRequest, - res: express.Response, - ): Promise { - const { path: executionId, suffix } = req.params; - - this.logReceivedWebhook(req.method, executionId); - - // Reset request parameters - req.params = {} as WaitingWebhookRequest['params']; - - const execution = await this.executionRepository.findSingleExecution(executionId, { - includeData: true, - unflattenData: true, - }); - - if (!execution) { - throw new NotFoundError(`The execution "${executionId} does not exist.`); - } - - if (execution.status === 'running') { - if (this.includeForms) { - res.send(` - - `); - return { - noWebhookResponse: true, - }; - } - throw new ConflictError(`The execution "${executionId} is running already.`); - } - - if (execution.data.resultData.error) { - throw new ConflictError(`The execution "${executionId} has finished with error.`); - } - - let completionPage; - if (execution.finished) { - const { workflowData } = execution; - const workflow = new Workflow({ - id: workflowData.id, - name: workflowData.name, - nodes: workflowData.nodes, - connections: workflowData.connections, - active: workflowData.active, - nodeTypes: this.nodeTypes, - staticData: workflowData.staticData, - settings: workflowData.settings, - }); - - const connectedNodes = workflow.getParentNodes( - execution.data.resultData.lastNodeExecuted as string, - ); - - completionPage = Object.keys(workflow.nodes).find((nodeName) => { - const node = workflow.nodes[nodeName]; - return ( - connectedNodes.includes(nodeName) && - node.type === 'n8n-nodes-base.form' && - node.parameters.operation === 'completion' - ); - }); - - if (!completionPage) { - throw new ConflictError(`The execution "${executionId} has finished already.`); - } - } - - const lastNodeExecuted = - completionPage || (execution.data.resultData.lastNodeExecuted as string); - + protected async getWebhookExecutionData({ + execution, + req, + res, + lastNodeExecuted, + executionId, + suffix, + }: { + execution: IExecutionResponse; + req: WaitingWebhookRequest; + res: express.Response; + lastNodeExecuted: string; + executionId: string; + suffix?: string; + }): Promise { // Set the node as disabled so that the data does not get executed again as it would result // in starting the wait all over again this.disableNode(execution, req.method); @@ -189,4 +129,48 @@ export class WaitingWebhooks implements IWebhookManager { ); }); } + + protected async getExecution(executionId: string) { + return await this.executionRepository.findSingleExecution(executionId, { + includeData: true, + unflattenData: true, + }); + } + + async executeWebhook( + req: WaitingWebhookRequest, + res: express.Response, + ): Promise { + const { path: executionId, suffix } = req.params; + + this.logReceivedWebhook(req.method, executionId); + + // Reset request parameters + req.params = {} as WaitingWebhookRequest['params']; + + const execution = await this.getExecution(executionId); + + if (!execution) { + throw new NotFoundError(`The execution "${executionId} does not exist.`); + } + + if (execution.status === 'running') { + throw new ConflictError(`The execution "${executionId} is running already.`); + } + + if (execution.finished || execution.data.resultData.error) { + throw new ConflictError(`The execution "${executionId} has finished already.`); + } + + const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; + + return await this.getWebhookExecutionData({ + execution, + req, + res, + lastNodeExecuted, + executionId, + suffix, + }); + } } diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index cd2e809558f8a..31a457ddaae57 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -721,6 +721,7 @@ if (response.status === 200) { if(response.redirected) { window.location.replace(response.url); + return; } const redirectUrl = document.getElementById("redirectUrl"); if (redirectUrl) { From ccd6f63788919c9cd581f8600fa913d2c42a0cf4 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 20 Aug 2024 12:09:47 +0300 Subject: [PATCH 13/56] fix error when trying to open form from wait node ndv --- packages/editor-ui/src/composables/useRunWorkflow.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index d270eb0f60e97..2c3dfe0b21f16 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -282,7 +282,9 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType Date: Tue, 20 Aug 2024 12:49:41 +0300 Subject: [PATCH 14/56] completion page last executed node fix --- packages/cli/src/webhooks/WaitingForms.ts | 26 ++++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/webhooks/WaitingForms.ts b/packages/cli/src/webhooks/WaitingForms.ts index e235ef6356153..ab6aa4b0d4be3 100644 --- a/packages/cli/src/webhooks/WaitingForms.ts +++ b/packages/cli/src/webhooks/WaitingForms.ts @@ -82,14 +82,24 @@ export class WaitingForms extends WaitingWebhooks { execution.data.resultData.lastNodeExecuted as string, ); - completionPage = Object.keys(workflow.nodes).find((nodeName) => { - const node = workflow.nodes[nodeName]; - return ( - connectedNodes.includes(nodeName) && - node.type === 'n8n-nodes-base.form' && - node.parameters.operation === 'completion' - ); - }); + const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; + const lastNode = workflow.nodes[execution.data.resultData.lastNodeExecuted as string]; + + if ( + lastNode.type === 'n8n-nodes-base.form' && + lastNode.parameters.operation === 'completion' + ) { + completionPage = lastNodeExecuted; + } else { + completionPage = Object.keys(workflow.nodes).find((nodeName) => { + const node = workflow.nodes[nodeName]; + return ( + connectedNodes.includes(nodeName) && + node.type === 'n8n-nodes-base.form' && + node.parameters.operation === 'completion' + ); + }); + } if (!completionPage) { throw new ConflictError(`The execution "${executionId} has finished already.`); From 927fa93516e1ef244fa6d3dfa1a5c9c6465ff85c Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 20 Aug 2024 15:29:43 +0300 Subject: [PATCH 15/56] json fields input --- packages/nodes-base/nodes/Form/Form.node.ts | 47 +++++++++++++++++++-- packages/workflow/src/utils.ts | 2 +- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 943dbe93a04d4..ad7f688f30f77 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -5,7 +5,13 @@ import type { IWebhookFunctions, NodeTypeAndVersion, } from 'n8n-workflow'; -import { WAIT_TIME_UNLIMITED, Node, updateDisplayOptions, NodeOperationError } from 'n8n-workflow'; +import { + WAIT_TIME_UNLIMITED, + Node, + updateDisplayOptions, + NodeOperationError, + jsonParse, +} from 'n8n-workflow'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; import { prepareFormReturnItem, renderForm } from '../Form/utils'; @@ -19,7 +25,31 @@ const pageProperties = updateDisplayOptions( }, }, [ - formFields, + { + displayName: 'Use JSON', + name: 'useJson', + type: 'boolean', + default: false, + description: 'Whether to use JSON as input to specify the form fields', + }, + { + displayName: 'JSON', + name: 'jsonOutput', + type: 'json', + typeOptions: { + rows: 5, + }, + default: + '[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]', + validateType: 'array', + ignoreValidationDuringExecution: true, + displayOptions: { + show: { + useJson: [true], + }, + }, + }, + { ...formFields, displayOptions: { show: { useJson: [false] } } }, { displayName: 'Options', name: 'options', @@ -142,9 +172,18 @@ export class Form extends Node { async webhook(context: IWebhookFunctions) { const res = context.getResponseObject(); - const mode = context.getMode() === 'manual' ? 'test' : 'production'; - const fields = context.getNodeParameter('formFields.values', []) as FormField[]; const operation = context.getNodeParameter('operation', '') as string; + const mode = context.getMode() === 'manual' ? 'test' : 'production'; + const useJson = context.getNodeParameter('useJson', false) as boolean; + + let fields: FormField[]; + if (useJson) { + fields = jsonParse(context.getNodeParameter('jsonOutput', '') as string, { + acceptJSObject: true, + }); + } else { + fields = context.getNodeParameter('formFields.values', []) as FormField[]; + } const parentNodes = context.getParentNodes(context.getNode().name); const trigger = parentNodes.find( diff --git a/packages/workflow/src/utils.ts b/packages/workflow/src/utils.ts index c0330955c3508..a26850442b323 100644 --- a/packages/workflow/src/utils.ts +++ b/packages/workflow/src/utils.ts @@ -122,7 +122,7 @@ type JSONParseOptions = { acceptJSObject?: boolean } & MutuallyExclusive< * * @param {string} jsonString - The JSON string to parse. * @param {Object} [options] - Optional settings for parsing the JSON string. Either `fallbackValue` or `errorMessage` can be set, but not both. - * @param {boolean} [options.parseJSObject=false] - If true, attempts to recover from common JSON format errors by parsing the JSON string as a JavaScript Object. + * @param {boolean} [options.acceptJSObject=false] - If true, attempts to recover from common JSON format errors by parsing the JSON string as a JavaScript Object. * @param {string} [options.errorMessage] - A custom error message to throw if the JSON string cannot be parsed. * @param {*} [options.fallbackValue] - A fallback value to return if the JSON string cannot be parsed. * @returns {Object} - The parsed object, or the fallback value if parsing fails and `fallbackValue` is set. From 81c0082ed3aabf9e48802dc4504aa9e044fb79af Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 21 Aug 2024 09:13:01 +0300 Subject: [PATCH 16/56] form json fields better error when json invalid, check for fields syntax --- packages/cli/src/webhooks/WebhookHelpers.ts | 11 +++- packages/nodes-base/nodes/Form/Form.node.ts | 43 +++++++++---- packages/nodes-base/nodes/Form/utils.ts | 68 +++++++++++++++++++++ 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/webhooks/WebhookHelpers.ts b/packages/cli/src/webhooks/WebhookHelpers.ts index 080e471646a78..2d7da7e78bac8 100644 --- a/packages/cli/src/webhooks/WebhookHelpers.ts +++ b/packages/cli/src/webhooks/WebhookHelpers.ts @@ -38,6 +38,7 @@ import { createDeferredPromise, ErrorReporterProxy as ErrorReporter, NodeHelpers, + NodeOperationError, } from 'n8n-workflow'; import type { IWebhookResponseCallbackData, WebhookRequest } from './webhook.types'; @@ -278,7 +279,15 @@ export async function executeWebhook( }); } catch (err) { // Send error response to webhook caller - const errorMessage = 'Workflow Webhook Error: Workflow could not be started!'; + const webhookType = ['formTrigger', 'form'].includes(nodeType.description.name) + ? 'Form' + : 'Webhook'; + let errorMessage = `Workflow ${webhookType} Error: Workflow could not be started!`; + + if (err instanceof NodeOperationError && err.type === 'manual-form-test') { + errorMessage = err.message; + } + responseCallback(new Error(errorMessage), {}); didSendResponse = true; diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index ad7f688f30f77..38d5e491bef62 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -1,4 +1,5 @@ import type { + IDataObject, IExecuteFunctions, INodeExecutionData, INodeTypeDescription, @@ -14,7 +15,7 @@ import { } from 'n8n-workflow'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; -import { prepareFormReturnItem, renderForm } from '../Form/utils'; +import { checkFieldsSyntax, prepareFormReturnItem, renderForm } from '../Form/utils'; import type { FormField } from './interfaces'; @@ -173,23 +174,43 @@ export class Form extends Node { const res = context.getResponseObject(); const operation = context.getNodeParameter('operation', '') as string; - const mode = context.getMode() === 'manual' ? 'test' : 'production'; + + const parentNodes = context.getParentNodes(context.getNode().name); + const trigger = parentNodes.find( + (node) => node.type === 'n8n-nodes-base.formTrigger', + ) as NodeTypeAndVersion; + + const mode = context.evaluateExpression(`{{ $('${trigger?.name}').first().json.formMode }}`) as + | 'test' + | 'production'; + const useJson = context.getNodeParameter('useJson', false) as boolean; - let fields: FormField[]; + let fields: FormField[] = []; if (useJson) { - fields = jsonParse(context.getNodeParameter('jsonOutput', '') as string, { - acceptJSObject: true, - }); + try { + const jsonOutput = context.getNodeParameter('jsonOutput', '') as string; + const rawFields = jsonParse(jsonOutput, { + acceptJSObject: true, + }); + fields = checkFieldsSyntax(context.getNode(), rawFields, mode); + } catch (error) { + if (error instanceof NodeOperationError) { + throw error; + } + throw new NodeOperationError( + context.getNode(), + `Fields in '${context.getNode().name}' node are not valid JSON`, + { + description: error.message, + type: mode === 'test' ? 'manual-form-test' : undefined, + }, + ); + } } else { fields = context.getNodeParameter('formFields.values', []) as FormField[]; } - const parentNodes = context.getParentNodes(context.getNode().name); - const trigger = parentNodes.find( - (node) => node.type === 'n8n-nodes-base.formTrigger', - ) as NodeTypeAndVersion; - const method = context.getRequestObject().method; if (operation === 'completion') { diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 696a9b77f847d..16bd2e60ea2c1 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -3,6 +3,7 @@ import type { MultiPartFormData, IDataObject, IWebhookFunctions, + INode, } from 'n8n-workflow'; import { NodeOperationError, jsonParse } from 'n8n-workflow'; @@ -394,3 +395,70 @@ export async function formWebhook( workflowData: [[returnItem]], }; } + +const ALLOWED_FORM_FIELD_KEYS = [ + 'fieldLabel', + 'fieldType', + 'placeholder', + 'fieldOptions', + 'multiselect', + 'multipleFiles', + 'acceptFileTypes', + 'formatDate', + 'requiredField', +]; + +const ALLOWED_FIELD_TYPES = [ + 'date', + 'dropdown', + 'email', + 'file', + 'number', + 'password', + 'text', + 'textarea', +]; +export function checkFieldsSyntax( + node: INode, + rawFields: IDataObject[], + mode: 'test' | 'production', +) { + const fields: FormField[] = []; + + for (const [index, field] of rawFields.entries()) { + for (const key of Object.keys(field)) { + if (!ALLOWED_FORM_FIELD_KEYS.includes(key)) { + throw new NodeOperationError( + node, + `Key '${key}' in field ${index} is not valid for form fields`, + { + type: mode === 'test' ? 'manual-form-test' : undefined, + }, + ); + } + if (key !== 'fieldOptions' && !['string', 'number', 'boolean'].includes(typeof field[key])) { + field[key] = String(field[key]); + } else if (typeof field[key] === 'string') { + field[key] = field[key].replace(//g, '>'); + } + + if (key === 'fieldType' && !ALLOWED_FIELD_TYPES.includes(field[key] as string)) { + throw new NodeOperationError( + node, + `Field type '${field[key]}' in field ${index} is not valid for form fields`, + { + type: mode === 'test' ? 'manual-form-test' : undefined, + }, + ); + } + + if (key === 'fieldOptions') { + field[key] = { values: field[key] }; + } + } + + fields.push(field as FormField); + } + + return fields; +} From 308d6d4bd123f974f926bd95f370786d0fb2849a Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 21 Aug 2024 09:45:02 +0300 Subject: [PATCH 17/56] field dropdown options syntax check --- packages/nodes-base/nodes/Form/Form.node.ts | 2 ++ packages/nodes-base/nodes/Form/utils.ts | 28 ++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 38d5e491bef62..d17b280bc4ce3 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -44,6 +44,8 @@ const pageProperties = updateDisplayOptions( '[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]', validateType: 'array', ignoreValidationDuringExecution: true, + //TODO: replace with link https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/ + hint: 'Syntax for fields described in the docs', displayOptions: { show: { useJson: [true], diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 16bd2e60ea2c1..51e626b76234a 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -453,7 +453,33 @@ export function checkFieldsSyntax( } if (key === 'fieldOptions') { - field[key] = { values: field[key] }; + if (Array.isArray(field[key])) { + field[key] = { values: field[key] }; + } + + if (typeof field[key] !== 'object' || !(field[key] as IDataObject).values) { + throw new NodeOperationError( + node, + `Field dropdown in field ${index} does has no 'values' property that contain an array of options`, + { + type: mode === 'test' ? 'manual-form-test' : undefined, + }, + ); + } + + for (const [optionIndex, option] of ( + (field[key] as IDataObject).values as IDataObject[] + ).entries()) { + if (Object.keys(option).length !== 1 || typeof option.option !== 'string') { + throw new NodeOperationError( + node, + `Field dropdown in field ${index} has an invalid option ${optionIndex}`, + { + type: mode === 'test' ? 'manual-form-test' : undefined, + }, + ); + } + } } } From 58fe621a11764af628bc845cd0c3824c6397bbd3 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 21 Aug 2024 10:16:25 +0300 Subject: [PATCH 18/56] redirect option for completion screen --- packages/nodes-base/nodes/Form/Form.node.ts | 60 ++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index d17b280bc4ce3..a34097af71824 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -80,12 +80,48 @@ const completionProperties = updateDisplayOptions( }, }, [ + { + displayName: 'Respond With', + name: 'respondWith', + type: 'options', + default: 'text', + options: [ + { + name: 'Show Completion Screen', + value: 'text', + description: 'Show a response text to the user', + }, + { + name: 'Redirect to URL', + value: 'redirect', + description: 'Redirect the user to a URL', + }, + ], + }, + { + displayName: 'URL', + name: 'redirectUrl', + validateType: 'url', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + respondWith: ['redirect'], + }, + }, + }, { displayName: 'Completion Title', name: 'completionTitle', type: 'string', default: '', required: true, + displayOptions: { + show: { + respondWith: ['text'], + }, + }, }, { displayName: 'Completion Message', @@ -95,6 +131,11 @@ const completionProperties = updateDisplayOptions( typeOptions: { rows: 2, }, + displayOptions: { + show: { + respondWith: ['text'], + }, + }, }, { displayName: 'Options', @@ -102,7 +143,12 @@ const completionProperties = updateDisplayOptions( type: 'collection', placeholder: 'Add option', default: {}, - options: [{ ...formTitle, required: false }], + options: [{ ...formTitle, required: false, displayName: 'Completion Page Title' }], + displayOptions: { + show: { + respondWith: ['text'], + }, + }, }, ], ); @@ -162,7 +208,7 @@ export class Form extends Node { value: 'page', }, { - name: 'Form Completion Screen', + name: 'Form Completion', value: 'completion', }, ], @@ -216,6 +262,16 @@ export class Form extends Node { const method = context.getRequestObject().method; if (operation === 'completion') { + const respondWith = context.getNodeParameter('respondWith', '') as string; + + if (respondWith === 'redirect') { + const redirectUrl = context.getNodeParameter('redirectUrl', '') as string; + res.redirect(redirectUrl); + return { + noWebhookResponse: true, + }; + } + const completionTitle = context.getNodeParameter('completionTitle', '') as string; const completionMessage = context.getNodeParameter('completionMessage', '') as string; const options = context.getNodeParameter('options', {}) as { From 121bfb9301421a5fd1787a9c38968598e915ac6a Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 21 Aug 2024 10:32:53 +0300 Subject: [PATCH 19/56] renamed node --- packages/nodes-base/nodes/Form/Form.node.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index a34097af71824..46882f6e27521 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -157,14 +157,14 @@ export class Form extends Node { nodeInputData: INodeExecutionData[] = []; description: INodeTypeDescription = { - displayName: 'n8n Form Page', + displayName: 'n8n Form', name: 'form', icon: 'file:form.svg', group: ['input'], version: 1, description: 'Create a multi-step webform by adding pages to a n8n form', defaults: { - name: 'Form Page', + name: 'Form', }, inputs: ['main'], outputs: ['main'], From 16c596c66410d644bfd7ee76f6a7312ecd10d0ff Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 21 Aug 2024 10:37:51 +0300 Subject: [PATCH 20/56] renamed json parameter --- packages/nodes-base/nodes/Form/Form.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 46882f6e27521..4c95d2f0f79f0 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -34,7 +34,7 @@ const pageProperties = updateDisplayOptions( description: 'Whether to use JSON as input to specify the form fields', }, { - displayName: 'JSON', + displayName: 'Form Fields', name: 'jsonOutput', type: 'json', typeOptions: { From 8b1ff6a6667d981ba98f2220120c38afb9c63f16 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 21 Aug 2024 15:10:57 +0300 Subject: [PATCH 21/56] triger parameters update on form page connection --- .../src/components/ParameterInputList.vue | 45 ++++++++++++++++++- packages/editor-ui/src/constants.ts | 1 + 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/components/ParameterInputList.vue b/packages/editor-ui/src/components/ParameterInputList.vue index 3e27b8c96cd7b..b0c02e223358b 100644 --- a/packages/editor-ui/src/components/ParameterInputList.vue +++ b/packages/editor-ui/src/components/ParameterInputList.vue @@ -183,7 +183,7 @@ import ParameterInputFull from '@/components/ParameterInputFull.vue'; import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; -import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants'; +import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { @@ -254,7 +254,17 @@ const nodeType = computed(() => { }); const filteredParameters = computed(() => { - return props.parameters.filter((parameter: INodeProperties) => displayNodeParameter(parameter)); + const parameters = props.parameters.filter((parameter: INodeProperties) => + displayNodeParameter(parameter), + ); + + const activeNode = ndvStore.activeNode; + + if (activeNode && activeNode.type === FORM_TRIGGER_NODE_TYPE) { + return updateFormTriggerParameters(parameters, activeNode.name); + } + + return parameters; }); const filteredParameterNames = computed(() => { @@ -314,6 +324,37 @@ watch(filteredParameterNames, (newValue, oldValue) => { } }); +function updateFormTriggerParameters(parameters: INodeProperties[], triggerName: string) { + const workflow = workflowHelpers.getCurrentWorkflow(); + const connectedNodes = workflow.getChildNodes(triggerName); + + const hasFormPage = connectedNodes.some((nodeName) => { + const node = workflow.getNode(nodeName); + return node && node.type === FORM_NODE_TYPE; + }); + + if (hasFormPage) { + const triggerParameters: INodeProperties[] = []; + + for (const parameter of parameters) { + if (parameter.name === 'responseMode') { + triggerParameters.push({ + displayName: + "When n8n Form node connected to this trigger 'Response' mode is selected automaticly", + name: 'formResponseModeNotice', + type: 'notice', + default: '', + }); + } else { + triggerParameters.push(parameter); + } + } + return triggerParameters; + } + + return parameters; +} + function onParameterBlur(parameterName: string) { emit('parameterBlur', parameterName); } diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index daf788eb21d45..eedd089bf9e12 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -192,6 +192,7 @@ export const CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE = export const SIMULATE_NODE_TYPE = 'n8n-nodes-base.simulate'; export const SIMULATE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.simulateTrigger'; export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform'; +export const FORM_NODE_TYPE = 'n8n-nodes-base.form'; export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base'; export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1; From 2a5ad4763f1c1c57faa92dd2fceda32362d8c204 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 21 Aug 2024 15:54:48 +0300 Subject: [PATCH 22/56] form response mode fix --- packages/cli/src/webhooks/WebhookHelpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/webhooks/WebhookHelpers.ts b/packages/cli/src/webhooks/WebhookHelpers.ts index 2d7da7e78bac8..a573e00cb40f9 100644 --- a/packages/cli/src/webhooks/WebhookHelpers.ts +++ b/packages/cli/src/webhooks/WebhookHelpers.ts @@ -153,8 +153,9 @@ export async function executeWebhook( //if formTrigger node, check if there is a next page, if so set responseMode to formPage to redirect to next page later if (nodeType.description.name === 'formTrigger') { + const connectedNodes = workflow.getChildNodes(workflowStartNode.name); let hasNextPage = false; - for (const node of Object.keys(workflow.nodes)) { + for (const node of connectedNodes) { if (workflow.nodes[node].type === 'n8n-nodes-base.form') { hasNextPage = true; break; From 5fd184aa2b16a73ee38f9d53018ed3ee8e738871 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 22 Aug 2024 08:44:32 +0300 Subject: [PATCH 23/56] cleanup --- packages/cli/src/webhooks/WaitingForms.ts | 58 ++++++++++++------- packages/cli/src/webhooks/WebhookHelpers.ts | 39 +++++++++---- .../src/composables/useRunWorkflow.ts | 11 +++- .../nodes/Form/common.descriptions.ts | 2 +- .../nodes/Form/v2/FormTriggerV2.node.ts | 1 + packages/workflow/src/Constants.ts | 1 + 6 files changed, 76 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/webhooks/WaitingForms.ts b/packages/cli/src/webhooks/WaitingForms.ts index ab6aa4b0d4be3..bb2b0b552bddb 100644 --- a/packages/cli/src/webhooks/WaitingForms.ts +++ b/packages/cli/src/webhooks/WaitingForms.ts @@ -1,6 +1,6 @@ import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; -import { sleep, Workflow } from 'n8n-workflow'; +import { FORM_NODE_TYPE, sleep, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; import type express from 'express'; @@ -24,6 +24,20 @@ export class WaitingForms extends WaitingWebhooks { } } + private getWorkflow(execution: IExecutionResponse) { + const { workflowData } = execution; + return new Workflow({ + id: workflowData.id, + name: workflowData.name, + nodes: workflowData.nodes, + connections: workflowData.connections, + active: workflowData.active, + nodeTypes: this.nodeTypes, + staticData: workflowData.staticData, + settings: workflowData.settings, + }); + } + async executeWebhook( req: WaitingWebhookRequest, res: express.Response, @@ -46,7 +60,18 @@ export class WaitingForms extends WaitingWebhooks { } if (execution.status === 'running') { - if (this.includeForms) { + const workflow = this.getWorkflow(execution); + + const childNodes = workflow.getChildNodes( + execution.data.resultData.lastNodeExecuted as string, + ); + + const hasNextPage = childNodes.some((nodeName) => { + const node = workflow.nodes[nodeName]; + return !node.disabled && node.type === FORM_NODE_TYPE; + }); + + if (hasNextPage && this.includeForms) { await sleep(1000); res.send(` @@ -66,27 +91,18 @@ export class WaitingForms extends WaitingWebhooks { let completionPage; if (execution.finished) { - const { workflowData } = execution; - const workflow = new Workflow({ - id: workflowData.id, - name: workflowData.name, - nodes: workflowData.nodes, - connections: workflowData.connections, - active: workflowData.active, - nodeTypes: this.nodeTypes, - staticData: workflowData.staticData, - settings: workflowData.settings, - }); + const workflow = this.getWorkflow(execution); - const connectedNodes = workflow.getParentNodes( + const parentNodes = workflow.getParentNodes( execution.data.resultData.lastNodeExecuted as string, ); const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; - const lastNode = workflow.nodes[execution.data.resultData.lastNodeExecuted as string]; + const lastNode = workflow.nodes[lastNodeExecuted]; if ( - lastNode.type === 'n8n-nodes-base.form' && + !lastNode.disabled && + lastNode.type === FORM_NODE_TYPE && lastNode.parameters.operation === 'completion' ) { completionPage = lastNodeExecuted; @@ -94,8 +110,9 @@ export class WaitingForms extends WaitingWebhooks { completionPage = Object.keys(workflow.nodes).find((nodeName) => { const node = workflow.nodes[nodeName]; return ( - connectedNodes.includes(nodeName) && - node.type === 'n8n-nodes-base.form' && + parentNodes.includes(nodeName) && + !node.disabled && + node.type === FORM_NODE_TYPE && node.parameters.operation === 'completion' ); }); @@ -106,14 +123,13 @@ export class WaitingForms extends WaitingWebhooks { } } - const lastNodeExecuted = - completionPage || (execution.data.resultData.lastNodeExecuted as string); + const targetNode = completionPage || (execution.data.resultData.lastNodeExecuted as string); return await this.getWebhookExecutionData({ execution, req, res, - lastNodeExecuted, + lastNodeExecuted: targetNode, executionId, suffix, }); diff --git a/packages/cli/src/webhooks/WebhookHelpers.ts b/packages/cli/src/webhooks/WebhookHelpers.ts index a573e00cb40f9..e978a35030e0d 100644 --- a/packages/cli/src/webhooks/WebhookHelpers.ts +++ b/packages/cli/src/webhooks/WebhookHelpers.ts @@ -37,6 +37,8 @@ import { BINARY_ENCODING, createDeferredPromise, ErrorReporterProxy as ErrorReporter, + ErrorReporterProxy, + FORM_NODE_TYPE, NodeHelpers, NodeOperationError, } from 'n8n-workflow'; @@ -126,7 +128,7 @@ export async function executeWebhook( ); if (nodeType === undefined) { const errorMessage = `The type of the webhook node "${workflowStartNode.name}" is not known`; - responseCallback(new Error(errorMessage), {}); + responseCallback(new ApplicationError(errorMessage), {}); throw new InternalServerError(errorMessage); } @@ -151,12 +153,14 @@ export async function executeWebhook( // Get the responseMode let responseMode; - //if formTrigger node, check if there is a next page, if so set responseMode to formPage to redirect to next page later + // if this is n8n FormTrigger node, check if there is a Form node in child nodes, + // if so, set 'responseMode' to 'formPage' to redirect to URL of that Form later if (nodeType.description.name === 'formTrigger') { const connectedNodes = workflow.getChildNodes(workflowStartNode.name); let hasNextPage = false; - for (const node of connectedNodes) { - if (workflow.nodes[node].type === 'n8n-nodes-base.form') { + for (const nodeName of connectedNodes) { + const node = workflow.nodes[nodeName]; + if (node.type === FORM_NODE_TYPE && !node.disabled) { hasNextPage = true; break; } @@ -201,7 +205,7 @@ export async function executeWebhook( // the default that people know as early as possible (probably already testing phase) // that something does not resolve properly. const errorMessage = `The response mode '${responseMode}' is not valid!`; - responseCallback(new Error(errorMessage), {}); + responseCallback(new ApplicationError(errorMessage), {}); throw new InternalServerError(errorMessage); } @@ -285,11 +289,21 @@ export async function executeWebhook( : 'Webhook'; let errorMessage = `Workflow ${webhookType} Error: Workflow could not be started!`; + // if workflow started manually, show and actual error message if (err instanceof NodeOperationError && err.type === 'manual-form-test') { errorMessage = err.message; } - responseCallback(new Error(errorMessage), {}); + ErrorReporterProxy.error(err, { + extra: { + nodeName: workflowStartNode.name, + nodeType: workflowStartNode.type, + nodeVersion: workflowStartNode.typeVersion, + workflowId: workflow.id, + }, + }); + + responseCallback(new ApplicationError(errorMessage), {}); didSendResponse = true; // Add error to execution data that it can be logged and send to Editor-UI @@ -615,7 +629,7 @@ export async function executeWebhook( // Return the JSON data of the first entry if (returnData.data!.main[0]![0] === undefined) { - responseCallback(new Error('No item to return got found'), {}); + responseCallback(new ApplicationError('No item to return got found'), {}); didSendResponse = true; return undefined; } @@ -669,13 +683,13 @@ export async function executeWebhook( data = returnData.data!.main[0]![0]; if (data === undefined) { - responseCallback(new Error('No item was found to return'), {}); + responseCallback(new ApplicationError('No item was found to return'), {}); didSendResponse = true; return undefined; } if (data.binary === undefined) { - responseCallback(new Error('No binary data was found to return'), {}); + responseCallback(new ApplicationError('No binary data was found to return'), {}); didSendResponse = true; return undefined; } @@ -690,7 +704,10 @@ export async function executeWebhook( ); if (responseBinaryPropertyName === undefined && !didSendResponse) { - responseCallback(new Error("No 'responseBinaryPropertyName' is set"), {}); + responseCallback( + new ApplicationError("No 'responseBinaryPropertyName' is set"), + {}, + ); didSendResponse = true; } @@ -699,7 +716,7 @@ export async function executeWebhook( ]; if (binaryData === undefined && !didSendResponse) { responseCallback( - new Error( + new ApplicationError( `The binary property '${responseBinaryPropertyName}' which should be returned does not exist`, ), {}, diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index 2c3dfe0b21f16..b034cd02351fe 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -24,6 +24,7 @@ import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE, + FORM_NODE_TYPE, WORKFLOW_LM_CHAT_MODAL_KEY, } from '@/constants'; import { useTitleChange } from '@/composables/useTitleChange'; @@ -284,14 +285,18 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType Date: Thu, 22 Aug 2024 10:01:29 +0300 Subject: [PATCH 24/56] validation for form fields on FE --- packages/nodes-base/nodes/Form/Form.node.ts | 43 +++---- packages/nodes-base/nodes/Form/interfaces.ts | 12 -- .../nodes-base/nodes/Form/test/utils.test.ts | 15 ++- packages/nodes-base/nodes/Form/utils.ts | 105 +---------------- packages/workflow/src/Constants.ts | 1 + packages/workflow/src/Interfaces.ts | 13 +++ packages/workflow/src/TypeValidation.ts | 107 +++++++++++++++++- 7 files changed, 150 insertions(+), 146 deletions(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 4c95d2f0f79f0..7e9339268f70e 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -1,5 +1,5 @@ import type { - IDataObject, + FormFieldsParameter, IExecuteFunctions, INodeExecutionData, INodeTypeDescription, @@ -11,13 +11,13 @@ import { Node, updateDisplayOptions, NodeOperationError, - jsonParse, + FORM_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, + tryToParseFormFields, } from 'n8n-workflow'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; -import { checkFieldsSyntax, prepareFormReturnItem, renderForm } from '../Form/utils'; - -import type { FormField } from './interfaces'; +import { prepareFormReturnItem, renderForm } from '../Form/utils'; const pageProperties = updateDisplayOptions( { @@ -42,10 +42,10 @@ const pageProperties = updateDisplayOptions( }, default: '[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]', - validateType: 'array', + validateType: 'form-fields', ignoreValidationDuringExecution: true, //TODO: replace with link https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/ - hint: 'Syntax for fields described in the docs', + hint: 'Syntax for fields described in the docs(hint: define fields in fixed mode to saw validation errors)', displayOptions: { show: { useJson: [true], @@ -234,29 +234,20 @@ export class Form extends Node { const useJson = context.getNodeParameter('useJson', false) as boolean; - let fields: FormField[] = []; + let fields: FormFieldsParameter = []; if (useJson) { try { const jsonOutput = context.getNodeParameter('jsonOutput', '') as string; - const rawFields = jsonParse(jsonOutput, { - acceptJSObject: true, - }); - fields = checkFieldsSyntax(context.getNode(), rawFields, mode); + + fields = tryToParseFormFields(jsonOutput); } catch (error) { - if (error instanceof NodeOperationError) { - throw error; - } - throw new NodeOperationError( - context.getNode(), - `Fields in '${context.getNode().name}' node are not valid JSON`, - { - description: error.message, - type: mode === 'test' ? 'manual-form-test' : undefined, - }, - ); + throw new NodeOperationError(context.getNode(), error.message, { + description: error.message, + type: mode === 'test' ? 'manual-form-test' : undefined, + }); } } else { - fields = context.getNodeParameter('formFields.values', []) as FormField[]; + fields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter; } const method = context.getRequestObject().method; @@ -386,7 +377,7 @@ export class Form extends Node { } const parentNodes = context.getParentNodes(context.getNode().name); - const hasFormTrigger = parentNodes.some((node) => node.type === 'n8n-nodes-base.formTrigger'); + const hasFormTrigger = parentNodes.some((node) => node.type === FORM_TRIGGER_NODE_TYPE); if (!hasFormTrigger) { throw new NodeOperationError( @@ -396,7 +387,7 @@ export class Form extends Node { } const childNodes = context.getChildNodes(context.getNode().name); - const hasNextPage = childNodes.some((node) => node.type === 'n8n-nodes-base.form'); + const hasNextPage = childNodes.some((node) => node.type === FORM_NODE_TYPE); if (operation === 'completion' && hasNextPage) { throw new NodeOperationError( diff --git a/packages/nodes-base/nodes/Form/interfaces.ts b/packages/nodes-base/nodes/Form/interfaces.ts index 14320d027c8ec..26b5cf567adfd 100644 --- a/packages/nodes-base/nodes/Form/interfaces.ts +++ b/packages/nodes-base/nodes/Form/interfaces.ts @@ -1,15 +1,3 @@ -export type FormField = { - fieldLabel: string; - fieldType: string; - requiredField: boolean; - fieldOptions?: { values: Array<{ option: string }> }; - multiselect?: boolean; - multipleFiles?: boolean; - acceptFileTypes?: string; - formatDate?: string; - placeholder?: string; -}; - export type FormTriggerInput = { isSelect?: boolean; isMultiSelect?: boolean; diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index 4117b57491aea..c7e45699f835e 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -1,6 +1,5 @@ import { mock } from 'jest-mock-extended'; -import type { IWebhookFunctions } from 'n8n-workflow'; -import type { FormField } from '../interfaces'; +import type { FormFieldsParameter, IWebhookFunctions } from 'n8n-workflow'; import { formWebhook, prepareFormData } from '../utils'; describe('FormTrigger, formWebhook', () => { @@ -12,7 +11,7 @@ describe('FormTrigger, formWebhook', () => { const executeFunctions = mock(); const mockRender = jest.fn(); - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, { @@ -109,7 +108,7 @@ describe('FormTrigger, formWebhook', () => { const mockStatus = jest.fn(); const mockEnd = jest.fn(); - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, ]; @@ -152,7 +151,7 @@ describe('FormTrigger, formWebhook', () => { describe('FormTrigger, prepareFormData', () => { it('should return valid form data with given parameters', () => { - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Name', fieldType: 'text', @@ -254,7 +253,7 @@ describe('FormTrigger, prepareFormData', () => { }); it('should handle missing optional fields gracefully', () => { - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Name', fieldType: 'text', @@ -300,7 +299,7 @@ describe('FormTrigger, prepareFormData', () => { }); it('should set redirectUrl with http if protocol is missing', () => { - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Name', fieldType: 'text', @@ -340,7 +339,7 @@ describe('FormTrigger, prepareFormData', () => { }); it('should correctly handle multiselect fields', () => { - const formFields: FormField[] = [ + const formFields: FormFieldsParameter = [ { fieldLabel: 'Favorite Colors', fieldType: 'text', diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 51e626b76234a..fcabe5490e2d0 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -3,11 +3,11 @@ import type { MultiPartFormData, IDataObject, IWebhookFunctions, - INode, + FormFieldsParameter, } from 'n8n-workflow'; import { NodeOperationError, jsonParse } from 'n8n-workflow'; -import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces'; +import type { FormTriggerData, FormTriggerInput } from './interfaces'; import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces'; import { WebhookAuthorizationError } from '../Webhook/error'; @@ -34,7 +34,7 @@ export function prepareFormData({ formDescription: string; formSubmittedText: string | undefined; redirectUrl: string | undefined; - formFields: FormField[]; + formFields: FormFieldsParameter; testRun: boolean; query: IDataObject; instanceId?: string; @@ -147,7 +147,7 @@ const checkResponseModeConfiguration = (context: IWebhookFunctions) => { export async function prepareFormReturnItem( context: IWebhookFunctions, - formFields: FormField[], + formFields: FormFieldsParameter, mode: 'test' | 'production', useWorkflowTimezone: boolean = false, ) { @@ -255,7 +255,7 @@ export function renderForm({ res: Response; formTitle: string; formDescription: string; - formFields: FormField[]; + formFields: FormFieldsParameter; responseMode: string; mode: 'test' | 'production'; formSubmittedText?: string; @@ -326,7 +326,7 @@ export async function formWebhook( } const mode = context.getMode() === 'manual' ? 'test' : 'production'; - const formFields = context.getNodeParameter('formFields.values', []) as FormField[]; + const formFields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter; const method = context.getRequestObject().method; checkResponseModeConfiguration(context); @@ -395,96 +395,3 @@ export async function formWebhook( workflowData: [[returnItem]], }; } - -const ALLOWED_FORM_FIELD_KEYS = [ - 'fieldLabel', - 'fieldType', - 'placeholder', - 'fieldOptions', - 'multiselect', - 'multipleFiles', - 'acceptFileTypes', - 'formatDate', - 'requiredField', -]; - -const ALLOWED_FIELD_TYPES = [ - 'date', - 'dropdown', - 'email', - 'file', - 'number', - 'password', - 'text', - 'textarea', -]; -export function checkFieldsSyntax( - node: INode, - rawFields: IDataObject[], - mode: 'test' | 'production', -) { - const fields: FormField[] = []; - - for (const [index, field] of rawFields.entries()) { - for (const key of Object.keys(field)) { - if (!ALLOWED_FORM_FIELD_KEYS.includes(key)) { - throw new NodeOperationError( - node, - `Key '${key}' in field ${index} is not valid for form fields`, - { - type: mode === 'test' ? 'manual-form-test' : undefined, - }, - ); - } - if (key !== 'fieldOptions' && !['string', 'number', 'boolean'].includes(typeof field[key])) { - field[key] = String(field[key]); - } else if (typeof field[key] === 'string') { - field[key] = field[key].replace(//g, '>'); - } - - if (key === 'fieldType' && !ALLOWED_FIELD_TYPES.includes(field[key] as string)) { - throw new NodeOperationError( - node, - `Field type '${field[key]}' in field ${index} is not valid for form fields`, - { - type: mode === 'test' ? 'manual-form-test' : undefined, - }, - ); - } - - if (key === 'fieldOptions') { - if (Array.isArray(field[key])) { - field[key] = { values: field[key] }; - } - - if (typeof field[key] !== 'object' || !(field[key] as IDataObject).values) { - throw new NodeOperationError( - node, - `Field dropdown in field ${index} does has no 'values' property that contain an array of options`, - { - type: mode === 'test' ? 'manual-form-test' : undefined, - }, - ); - } - - for (const [optionIndex, option] of ( - (field[key] as IDataObject).values as IDataObject[] - ).entries()) { - if (Object.keys(option).length !== 1 || typeof option.option !== 'string') { - throw new NodeOperationError( - node, - `Field dropdown in field ${index} has an invalid option ${optionIndex}`, - { - type: mode === 'test' ? 'manual-form-test' : undefined, - }, - ); - } - } - } - } - - fields.push(field as FormField); - } - - return fields; -} diff --git a/packages/workflow/src/Constants.ts b/packages/workflow/src/Constants.ts index 42d861ef21ebe..ec4fa57e0902d 100644 --- a/packages/workflow/src/Constants.ts +++ b/packages/workflow/src/Constants.ts @@ -39,6 +39,7 @@ export const FUNCTION_ITEM_NODE_TYPE = 'n8n-nodes-base.functionItem'; export const MERGE_NODE_TYPE = 'n8n-nodes-base.merge'; export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform'; export const FORM_NODE_TYPE = 'n8n-nodes-base.form'; +export const FORM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.formTrigger'; export const STARTING_NODE_TYPES = [ MANUAL_TRIGGER_NODE_TYPE, diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 737b6ef99a852..8be480897f665 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2492,6 +2492,18 @@ export interface ResourceMapperField { readOnly?: boolean; } +export type FormFieldsParameter = Array<{ + fieldLabel: string; + fieldType: string; + requiredField: boolean; + fieldOptions?: { values: Array<{ option: string }> }; + multiselect?: boolean; + multipleFiles?: boolean; + acceptFileTypes?: string; + formatDate?: string; + placeholder?: string; +}>; + export type FieldTypeMap = { // eslint-disable-next-line id-denylist boolean: boolean; @@ -2507,6 +2519,7 @@ export type FieldTypeMap = { options: any; url: string; jwt: string; + 'form-fields': FormFieldsParameter; }; export type FieldType = keyof FieldTypeMap; diff --git a/packages/workflow/src/TypeValidation.ts b/packages/workflow/src/TypeValidation.ts index a2c299303fe0a..ed233a6067015 100644 --- a/packages/workflow/src/TypeValidation.ts +++ b/packages/workflow/src/TypeValidation.ts @@ -1,5 +1,10 @@ import { DateTime } from 'luxon'; -import type { FieldType, INodePropertyOptions, ValidationResult } from './Interfaces'; +import type { + FieldType, + FormFieldsParameter, + INodePropertyOptions, + ValidationResult, +} from './Interfaces'; import isObject from 'lodash/isObject'; import { ApplicationError } from './errors'; import { jsonParse } from './utils'; @@ -147,6 +152,96 @@ export const tryToParseObject = (value: unknown): object => { } }; +const ALLOWED_FORM_FIELDS_KEYS = [ + 'fieldLabel', + 'fieldType', + 'placeholder', + 'fieldOptions', + 'multiselect', + 'multipleFiles', + 'acceptFileTypes', + 'formatDate', + 'requiredField', +]; + +const ALLOWED_FIELD_TYPES = [ + 'date', + 'dropdown', + 'email', + 'file', + 'number', + 'password', + 'text', + 'textarea', +]; + +export const tryToParseFormFields = (value: unknown): FormFieldsParameter => { + const fields: FormFieldsParameter = []; + + try { + const rawFields = jsonParse>(value as string, { + acceptJSObject: true, + }); + + for (const [index, field] of rawFields.entries()) { + for (const key of Object.keys(field)) { + if (!ALLOWED_FORM_FIELDS_KEYS.includes(key)) { + throw new ApplicationError(`Key '${key}' in field ${index} is not valid for form fields`); + } + if ( + key !== 'fieldOptions' && + !['string', 'number', 'boolean'].includes(typeof field[key]) + ) { + field[key] = String(field[key]); + } else if (typeof field[key] === 'string') { + field[key] = field[key].replace(//g, '>'); + } + + if (key === 'fieldType' && !ALLOWED_FIELD_TYPES.includes(field[key] as string)) { + throw new ApplicationError( + `Field type '${field[key] as string}' in field ${index} is not valid for form fields`, + ); + } + + if (key === 'fieldOptions') { + if (Array.isArray(field[key])) { + field[key] = { values: field[key] }; + } + + if ( + typeof field[key] !== 'object' || + !(field[key] as { [key: string]: unknown }).values + ) { + throw new ApplicationError( + `Field dropdown in field ${index} does has no 'values' property that contain an array of options`, + ); + } + + for (const [optionIndex, option] of ( + (field[key] as { [key: string]: unknown }).values as Array<{ + [key: string]: { option: string }; + }> + ).entries()) { + if (Object.keys(option).length !== 1 || typeof option.option !== 'string') { + throw new ApplicationError( + `Field dropdown in field ${index} has an invalid option ${optionIndex}`, + ); + } + } + } + } + + fields.push(field as FormFieldsParameter[number]); + } + } catch (error) { + if (error instanceof ApplicationError) throw error; + + throw new ApplicationError('Value is not valid JSON'); + } + + return fields; +}; + export const getValueDescription = (value: T): string => { if (typeof value === 'object') { if (value === null) return "'null'"; @@ -324,6 +419,16 @@ export function validateFieldType( }; } } + case 'form-fields': { + try { + return { valid: true, newValue: tryToParseFormFields(value) }; + } catch (e) { + return { + valid: false, + errorMessage: (e as Error).message, + }; + } + } default: { return { valid: true, newValue: value }; } From 065659b968fe1c7671006b41929901977c9b850b Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 22 Aug 2024 12:52:24 +0300 Subject: [PATCH 25/56] form flashing on reload fix --- packages/cli/src/webhooks/WaitingForms.ts | 31 +++++++++++------------ 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/webhooks/WaitingForms.ts b/packages/cli/src/webhooks/WaitingForms.ts index bb2b0b552bddb..f78c352ddd65e 100644 --- a/packages/cli/src/webhooks/WaitingForms.ts +++ b/packages/cli/src/webhooks/WaitingForms.ts @@ -9,6 +9,7 @@ import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from './webh import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { IExecutionResponse } from '@/Interfaces'; +import axios from 'axios'; @Service() export class WaitingForms extends WaitingWebhooks { @@ -60,21 +61,14 @@ export class WaitingForms extends WaitingWebhooks { } if (execution.status === 'running') { - const workflow = this.getWorkflow(execution); - - const childNodes = workflow.getChildNodes( - execution.data.resultData.lastNodeExecuted as string, - ); - - const hasNextPage = childNodes.some((nodeName) => { - const node = workflow.nodes[nodeName]; - return !node.disabled && node.type === FORM_NODE_TYPE; - }); - - if (hasNextPage && this.includeForms) { + if (this.includeForms && req.method === 'GET') { await sleep(1000); - res.send(` + const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`; + const page = await axios({ url }); + + if (page) { + res.send(` `); - return { - noWebhookResponse: true, - }; + return { + noWebhookResponse: true, + }; + } else { + return { + noWebhookResponse: true, + }; + } } throw new ConflictError(`The execution "${executionId} is running already.`); } From a78e4c44e18e816f2abf077d386ecbed48bd3a13 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 22 Aug 2024 13:03:33 +0300 Subject: [PATCH 26/56] merge fixes --- packages/cli/src/abstract-server.ts | 2 +- packages/cli/src/webhooks/{WaitingForms.ts => waiting-forms.ts} | 2 +- packages/cli/test/integration/webhooks.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename packages/cli/src/webhooks/{WaitingForms.ts => waiting-forms.ts} (98%) diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index 19570d094423a..8f0f71c81a115 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -13,7 +13,7 @@ import { N8nInstanceType } from '@/Interfaces'; import { ExternalHooks } from '@/external-hooks'; import { send, sendErrorResponse } from '@/response-helper'; import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares'; -import { WaitingForms } from '@/webhooks/WaitingForms'; +import { WaitingForms } from '@/webhooks/waiting-forms'; import { TestWebhooks } from '@/webhooks/test-webhooks'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; import { createWebhookHandlerFor } from '@/webhooks/webhook-request-handler'; diff --git a/packages/cli/src/webhooks/WaitingForms.ts b/packages/cli/src/webhooks/waiting-forms.ts similarity index 98% rename from packages/cli/src/webhooks/WaitingForms.ts rename to packages/cli/src/webhooks/waiting-forms.ts index f78c352ddd65e..dc3dffa9bdcd6 100644 --- a/packages/cli/src/webhooks/WaitingForms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -1,4 +1,4 @@ -import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; +import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; import { FORM_NODE_TYPE, sleep, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; diff --git a/packages/cli/test/integration/webhooks.test.ts b/packages/cli/test/integration/webhooks.test.ts index 13508595af72f..97a2dc94a6943 100644 --- a/packages/cli/test/integration/webhooks.test.ts +++ b/packages/cli/test/integration/webhooks.test.ts @@ -6,7 +6,7 @@ import { LiveWebhooks } from '@/webhooks/live-webhooks'; import { ExternalHooks } from '@/external-hooks'; import { TestWebhooks } from '@/webhooks/test-webhooks'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; -import { WaitingForms } from '@/waiting-forms'; +import { WaitingForms } from '@/webhooks/waiting-forms'; import type { IWebhookResponseCallbackData } from '@/webhooks/webhook.types'; import { mockInstance } from '@test/mocking'; From c0c92c659a443b8e164207a4b1b2c038c144c4f8 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 22 Aug 2024 13:07:19 +0300 Subject: [PATCH 27/56] removed hard coded form types --- packages/editor-ui/src/composables/useRunWorkflow.ts | 2 +- packages/nodes-base/nodes/Form/Form.node.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index b034cd02351fe..e37dfa2803854 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -265,7 +265,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType node.type === 'n8n-nodes-base.form'); + const hasNextPage = connectedNodes.some((node) => node.type === FORM_NODE_TYPE); if (hasNextPage) { redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string; From 9eb86e87b1c458d7249858832d32a5ea2a5b171b Mon Sep 17 00:00:00 2001 From: Giulio Andreini Date: Fri, 23 Aug 2024 19:11:43 +0200 Subject: [PATCH 28/56] Minor copy tweaks. --- packages/editor-ui/src/components/ParameterInputList.vue | 2 +- packages/nodes-base/nodes/Form/Form.node.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/components/ParameterInputList.vue b/packages/editor-ui/src/components/ParameterInputList.vue index b0c02e223358b..570dc652c2416 100644 --- a/packages/editor-ui/src/components/ParameterInputList.vue +++ b/packages/editor-ui/src/components/ParameterInputList.vue @@ -340,7 +340,7 @@ function updateFormTriggerParameters(parameters: INodeProperties[], triggerName: if (parameter.name === 'responseMode') { triggerParameters.push({ displayName: - "When n8n Form node connected to this trigger 'Response' mode is selected automaticly", + "This node is automatically set to use the wired n8n Form node. It's not possible to select other 'Respond When' options.", name: 'formResponseModeNotice', type: 'notice', default: '', diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index bb7c4e617afe3..e50f370f05b73 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -191,7 +191,7 @@ export class Form extends Node { properties: [ { // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased - displayName: 'n8n Form Trigger node must be set before this node', + displayName: 'An n8n Form Trigger node must be set up before this node', name: 'triggerNotice', type: 'notice', default: '', From 9ffaae81c8ed0e29abab929ae2aa60919a1c4c92 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Mon, 2 Sep 2024 12:11:29 +0300 Subject: [PATCH 29/56] merge fixes --- packages/cli/src/webhooks/waiting-forms.ts | 2 +- packages/nodes-base/nodes/Form/Form.node.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index dc3dffa9bdcd6..63538c6b3517a 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -8,7 +8,7 @@ import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from './webh import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { IExecutionResponse } from '@/Interfaces'; +import type { IExecutionResponse } from '@/interfaces'; import axios from 'axios'; @Service() diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index e50f370f05b73..66a1b178c4cf8 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -14,6 +14,7 @@ import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, tryToParseFormFields, + NodeConnectionType, } from 'n8n-workflow'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; @@ -166,8 +167,8 @@ export class Form extends Node { defaults: { name: 'Form', }, - inputs: ['main'], - outputs: ['main'], + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], webhooks: [ { name: 'default', From 804bde9d675df1ef8c81e2ce28ff984ceda8e810 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Mon, 2 Sep 2024 16:25:39 +0300 Subject: [PATCH 30/56] render default form submited if no completion page and no errors on waiting form --- packages/cli/src/webhooks/waiting-forms.ts | 16 ++++++++++++---- packages/cli/src/webhooks/waiting-webhooks.ts | 6 +++--- packages/cli/templates/form-trigger.handlebars | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index 63538c6b3517a..cc112c0abbcc3 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -53,11 +53,11 @@ export class WaitingForms extends WaitingWebhooks { const execution = await this.getExecution(executionId); if (!execution) { - throw new NotFoundError(`The execution "${executionId} does not exist.`); + throw new NotFoundError(`The execution "${executionId}" does not exist.`); } if (execution.data.resultData.error) { - throw new ConflictError(`The execution "${executionId} has finished with error.`); + throw new ConflictError(`The execution "${executionId}" has finished with error.`); } if (execution.status === 'running') { @@ -85,7 +85,7 @@ export class WaitingForms extends WaitingWebhooks { }; } } - throw new ConflictError(`The execution "${executionId} is running already.`); + throw new ConflictError(`The execution "${executionId}" is running already.`); } let completionPage; @@ -118,7 +118,15 @@ export class WaitingForms extends WaitingWebhooks { } if (!completionPage) { - throw new ConflictError(`The execution "${executionId} has finished already.`); + res.render('form-trigger-completion', { + title: 'Form Submitted', + message: 'Your response has been recorded', + formTitle: 'Form Submitted', + }); + + return { + noWebhookResponse: true, + }; } } diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index ec17073a68cbd..ff8c5f1010994 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -151,15 +151,15 @@ export class WaitingWebhooks implements IWebhookManager { const execution = await this.getExecution(executionId); if (!execution) { - throw new NotFoundError(`The execution "${executionId} does not exist.`); + throw new NotFoundError(`The execution "${executionId}" does not exist.`); } if (execution.status === 'running') { - throw new ConflictError(`The execution "${executionId} is running already.`); + throw new ConflictError(`The execution "${executionId}" is running already.`); } if (execution.finished || execution.data.resultData.error) { - throw new ConflictError(`The execution "${executionId} has finished already.`); + throw new ConflictError(`The execution "${executionId}" has finished already.`); } const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 31a457ddaae57..581416b98c231 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -735,7 +735,7 @@ document.querySelector('#submitted-form').style.display = 'block'; document.querySelector('#submitted-header').textContent = 'Problem submitting response'; document.querySelector('#submitted-content').textContent = - 'An error occurred in the workflow handling this form'; + 'Please try again or contact support if the problem persists'; } return; From 06ab0ce45db9212eab8caf3fe9ab233014c1cc0b Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 3 Sep 2024 08:56:21 +0300 Subject: [PATCH 31/56] fix json parse, show add form on side panel if form trigger in workflow --- .../Node/NodeCreator/Panel/NodesListPanel.vue | 12 ++++++++- .../components/Node/NodeCreator/viewsData.ts | 24 +++++++++++++++++- .../src/plugins/i18n/locales/en.json | 2 ++ packages/nodes-base/nodes/Form/Form.node.ts | 16 +++++++++--- packages/nodes-base/nodes/Form/utils.ts | 25 +++++++++++++++++++ packages/nodes-base/nodes/Wait/Wait.node.ts | 2 ++ 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue b/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue index 2cceed685af85..d55bfff3b7cd2 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue +++ b/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue @@ -6,9 +6,11 @@ import { AI_NODE_CREATOR_VIEW, REGULAR_NODE_CREATOR_VIEW, TRIGGER_NODE_CREATOR_VIEW, + FORM_TRIGGER_NODE_TYPE, } from '@/constants'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; +import { useWorkflowsStore } from '@/stores/workflows.store'; import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData'; import { useViewStacks } from '../composables/useViewStacks'; @@ -92,7 +94,15 @@ watch( console.warn(`No view found for ${itemKey}`); return; } - const view = matchedView(mergedNodes); + let view; + if (itemKey === REGULAR_NODE_CREATOR_VIEW) { + const hasFormTrigger = useWorkflowsStore() + .getNodes() + .some((n) => n.type === FORM_TRIGGER_NODE_TYPE && !n.disabled); + view = matchedView(mergedNodes, { showForm: hasFormTrigger }); + } else { + view = matchedView(mergedNodes); + } pushViewStack({ title: view.title, diff --git a/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts b/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts index 33d3626d7e320..943dc839a8aa6 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts +++ b/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts @@ -55,6 +55,7 @@ import { COMPRESSION_NODE_TYPE, AI_CODE_TOOL_LANGCHAIN_NODE_TYPE, AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE, + FORM_NODE_TYPE, } from '@/constants'; import { useI18n } from '@/composables/useI18n'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; @@ -427,8 +428,28 @@ export function TriggerView() { return view; } -export function RegularView(nodes: SimplifiedNodeType[]) { +export function RegularView(nodes: SimplifiedNodeType[], options?: { showForm?: boolean }) { const i18n = useI18n(); + const formPage: NodeViewItem[] = []; + + if (options?.showForm) { + formPage.push({ + key: FORM_NODE_TYPE, + type: 'node', + category: [CORE_NODES_CATEGORY], + properties: { + group: [], + name: FORM_NODE_TYPE, + displayName: i18n.baseText('nodeCreator.triggerHelperPanel.formDisplayName'), + description: i18n.baseText('nodeCreator.triggerHelperPanel.formDescription'), + iconData: { + type: 'file', + icon: 'form', + fileBuffer: '/static/form-grey.svg', + }, + }, + }); + } const popularItemsSubcategory = [ SET_NODE_TYPE, @@ -441,6 +462,7 @@ export function RegularView(nodes: SimplifiedNodeType[]) { value: REGULAR_NODE_CREATOR_VIEW, title: i18n.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'), items: [ + ...formPage, { key: DEFAULT_SUBCATEGORY, type: 'subcategory', diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index e1c12c350d7b1..019027a055b11 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1085,6 +1085,8 @@ "nodeCreator.triggerHelperPanel.webhookTriggerDescription": "Runs the flow on receiving an HTTP request", "nodeCreator.triggerHelperPanel.formTriggerDisplayName": "On form submission", "nodeCreator.triggerHelperPanel.formTriggerDescription": "Runs the flow when an n8n generated webform is submitted", + "nodeCreator.triggerHelperPanel.formDisplayName": "Form", + "nodeCreator.triggerHelperPanel.formDescription": "Add next form page", "nodeCreator.triggerHelperPanel.manualTriggerDisplayName": "Trigger manually", "nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n. Good for getting started quickly", "nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message", diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 66a1b178c4cf8..71676a40accbe 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -18,7 +18,7 @@ import { } from 'n8n-workflow'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; -import { prepareFormReturnItem, renderForm } from '../Form/utils'; +import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils'; const pageProperties = updateDisplayOptions( { @@ -189,6 +189,14 @@ export class Form extends Node { isForm: true, }, ], + hints: [ + { + message: + "When testing your workflow using the Editor UI, you can't see the rest of the execution following the n8n Form node. To inspect the execution results, enable Save Manual Executions in your Workflow settings so you can review the execution results in the Executions tab.", + location: 'outputPane', + whenToDisplay: 'beforeExecution', + }, + ], properties: [ { // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased @@ -238,9 +246,11 @@ export class Form extends Node { let fields: FormFieldsParameter = []; if (useJson) { try { - const jsonOutput = context.getNodeParameter('jsonOutput', '') as string; + const jsonOutput = context.getNodeParameter('jsonOutput', '', { + rawExpressions: true, + }) as string; - fields = tryToParseFormFields(jsonOutput); + fields = tryToParseFormFields(resolveRawData(context, jsonOutput)); } catch (error) { throw new NodeOperationError(context.getNode(), error.message, { description: error.message, diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index fcabe5490e2d0..5e6dd36f64599 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -16,6 +16,7 @@ import { validateWebhookAuthentication } from '../Webhook/utils'; import { DateTime } from 'luxon'; import isbot from 'isbot'; import type { Response } from 'express'; +import { getResolvables } from '../../utils/utilities'; export function prepareFormData({ formTitle, @@ -395,3 +396,27 @@ export async function formWebhook( workflowData: [[returnItem]], }; } + +export function resolveRawData(context: IWebhookFunctions, rawData: string) { + const resolvables = getResolvables(rawData); + let returnData: string = rawData; + + if (returnData.startsWith('=')) { + returnData = returnData.replace(/^=+/, ''); + } else { + return returnData; + } + + if (resolvables.length) { + for (const resolvable of resolvables) { + const resolvedValue = context.evaluateExpression(`${resolvable}`); + + if (typeof resolvedValue === 'object' && resolvedValue !== null) { + returnData = returnData.replace(resolvable, JSON.stringify(resolvedValue)); + } else { + returnData = returnData.replace(resolvable, resolvedValue as string); + } + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/Wait/Wait.node.ts b/packages/nodes-base/nodes/Wait/Wait.node.ts index 5da813981bf4f..644d45ced4775 100644 --- a/packages/nodes-base/nodes/Wait/Wait.node.ts +++ b/packages/nodes-base/nodes/Wait/Wait.node.ts @@ -244,6 +244,8 @@ export class Wait extends Webhook { "When testing your workflow using the Editor UI, you can't see the rest of the execution following the Wait node. To inspect the execution results, enable Save Manual Executions in your Workflow settings so you can review the execution results there.", location: 'outputPane', whenToDisplay: 'beforeExecution', + displayCondition: + '={{$parameter["resume"] === "webhook" || $parameter["resume"] === "form"}}', }, ], webhooks: [ From 1e23779e36279869ab89ad59086e27f3512f0227 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 3 Sep 2024 09:14:58 +0300 Subject: [PATCH 32/56] get query parameters only on query --- packages/nodes-base/nodes/Form/utils.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 5e6dd36f64599..2951613e4fb37 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -5,7 +5,7 @@ import type { IWebhookFunctions, FormFieldsParameter, } from 'n8n-workflow'; -import { NodeOperationError, jsonParse } from 'n8n-workflow'; +import { FORM_TRIGGER_NODE_TYPE, NodeOperationError, jsonParse } from 'n8n-workflow'; import type { FormTriggerData, FormTriggerInput } from './interfaces'; import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces'; @@ -269,7 +269,10 @@ export function renderForm({ const useResponseData = responseMode === 'responseNode'; - const query = context.getRequestObject().query as IDataObject; + let query: IDataObject = {}; + if (context.getNode().type === FORM_TRIGGER_NODE_TYPE) { + query = context.getRequestObject().query as IDataObject; + } const data = prepareFormData({ formTitle, From 5d66e1906e9fe9501a133c84141dac15c0bd9d78 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Tue, 3 Sep 2024 12:23:50 +0300 Subject: [PATCH 33/56] query parameters support for form node --- packages/nodes-base/nodes/Form/utils.ts | 35 ++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 2951613e4fb37..c6313414c774e 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -4,8 +4,14 @@ import type { IDataObject, IWebhookFunctions, FormFieldsParameter, + NodeTypeAndVersion, +} from 'n8n-workflow'; +import { + FORM_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, + NodeOperationError, + jsonParse, } from 'n8n-workflow'; -import { FORM_TRIGGER_NODE_TYPE, NodeOperationError, jsonParse } from 'n8n-workflow'; import type { FormTriggerData, FormTriggerInput } from './interfaces'; import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces'; @@ -236,6 +242,14 @@ export async function prepareFormReturnItem( returnItem.json.formMode = mode; + const workflowStaticData = context.getWorkflowStaticData('node'); + if ( + Object.keys(workflowStaticData || {}).length && + context.getNode().type === FORM_TRIGGER_NODE_TYPE + ) { + returnItem.json.formQueryParameters = workflowStaticData; + } + return returnItem; } @@ -270,8 +284,27 @@ export function renderForm({ const useResponseData = responseMode === 'responseNode'; let query: IDataObject = {}; + if (context.getNode().type === FORM_TRIGGER_NODE_TYPE) { query = context.getRequestObject().query as IDataObject; + const workflowStaticData = context.getWorkflowStaticData('node'); + for (const key of Object.keys(query)) { + workflowStaticData[key] = query[key]; + } + } else if (context.getNode().type === FORM_NODE_TYPE) { + const parentNodes = context.getParentNodes(context.getNode().name); + const trigger = parentNodes.find( + (node) => node.type === FORM_TRIGGER_NODE_TYPE, + ) as NodeTypeAndVersion; + try { + const triggerQueryParameters = context.evaluateExpression( + `{{ $('${trigger?.name}').first().json.formQueryParameters }}`, + ) as IDataObject; + + if (triggerQueryParameters) { + query = triggerQueryParameters; + } + } catch (error) {} } const data = prepareFormData({ From 384f542fa4c078a28de10282ac33b3162d80ac23 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 4 Sep 2024 07:29:05 +0300 Subject: [PATCH 34/56] utils tests --- .../nodes-base/nodes/Form/test/utils.test.ts | 313 +++++++++++++++++- packages/workflow/src/Interfaces.ts | 4 +- 2 files changed, 313 insertions(+), 4 deletions(-) diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index c7e45699f835e..e41aa3885c308 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -1,6 +1,12 @@ import { mock } from 'jest-mock-extended'; -import type { FormFieldsParameter, IWebhookFunctions } from 'n8n-workflow'; -import { formWebhook, prepareFormData } from '../utils'; +import type { + FormFieldsParameter, + INode, + IWebhookFunctions, + MultiPartFormData, +} from 'n8n-workflow'; +import { DateTime } from 'luxon'; +import { formWebhook, prepareFormData, prepareFormReturnItem, resolveRawData } from '../utils'; describe('FormTrigger, formWebhook', () => { beforeEach(() => { @@ -369,3 +375,306 @@ describe('FormTrigger, prepareFormData', () => { ]); }); }); + +jest.mock('luxon', () => ({ + DateTime: { + fromFormat: jest.fn().mockReturnValue({ + toFormat: jest.fn().mockReturnValue('formatted-date'), + }), + now: jest.fn().mockReturnValue({ + setZone: jest.fn().mockReturnValue({ + toISO: jest.fn().mockReturnValue('2023-04-01T12:00:00.000Z'), + }), + }), + }, +})); + +describe('prepareFormReturnItem', () => { + const mockContext = mock({ + nodeHelpers: mock({ + copyBinaryFile: jest.fn().mockResolvedValue({}), + }), + }); + const formNode = mock({ type: 'n8n-nodes-base.formTrigger' }); + + beforeEach(() => { + jest.clearAllMocks(); + mockContext.getBodyData.mockReturnValue({ data: {}, files: {} }); + mockContext.getTimezone.mockReturnValue('UTC'); + mockContext.getNode.mockReturnValue(formNode); + mockContext.getWorkflowStaticData.mockReturnValue({}); + }); + + it('should handle empty form submission', async () => { + const result = await prepareFormReturnItem(mockContext, [], 'test'); + + expect(result).toEqual({ + json: { + submittedAt: '2023-04-01T12:00:00.000Z', + formMode: 'test', + }, + }); + }); + + it('should process text fields correctly', async () => { + mockContext.getBodyData.mockReturnValue({ + data: { 'field-0': ' test value ' }, + files: {}, + }); + + const formFields = [{ fieldLabel: 'Text Field', fieldType: 'text' }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'production'); + + expect(result.json['Text Field']).toBe('test value'); + expect(result.json.formMode).toBe('production'); + }); + + it('should process number fields correctly', async () => { + mockContext.getBodyData.mockReturnValue({ + data: { 'field-0': '42' }, + files: {}, + }); + + const formFields = [{ fieldLabel: 'Number Field', fieldType: 'number' }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json['Number Field']).toBe(42); + }); + + it('should handle file uploads', async () => { + const mockFile: Partial = { + filepath: '/tmp/uploaded-file', + originalFilename: 'test.txt', + mimetype: 'text/plain', + size: 1024, + }; + + mockContext.getBodyData.mockReturnValue({ + data: {}, + files: { 'field-0': mockFile }, + }); + + const formFields = [{ fieldLabel: 'File Upload', fieldType: 'file' }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json['File Upload']).toEqual({ + filename: 'test.txt', + mimetype: 'text/plain', + size: 1024, + }); + expect(result.binary).toBeDefined(); + expect(result.binary!.File_Upload).toEqual({}); + }); + + it('should handle multiple file uploads', async () => { + const mockFiles: Array> = [ + { filepath: '/tmp/file1', originalFilename: 'file1.txt', mimetype: 'text/plain', size: 1024 }, + { filepath: '/tmp/file2', originalFilename: 'file2.txt', mimetype: 'text/plain', size: 2048 }, + ]; + + mockContext.getBodyData.mockReturnValue({ + data: {}, + files: { 'field-0': mockFiles }, + }); + + const formFields = [{ fieldLabel: 'Multiple Files', fieldType: 'file', multipleFiles: true }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json['Multiple Files']).toEqual([ + { filename: 'file1.txt', mimetype: 'text/plain', size: 1024 }, + { filename: 'file2.txt', mimetype: 'text/plain', size: 2048 }, + ]); + expect(result.binary).toBeDefined(); + expect(result.binary!.Multiple_Files_0).toEqual({}); + expect(result.binary!.Multiple_Files_1).toEqual({}); + }); + + it('should format date fields', async () => { + mockContext.getBodyData.mockReturnValue({ + data: { 'field-0': '2023-04-01' }, + files: {}, + }); + + const formFields = [{ fieldLabel: 'Date Field', fieldType: 'date', formatDate: 'dd/MM/yyyy' }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json['Date Field']).toBe('formatted-date'); + expect(DateTime.fromFormat).toHaveBeenCalledWith('2023-04-01', 'yyyy-mm-dd'); + }); + + it('should handle multiselect fields', async () => { + mockContext.getBodyData.mockReturnValue({ + data: { 'field-0': '["option1", "option2"]' }, + files: {}, + }); + + const formFields = [{ fieldLabel: 'Multiselect', fieldType: 'multiSelect', multiselect: true }]; + const result = await prepareFormReturnItem(mockContext, formFields, 'test'); + + expect(result.json.Multiselect).toEqual(['option1', 'option2']); + }); + + it('should use workflow timezone when specified', async () => { + mockContext.getTimezone.mockReturnValue('America/New_York'); + + await prepareFormReturnItem(mockContext, [], 'test', true); + + expect(mockContext.getTimezone).toHaveBeenCalled(); + expect(DateTime.now().setZone).toHaveBeenCalledWith('America/New_York'); + }); + + it('should include workflow static data for form trigger node', async () => { + const staticData = { queryParam: 'value' }; + mockContext.getWorkflowStaticData.mockReturnValue(staticData); + + const result = await prepareFormReturnItem(mockContext, [], 'test'); + + expect(result.json.formQueryParameters).toEqual(staticData); + }); +}); + +describe('resolveRawData', () => { + const mockContext = mock(); + + const dummyData = { + name: 'Hanna', + age: 30, + city: 'New York', + isStudent: false, + hasJob: true, + grades: { + math: 95, + science: 88, + history: 92, + }, + hobbies: ['reading', 'painting', 'coding'], + address: { + street: '123 Main St', + zipCode: '10001', + country: 'USA', + }, + languages: ['English', 'Spanish'], + projects: [ + { name: 'Project A', status: 'completed' }, + { name: 'Project B', status: 'in-progress' }, + ], + emptyArray: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockContext.evaluateExpression.mockImplementation((expression: string) => { + const key = expression.replace(/[{}]/g, '').trim(); + return key.split('.').reduce((obj, prop) => obj?.[prop], dummyData as any); + }); + }); + + it('should return the input string if it does not start with "="', () => { + const input = 'Hello, world!'; + expect(resolveRawData(mockContext, input)).toBe(input); + }); + + it('should remove leading "=" characters', () => { + const input = '=Hello, world!'; + expect(resolveRawData(mockContext, input)).toBe('Hello, world!'); + }); + + it('should resolve a single expression', () => { + const input = '=Hello, {{name}}!'; + expect(resolveRawData(mockContext, input)).toBe('Hello, Hanna!'); + }); + + it('should resolve multiple expressions', () => { + const input = '={{name}} is {{age}} years old and lives in {{city}}.'; + expect(resolveRawData(mockContext, input)).toBe('Hanna is 30 years old and lives in New York.'); + }); + + it('should handle object resolutions', () => { + const input = '=Grades: {{grades}}'; + expect(resolveRawData(mockContext, input)).toBe( + 'Grades: {"math":95,"science":88,"history":92}', + ); + }); + + it('should handle nested object properties', () => { + const input = "={{name}}'s math grade is {{grades.math}}."; + expect(resolveRawData(mockContext, input)).toBe("Hanna's math grade is 95."); + }); + + it('should handle boolean values', () => { + const input = '=Is {{name}} a student? {{isStudent}}'; + expect(resolveRawData(mockContext, input)).toBe('Is Hanna a student? false'); + }); + + it('should handle expressions with whitespace', () => { + const input = '={{ name }} is {{ age }} years old.'; + expect(resolveRawData(mockContext, input)).toBe('Hanna is 30 years old.'); + }); + + it('should return the original string if no resolvables are found', () => { + const input = '=Hello, world!'; + expect(resolveRawData(mockContext, input)).toBe('Hello, world!'); + }); + + it('should handle non-existent properties gracefully', () => { + const input = "={{name}}'s favorite color is {{favoriteColor}}."; + expect(resolveRawData(mockContext, input)).toBe("Hanna's favorite color is undefined."); + }); + + it('should handle mixed resolvable and non-resolvable content', () => { + const input = '={{name}} lives in {{city}} and enjoys programming.'; + expect(resolveRawData(mockContext, input)).toBe( + 'Hanna lives in New York and enjoys programming.', + ); + }); + + it('should handle boolean values correctly', () => { + const input = '={{name}} is a student: {{isStudent}}. {{name}} has a job: {{hasJob}}.'; + expect(resolveRawData(mockContext, input)).toBe( + 'Hanna is a student: false. Hanna has a job: true.', + ); + }); + + it('should handle arrays correctly', () => { + const input = "={{name}}'s hobbies are {{hobbies}}."; + expect(resolveRawData(mockContext, input)).toBe( + 'Hanna\'s hobbies are ["reading","painting","coding"].', + ); + }); + + it('should handle nested objects correctly', () => { + const input = '={{name}} lives at {{address.street}}, {{address.zipCode}}.'; + expect(resolveRawData(mockContext, input)).toBe('Hanna lives at 123 Main St, 10001.'); + }); + + it('should handle arrays of objects correctly', () => { + const input = '=Project statuses: {{projects.0.status}}, {{projects.1.status}}.'; + expect(resolveRawData(mockContext, input)).toBe('Project statuses: completed, in-progress.'); + }); + + it('should handle empty arrays correctly', () => { + const input = '=Empty array: {{emptyArray}}.'; + expect(resolveRawData(mockContext, input)).toBe('Empty array: [].'); + }); + + it('should handle a mix of different data types', () => { + const input = + '={{name}} ({{age}}) knows {{languages.length}} languages. First project: {{projects.0.name}}.'; + expect(resolveRawData(mockContext, input)).toBe( + 'Hanna (30) knows 2 languages. First project: Project A.', + ); + }); + + it('should handle nested array access', () => { + const input = '=First hobby: {{hobbies.0}}, Last hobby: {{hobbies.2}}.'; + expect(resolveRawData(mockContext, input)).toBe('First hobby: reading, Last hobby: coding.'); + }); + + it('should handle object-to-string conversion', () => { + const input = '=Address object: {{address}}.'; + expect(resolveRawData(mockContext, input)).toBe( + 'Address object: {"street":"123 Main St","zipCode":"10001","country":"USA"}.', + ); + }); +}); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index e7ff44f38e9db..20720ab5cac7c 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2492,8 +2492,8 @@ export interface ResourceMapperField { export type FormFieldsParameter = Array<{ fieldLabel: string; - fieldType: string; - requiredField: boolean; + fieldType?: string; + requiredField?: boolean; fieldOptions?: { values: Array<{ option: string }> }; multiselect?: boolean; multipleFiles?: boolean; From a5f0e2478cd7aa845a91146320f7948fd964224c Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 4 Sep 2024 12:16:22 +0300 Subject: [PATCH 35/56] form node's execute and webhook methods test --- .../nodes/Form/test/Form.node.test.ts | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 packages/nodes-base/nodes/Form/test/Form.node.test.ts diff --git a/packages/nodes-base/nodes/Form/test/Form.node.test.ts b/packages/nodes-base/nodes/Form/test/Form.node.test.ts new file mode 100644 index 0000000000000..616e3cf8dbd62 --- /dev/null +++ b/packages/nodes-base/nodes/Form/test/Form.node.test.ts @@ -0,0 +1,190 @@ +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import type { + IExecuteFunctions, + INode, + INodeExecutionData, + IWebhookFunctions, + NodeTypeAndVersion, +} from 'n8n-workflow'; +import type { Response, Request } from 'express'; +import { Form } from '../Form.node'; + +describe('Form Node', () => { + let form: Form; + let mockExecuteFunctions: MockProxy; + let mockWebhookFunctions: MockProxy; + + beforeEach(() => { + form = new Form(); + mockExecuteFunctions = mock(); + mockWebhookFunctions = mock(); + }); + + describe('execute method', () => { + it('should throw an error if Form Trigger node is not set', async () => { + mockExecuteFunctions.getNodeParameter.mockReturnValue('page'); + mockExecuteFunctions.getParentNodes.mockReturnValue([]); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + + await expect(form.execute(mockExecuteFunctions)).rejects.toThrow( + 'Form Trigger node must be set before this node', + ); + }); + + it('should put execution to wait if operation is not completion', async () => { + mockExecuteFunctions.getNodeParameter.mockReturnValue('page'); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + mock({ type: 'n8n-nodes-base.formTrigger' }), + ]); + mockExecuteFunctions.getChildNodes.mockReturnValue([]); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + + await form.execute(mockExecuteFunctions); + + expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalled(); + }); + + it('should throw an error if completion is not the last Form node', async () => { + mockExecuteFunctions.getNodeParameter.mockReturnValue('completion'); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + mock({ type: 'n8n-nodes-base.formTrigger' }), + ]); + mockExecuteFunctions.getChildNodes.mockReturnValue([ + mock({ type: 'n8n-nodes-base.form' }), + ]); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + + await expect(form.execute(mockExecuteFunctions)).rejects.toThrow( + 'Completion has to be the last Form node in the workflow', + ); + }); + + it('should return input data for completion operation', async () => { + const inputData: INodeExecutionData[] = [{ json: { test: 'data' } }]; + mockExecuteFunctions.getNodeParameter.mockReturnValue('completion'); + mockExecuteFunctions.getParentNodes.mockReturnValue([ + mock({ type: 'n8n-nodes-base.formTrigger' }), + ]); + mockExecuteFunctions.getChildNodes.mockReturnValue([]); + mockExecuteFunctions.getInputData.mockReturnValue(inputData); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + + const result = await form.execute(mockExecuteFunctions); + + expect(result).toEqual([inputData]); + }); + }); + + describe('webhook method', () => { + it('should render form for GET request', async () => { + const mockResponseObject = { + render: jest.fn(), + }; + mockWebhookFunctions.getResponseObject.mockReturnValue( + mockResponseObject as unknown as Response, + ); + mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request); + mockWebhookFunctions.getParentNodes.mockReturnValue([ + { type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 }, + ]); + mockWebhookFunctions.evaluateExpression.mockReturnValue('test'); + mockWebhookFunctions.getNode.mockReturnValue(mock()); + mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => { + if (paramName === 'operation') return 'page'; + if (paramName === 'useJson') return false; + if (paramName === 'formFields.values') return [{ fieldLabel: 'test' }]; + if (paramName === 'options') { + return { + formTitle: 'Form Title', + formDescription: 'Form Description', + buttonLabel: 'Form Button', + }; + } + return undefined; + }); + + mockWebhookFunctions.getChildNodes.mockReturnValue([]); + + await form.webhook(mockWebhookFunctions); + + expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger', expect.any(Object)); + }); + + it('should return form data for POST request', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as Request); + mockWebhookFunctions.getParentNodes.mockReturnValue([ + { type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 }, + ]); + mockWebhookFunctions.evaluateExpression.mockReturnValue('test'); + mockWebhookFunctions.getNode.mockReturnValue(mock()); + mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => { + if (paramName === 'operation') return 'page'; + if (paramName === 'useJson') return false; + if (paramName === 'formFields.values') return [{ fieldLabel: 'test' }]; + if (paramName === 'options') { + return { + formTitle: 'Form Title', + formDescription: 'Form Description', + buttonLabel: 'Form Button', + }; + } + return undefined; + }); + + mockWebhookFunctions.getBodyData.mockReturnValue({ + data: { 'field-0': 'test value' }, + files: {}, + }); + + const result = await form.webhook(mockWebhookFunctions); + + expect(result).toHaveProperty('webhookResponse'); + expect(result).toHaveProperty('workflowData'); + expect(result.workflowData).toEqual([ + [ + { + json: expect.objectContaining({ + formMode: 'test', + submittedAt: expect.any(String), + test: 'test value', + }), + }, + ], + ]); + }); + + it('should handle completion operation', async () => { + mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request); + mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => { + if (paramName === 'operation') return 'completion'; + if (paramName === 'useJson') return false; + if (paramName === 'jsonOutput') return '[]'; + if (paramName === 'respondWith') return 'text'; + if (paramName === 'completionTitle') return 'Test Title'; + if (paramName === 'completionMessage') return 'Test Message'; + return {}; + }); + mockWebhookFunctions.getParentNodes.mockReturnValue([ + { type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 }, + ]); + mockWebhookFunctions.evaluateExpression.mockReturnValue('test'); + + const mockResponseObject = { + render: jest.fn(), + }; + mockWebhookFunctions.getResponseObject.mockReturnValue( + mockResponseObject as unknown as Response, + ); + mockWebhookFunctions.getNode.mockReturnValue(mock()); + + const result = await form.webhook(mockWebhookFunctions); + + expect(result).toEqual({ noWebhookResponse: true }); + expect(mockResponseObject.render).toHaveBeenCalledWith( + 'form-trigger-completion', + expect.any(Object), + ); + }); + }); +}); From d2a53ec181b6286dea341bfc378c7dcd64b16c29 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Mon, 9 Sep 2024 07:54:26 +0300 Subject: [PATCH 36/56] review update --- .../Node/NodeCreator/Panel/NodesListPanel.vue | 12 +--------- .../components/Node/NodeCreator/viewsData.ts | 24 +------------------ .../src/components/ParameterInputList.vue | 10 +++++--- .../src/composables/useRunWorkflow.ts | 10 ++++---- .../editor-ui/src/utils/executionUtils.ts | 9 +++++-- packages/nodes-base/nodes/Form/Form.node.json | 3 +++ .../nodes/Form/v2/FormTriggerV2.node.ts | 8 +++++++ packages/workflow/src/Constants.ts | 2 ++ 8 files changed, 35 insertions(+), 43 deletions(-) diff --git a/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue b/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue index d55bfff3b7cd2..2cceed685af85 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue +++ b/packages/editor-ui/src/components/Node/NodeCreator/Panel/NodesListPanel.vue @@ -6,11 +6,9 @@ import { AI_NODE_CREATOR_VIEW, REGULAR_NODE_CREATOR_VIEW, TRIGGER_NODE_CREATOR_VIEW, - FORM_TRIGGER_NODE_TYPE, } from '@/constants'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; -import { useWorkflowsStore } from '@/stores/workflows.store'; import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData'; import { useViewStacks } from '../composables/useViewStacks'; @@ -94,15 +92,7 @@ watch( console.warn(`No view found for ${itemKey}`); return; } - let view; - if (itemKey === REGULAR_NODE_CREATOR_VIEW) { - const hasFormTrigger = useWorkflowsStore() - .getNodes() - .some((n) => n.type === FORM_TRIGGER_NODE_TYPE && !n.disabled); - view = matchedView(mergedNodes, { showForm: hasFormTrigger }); - } else { - view = matchedView(mergedNodes); - } + const view = matchedView(mergedNodes); pushViewStack({ title: view.title, diff --git a/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts b/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts index 943dc839a8aa6..33d3626d7e320 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts +++ b/packages/editor-ui/src/components/Node/NodeCreator/viewsData.ts @@ -55,7 +55,6 @@ import { COMPRESSION_NODE_TYPE, AI_CODE_TOOL_LANGCHAIN_NODE_TYPE, AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE, - FORM_NODE_TYPE, } from '@/constants'; import { useI18n } from '@/composables/useI18n'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; @@ -428,28 +427,8 @@ export function TriggerView() { return view; } -export function RegularView(nodes: SimplifiedNodeType[], options?: { showForm?: boolean }) { +export function RegularView(nodes: SimplifiedNodeType[]) { const i18n = useI18n(); - const formPage: NodeViewItem[] = []; - - if (options?.showForm) { - formPage.push({ - key: FORM_NODE_TYPE, - type: 'node', - category: [CORE_NODES_CATEGORY], - properties: { - group: [], - name: FORM_NODE_TYPE, - displayName: i18n.baseText('nodeCreator.triggerHelperPanel.formDisplayName'), - description: i18n.baseText('nodeCreator.triggerHelperPanel.formDescription'), - iconData: { - type: 'file', - icon: 'form', - fileBuffer: '/static/form-grey.svg', - }, - }, - }); - } const popularItemsSubcategory = [ SET_NODE_TYPE, @@ -462,7 +441,6 @@ export function RegularView(nodes: SimplifiedNodeType[], options?: { showForm?: value: REGULAR_NODE_CREATOR_VIEW, title: i18n.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'), items: [ - ...formPage, { key: DEFAULT_SUBCATEGORY, type: 'subcategory', diff --git a/packages/editor-ui/src/components/ParameterInputList.vue b/packages/editor-ui/src/components/ParameterInputList.vue index 4352e856d44eb..5b7f68ed6b83c 100644 --- a/packages/editor-ui/src/components/ParameterInputList.vue +++ b/packages/editor-ui/src/components/ParameterInputList.vue @@ -5,7 +5,7 @@ import type { NodeParameterValue, NodeParameterValueType, } from 'n8n-workflow'; -import { deepCopy } from 'n8n-workflow'; +import { deepCopy, ADD_FORM_NOTICE } from 'n8n-workflow'; import { computed, defineAsyncComponent, onErrorCaptured, ref, watch } from 'vue'; import type { IUpdateInformation } from '@/Interface'; @@ -181,9 +181,13 @@ function updateFormTriggerParameters(parameters: INodeProperties[], triggerName: type: 'notice', default: '', }); - } else { - triggerParameters.push(parameter); + + continue; } + + if (parameter.name === ADD_FORM_NOTICE) continue; + + triggerParameters.push(parameter); } return triggerParameters; } diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index 2698d6015facb..083647b26e1af 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -258,10 +258,12 @@ 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'); - } + try { + const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion); + if (nodeType?.webhooks?.length) { + return workflowHelpers.getWebhookUrl(nodeType.webhooks[0], node, 'test'); + } + } catch (error) {} return ''; }; })(); diff --git a/packages/editor-ui/src/utils/executionUtils.ts b/packages/editor-ui/src/utils/executionUtils.ts index 74458ecff84cf..ba2c12a849d35 100644 --- a/packages/editor-ui/src/utils/executionUtils.ts +++ b/packages/editor-ui/src/utils/executionUtils.ts @@ -1,7 +1,7 @@ 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'; +import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '../constants'; export function getDefaultExecutionFilters(): ExecutionFilterType { return { @@ -135,7 +135,12 @@ export function displayForm({ testUrl = getTestUrl(node); } - if (node.type === WAIT_NODE_TYPE && node.parameters.resume === 'form' && executionId) { + if ( + [WAIT_NODE_TYPE, FORM_NODE_TYPE].includes(node.type) && + // for Wait node we need to check if 'resume' is set to form + (node.parameters.resume === 'form' || node.type === FORM_NODE_TYPE) && + executionId + ) { if (!shouldShowForm(node)) continue; const { webhookSuffix } = (node.parameters.options ?? {}) as IDataObject; diff --git a/packages/nodes-base/nodes/Form/Form.node.json b/packages/nodes-base/nodes/Form/Form.node.json index b51358f0a5397..140e6d7503ae3 100644 --- a/packages/nodes-base/nodes/Form/Form.node.json +++ b/packages/nodes-base/nodes/Form/Form.node.json @@ -11,5 +11,8 @@ } ], "generic": [] + }, + "subcategories": { + "Core Nodes": ["Helpers"] } } diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index 7491bf5f88bbe..c37a7cd3dc060 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -1,5 +1,6 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ import { + ADD_FORM_NOTICE, NodeConnectionType, type INodeProperties, type INodeType, @@ -110,6 +111,13 @@ const descriptionV2: INodeTypeDescription = { }, default: '', }, + // notice would be shown if no Form node was connected to trigger + { + displayName: 'Add an n8n Form page to the workflow to build a multi-step form', + name: ADD_FORM_NOTICE, + type: 'notice', + default: '', + }, { displayName: 'Options', name: 'options', diff --git a/packages/workflow/src/Constants.ts b/packages/workflow/src/Constants.ts index 7a9330182d38f..e5d373db94c1a 100644 --- a/packages/workflow/src/Constants.ts +++ b/packages/workflow/src/Constants.ts @@ -55,6 +55,8 @@ export const SCRIPTING_NODE_TYPES = [ AI_TRANSFORM_NODE_TYPE, ]; +export const ADD_FORM_NOTICE = 'addFormPage'; + /** * Nodes whose parameter values may refer to other nodes without expressions. * Their content may need to be updated when the referenced node is renamed. From b0a15c2e5935d7783ab28ef4ce8bfcbd46407e8b Mon Sep 17 00:00:00 2001 From: Giulio Andreini Date: Tue, 10 Sep 2024 09:28:06 +0200 Subject: [PATCH 37/56] Minor copy tweak --- packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index c37a7cd3dc060..628b9f415b1b4 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -113,7 +113,7 @@ const descriptionV2: INodeTypeDescription = { }, // notice would be shown if no Form node was connected to trigger { - displayName: 'Add an n8n Form page to the workflow to build a multi-step form', + displayName: 'Add an n8n Form Page to the workflow to build a multi-step form', name: ADD_FORM_NOTICE, type: 'notice', default: '', From e72c75104c070407414cd9b011f26fb55dda85a2 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 19 Sep 2024 14:19:03 +0300 Subject: [PATCH 38/56] review updates, wip --- .../src/components/JsonEditor/JsonEditor.vue | 14 +++--- .../src/components/ParameterInput.vue | 2 +- .../src/components/ParameterInputList.vue | 3 +- .../src/composables/usePushConnection.ts | 3 +- .../src/plugins/i18n/locales/en.json | 2 +- packages/editor-ui/src/stores/root.store.ts | 4 +- packages/nodes-base/nodes/Form/Form.node.ts | 43 +++++++++++-------- .../nodes-base/nodes/Form/FormTrigger.node.ts | 2 +- .../nodes-base/nodes/Form/test/utils.test.ts | 10 ++--- packages/nodes-base/nodes/Form/utils.ts | 2 +- .../nodes/Form/v1/FormTriggerV1.node.ts | 2 +- .../nodes/Form/v2/FormTriggerV2.node.ts | 6 +-- 12 files changed, 52 insertions(+), 41 deletions(-) diff --git a/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue b/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue index f1116da097c37..dcf2b8673b96e 100644 --- a/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue +++ b/packages/editor-ui/src/components/JsonEditor/JsonEditor.vue @@ -109,18 +109,18 @@ function destroyEditor() { diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index df63a8b3228f4..3f4f2a6b66583 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -1098,7 +1098,7 @@ onUpdated(async () => { :model-value="modelValueString" :is-read-only="isReadOnly" :rows="editorRows" - fill-parent + fullscreen @update:model-value="valueChangedDebounced" /> diff --git a/packages/editor-ui/src/components/ParameterInputList.vue b/packages/editor-ui/src/components/ParameterInputList.vue index b03745ec7aff2..500f9aa563efd 100644 --- a/packages/editor-ui/src/components/ParameterInputList.vue +++ b/packages/editor-ui/src/components/ParameterInputList.vue @@ -175,8 +175,7 @@ function updateFormTriggerParameters(parameters: INodeProperties[], triggerName: for (const parameter of parameters) { if (parameter.name === 'responseMode') { triggerParameters.push({ - displayName: - "This node is automatically set to use the wired n8n Form node. It's not possible to select other 'Respond When' options.", + displayName: 'On submission, the user will be taken to the next form node', name: 'formResponseModeNotice', type: 'notice', default: '', diff --git a/packages/editor-ui/src/composables/usePushConnection.ts b/packages/editor-ui/src/composables/usePushConnection.ts index 347fc6d0bb414..14707d11d5698 100644 --- a/packages/editor-ui/src/composables/usePushConnection.ts +++ b/packages/editor-ui/src/composables/usePushConnection.ts @@ -37,6 +37,7 @@ import { useTelemetry } from '@/composables/useTelemetry'; import type { PushMessageQueueItem } from '@/types'; import { useAssistantStore } from '@/stores/assistant.store'; import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue'; +import { useRootStore } from '@/stores/root.store'; type IPushDataExecutionFinishedPayload = PushPayload<'executionFinished'>; @@ -320,7 +321,7 @@ export function usePushConnection({ router }: { router: ReturnTypeView the execution to see what happened after this node.`; + action = `View the execution to see what happened after this node.`; } // Workflow did start but had been put to wait diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 292339a4d8032..30f5eb7403c1b 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1089,7 +1089,7 @@ "nodeCreator.triggerHelperPanel.webhookTriggerDisplayName": "On webhook call", "nodeCreator.triggerHelperPanel.webhookTriggerDescription": "Runs the flow on receiving an HTTP request", "nodeCreator.triggerHelperPanel.formTriggerDisplayName": "On form submission", - "nodeCreator.triggerHelperPanel.formTriggerDescription": "Runs the flow when an n8n generated webform is submitted", + "nodeCreator.triggerHelperPanel.formTriggerDescription": "Generate webforms in n8n and pass their responses to the workflow", "nodeCreator.triggerHelperPanel.formDisplayName": "Form", "nodeCreator.triggerHelperPanel.formDescription": "Add next form page", "nodeCreator.triggerHelperPanel.manualTriggerDisplayName": "Trigger manually", diff --git a/packages/editor-ui/src/stores/root.store.ts b/packages/editor-ui/src/stores/root.store.ts index 339b40dd418eb..27fce811f50bb 100644 --- a/packages/editor-ui/src/stores/root.store.ts +++ b/packages/editor-ui/src/stores/root.store.ts @@ -44,7 +44,9 @@ export const useRootStore = defineStore(STORES.ROOT, () => { const formTestUrl = computed(() => `${state.value.urlBaseEditor}${state.value.endpointFormTest}`); - const formWaitingUrl = computed(() => `${state.value.baseUrl}${state.value.endpointFormWaiting}`); + const formWaitingUrl = computed( + () => `${state.value.urlBaseEditor}${state.value.endpointFormWaiting}`, + ); const webhookUrl = computed(() => `${state.value.urlBaseWebhook}${state.value.endpointWebhook}`); diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 71676a40accbe..8e38243090d16 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -28,11 +28,21 @@ const pageProperties = updateDisplayOptions( }, [ { - displayName: 'Use JSON', - name: 'useJson', - type: 'boolean', - default: false, - description: 'Whether to use JSON as input to specify the form fields', + displayName: 'Define Form', + name: 'defineForm', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Using Fields Below', + value: 'fields', + }, + { + name: 'Using JSON', + value: 'json', + }, + ], + default: 'fields', }, { displayName: 'Form Fields', @@ -45,15 +55,14 @@ const pageProperties = updateDisplayOptions( '[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]', validateType: 'form-fields', ignoreValidationDuringExecution: true, - //TODO: replace with link https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/ - hint: 'Syntax for fields described in the docs(hint: define fields in fixed mode to saw validation errors)', + hint: 'See docs for field syntax', displayOptions: { show: { - useJson: [true], + defineForm: ['json'], }, }, }, - { ...formFields, displayOptions: { show: { useJson: [false] } } }, + { ...formFields, displayOptions: { show: { defineForm: ['fields'] } } }, { displayName: 'Options', name: 'options', @@ -67,7 +76,7 @@ const pageProperties = updateDisplayOptions( displayName: 'Button Label', name: 'buttonLabel', type: 'string', - default: 'Submit form', + default: 'Submit', }, ], }, @@ -82,7 +91,7 @@ const completionProperties = updateDisplayOptions( }, [ { - displayName: 'Respond With', + displayName: 'On N8n Form Submission', name: 'respondWith', type: 'options', default: 'text', @@ -163,7 +172,7 @@ export class Form extends Node { icon: 'file:form.svg', group: ['input'], version: 1, - description: 'Create a multi-step webform by adding pages to a n8n form', + description: 'Generate webforms in n8n and pass their responses to the workflow', defaults: { name: 'Form', }, @@ -213,11 +222,11 @@ export class Form extends Node { noDataExpression: true, options: [ { - name: 'Form Page', + name: 'Next Form Page', value: 'page', }, { - name: 'Form Completion', + name: 'Form Ending', value: 'completion', }, ], @@ -241,10 +250,10 @@ export class Form extends Node { | 'test' | 'production'; - const useJson = context.getNodeParameter('useJson', false) as boolean; + const defineForm = context.getNodeParameter('defineForm', false) as string; let fields: FormFieldsParameter = []; - if (useJson) { + if (defineForm === 'json') { try { const jsonOutput = context.getNodeParameter('jsonOutput', '', { rawExpressions: true, @@ -327,7 +336,7 @@ export class Form extends Node { buttonLabel = (context.evaluateExpression( `{{ $('${trigger?.name}').params.options?.buttonLabel }}`, - ) as string) || 'Submit form'; + ) as string) || 'Submit'; } const responseMode = 'onReceived'; diff --git a/packages/nodes-base/nodes/Form/FormTrigger.node.ts b/packages/nodes-base/nodes/Form/FormTrigger.node.ts index ca2c85dcc1883..9e86e4407adb7 100644 --- a/packages/nodes-base/nodes/Form/FormTrigger.node.ts +++ b/packages/nodes-base/nodes/Form/FormTrigger.node.ts @@ -10,7 +10,7 @@ export class FormTrigger extends VersionedNodeType { name: 'formTrigger', icon: 'file:form.svg', group: ['trigger'], - description: 'Runs the flow when an n8n generated webform is submitted', + description: 'Generate webforms in n8n and pass their responses to the workflow', defaultVersion: 2.1, }; diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index e41aa3885c308..5532889bf8ec3 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -54,7 +54,7 @@ describe('FormTrigger, formWebhook', () => { expect(mockRender).toHaveBeenCalledWith('form-trigger', { appendAttribution: true, - buttonLabel: 'Submit form', + buttonLabel: 'Submit', formDescription: 'Test Description', formFields: [ { @@ -197,7 +197,7 @@ describe('FormTrigger, prepareFormData', () => { query, instanceId: 'test-instance', useResponseData: true, - buttonLabel: 'Submit form', + buttonLabel: 'Submit', }); expect(result).toEqual({ @@ -253,7 +253,7 @@ describe('FormTrigger, prepareFormData', () => { ], useResponseData: true, appendAttribution: true, - buttonLabel: 'Submit form', + buttonLabel: 'Submit', redirectUrl: 'https://example.com/thank-you', }); }); @@ -276,7 +276,7 @@ describe('FormTrigger, prepareFormData', () => { formFields, testRun: true, query: {}, - buttonLabel: 'Submit form', + buttonLabel: 'Submit', }); expect(result).toEqual({ @@ -300,7 +300,7 @@ describe('FormTrigger, prepareFormData', () => { ], useResponseData: undefined, appendAttribution: true, - buttonLabel: 'Submit form', + buttonLabel: 'Submit', }); }); diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index c6313414c774e..48203616e3186 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -394,7 +394,7 @@ export async function formWebhook( appendAttribution = false; } - let buttonLabel = 'Submit form'; + let buttonLabel = 'Submit'; if (options.buttonLabel) { buttonLabel = options.buttonLabel; diff --git a/packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts b/packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts index f68e0ac86d36b..d5755ed2ae105 100644 --- a/packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts +++ b/packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts @@ -23,7 +23,7 @@ const descriptionV1: INodeTypeDescription = { icon: 'file:form.svg', group: ['trigger'], version: 1, - description: 'Runs the flow when an n8n generated webform is submitted', + description: 'Generate webforms in n8n and pass their responses to the workflow', defaults: { name: 'n8n Form Trigger', }, diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index dc1f7e125f6ef..dfdb82d280a7c 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -35,9 +35,9 @@ const descriptionV2: INodeTypeDescription = { icon: 'file:form.svg', group: ['trigger'], version: [2, 2.1], - description: 'Runs the flow when an n8n generated webform is submitted', + description: 'Generate webforms in n8n and pass their responses to the workflow', defaults: { - name: 'n8n Form Trigger', + name: 'On form submission', }, inputs: [], @@ -130,7 +130,7 @@ const descriptionV2: INodeTypeDescription = { description: 'The label of the submit button in the form', name: 'buttonLabel', type: 'string', - default: 'Submit form', + default: 'Submit', }, { ...respondWithOptions, From de5098934da9c97a11b429b5e125070e47b608f2 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 19 Sep 2024 15:29:34 +0300 Subject: [PATCH 39/56] text update --- packages/cli/templates/form-trigger.handlebars | 2 +- packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 581416b98c231..3bca1e236fe34 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -315,7 +315,7 @@
{{#if testRun}}
-

This is test version of your form. Use it only for testing your Form Trigger.

+

This is test version of your form


{{/if}} diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index dfdb82d280a7c..133b9aa4919b4 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -112,7 +112,7 @@ const descriptionV2: INodeTypeDescription = { }, // notice would be shown if no Form node was connected to trigger { - displayName: 'Add an n8n Form Page to the workflow to build a multi-step form', + displayName: 'Build multi-step forms by adding a form page later in your workflow', name: ADD_FORM_NOTICE, type: 'notice', default: '', From c291f12113e0bb8d03380ff381edf9c72b9c6e91 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 3 Oct 2024 10:42:43 +0300 Subject: [PATCH 40/56] tests update --- .../nodes/Form/test/FormTriggerV2.node.test.ts | 12 ++++++------ packages/nodes-base/nodes/Form/test/utils.test.ts | 3 +-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts index fba596c87ed14..d3c96783c5ae2 100644 --- a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts +++ b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts @@ -4,7 +4,6 @@ import { NodeOperationError, type INode } from 'n8n-workflow'; import { testVersionedWebhookTriggerNode } from '@test/nodes/TriggerHelpers'; import { FormTrigger } from '../FormTrigger.node'; -import type { FormField } from '../interfaces'; describe('FormTrigger', () => { beforeEach(() => { @@ -12,7 +11,7 @@ describe('FormTrigger', () => { }); it('should render a form template with correct fields', async () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, { fieldLabel: 'Notes', fieldType: 'textarea', requiredField: false }, @@ -49,6 +48,7 @@ describe('FormTrigger', () => { expect(response.render).toHaveBeenCalledWith('form-trigger', { appendAttribution: false, + buttonLabel: 'Submit', formDescription: 'Test Description', formFields: [ { @@ -115,7 +115,7 @@ describe('FormTrigger', () => { }); it('should return workflowData on POST request', async () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, { fieldLabel: 'Date', fieldType: 'date', formatDate: 'dd MMM', requiredField: false }, @@ -205,13 +205,13 @@ describe('FormTrigger', () => { ], }), ).rejects.toEqual( - new NodeOperationError(mock(), 'n8n Form Trigger node not correctly configured'), + new NodeOperationError(mock(), 'On form submission node not correctly configured'), ); }); }); it('should throw on invalid webhook authentication', async () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, ]; @@ -239,7 +239,7 @@ describe('FormTrigger', () => { }); it('should handle files', async () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Resume', fieldType: 'file', diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index b01af43d164cd..3cd71b9a926d7 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -7,7 +7,6 @@ import type { } from 'n8n-workflow'; import { DateTime } from 'luxon'; import { formWebhook, prepareFormData, prepareFormReturnItem, resolveRawData } from '../utils'; -import type { FormField } from '../interfaces'; describe('FormTrigger, formWebhook', () => { beforeEach(() => { @@ -376,7 +375,7 @@ describe('FormTrigger, prepareFormData', () => { ]); }); it('should correctly handle multiselect fields with unique ids', () => { - const formFields: FormField[] = [ + const formFields = [ { fieldLabel: 'Favorite Colors', fieldType: 'text', From 20a9b4eec47409178a8453d942d3309ba81ffe2c Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 3 Oct 2024 10:52:14 +0300 Subject: [PATCH 41/56] lint fix --- packages/cli/src/webhooks/waiting-forms.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index cc112c0abbcc3..dd540d4ed88ef 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -1,15 +1,14 @@ -import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; - +import axios from 'axios'; +import type express from 'express'; import { FORM_NODE_TYPE, sleep, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; -import type express from 'express'; - -import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from './webhook.types'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { IExecutionResponse } from '@/interfaces'; -import axios from 'axios'; +import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; + +import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from './webhook.types'; @Service() export class WaitingForms extends WaitingWebhooks { From ff792abf243487d5776e4df526c42101844bd5a9 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Fri, 4 Oct 2024 10:53:39 +0300 Subject: [PATCH 42/56] resolve waiting resolution merge update --- .../src/composables/useRunWorkflow.ts | 28 +++++++++++-------- .../editor-ui/src/stores/workflows.store.ts | 6 +++- packages/nodes-base/nodes/Form/Form.node.ts | 8 ------ 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index 4d06d5b572bcd..86f6f7cd6a632 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -17,7 +17,7 @@ import type { IDataObject, } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { FORM_NODE_TYPE, NodeConnectionType } from 'n8n-workflow'; import { useToast } from '@/composables/useToast'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; @@ -373,11 +373,23 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { + return node.name === lastNodeExecuted; + }); + + if (!isFormShown && lastNode && lastNode.type === FORM_NODE_TYPE) { + const testUrl = `${rootStore.formWaitingUrl}/${executionId}`; + isFormShown = true; + openPopUpWindow(testUrl); + } + if ( execution.finished || ['error', 'canceled', 'crashed', 'success'].includes(execution.status) ) { workflowsStore.setWorkflowExecutionData(execution); + workflowsStore.activeExecutionId = null; if (timeoutId) clearTimeout(timeoutId); resolve(); return; @@ -389,18 +401,12 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { - return node.name === lastNodeExecuted; - }); - if ( - waitingNode && - waitingNode.type === WAIT_NODE_TYPE && - waitingNode.parameters.resume === 'form' + lastNode && + lastNode.type === WAIT_NODE_TYPE && + lastNode.parameters.resume === 'form' ) { - const testUrl = getFormResumeUrl(waitingNode, executionId as string); + const testUrl = getFormResumeUrl(lastNode, executionId as string); if (isFormShown) { localStorage.setItem(FORM_RELOAD, testUrl); diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 9d4da95f61874..78eceb77a22e9 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -3,6 +3,7 @@ import { DEFAULT_NEW_WORKFLOW_NAME, DUPLICATE_POSTFFIX, ERROR_TRIGGER_NODE_TYPE, + FORM_NODE_TYPE, MAX_WORKFLOW_NAME_LENGTH, PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, @@ -168,7 +169,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { const allNodes = computed(() => workflow.value.nodes); const isWaitingExecution = computed(() => { - return allNodes.value.some((node) => node.type === WAIT_NODE_TYPE && node.disabled !== true); + return allNodes.value.some( + (node) => + (node.type === WAIT_NODE_TYPE || node.type === FORM_NODE_TYPE) && node.disabled !== true, + ); }); // Names of all nodes currently on canvas. diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 8e38243090d16..e679e13403af3 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -198,14 +198,6 @@ export class Form extends Node { isForm: true, }, ], - hints: [ - { - message: - "When testing your workflow using the Editor UI, you can't see the rest of the execution following the n8n Form node. To inspect the execution results, enable Save Manual Executions in your Workflow settings so you can review the execution results in the Executions tab.", - location: 'outputPane', - whenToDisplay: 'beforeExecution', - }, - ], properties: [ { // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased From 2fb4a62100f0cfa9cfbf8962a26edc5e6053bd5a Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 9 Oct 2024 11:02:22 +0300 Subject: [PATCH 43/56] prepare to merge master --- packages/cli/src/webhooks/waiting-webhooks.ts | 139 ++++++++++++------ 1 file changed, 92 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index 86f3061790c02..9d3c90c390f66 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -1,5 +1,11 @@ import type express from 'express'; -import { NodeHelpers, Workflow } from 'n8n-workflow'; +import { + type INodes, + type IWorkflowBase, + NodeHelpers, + SEND_AND_WAIT_OPERATION, + Workflow, +} from 'n8n-workflow'; import { Service } from 'typedi'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -42,6 +48,84 @@ export class WaitingWebhooks implements IWebhookManager { execution.data.executionData!.nodeExecutionStack[0].node.disabled = true; } + private isSendAndWaitRequest(nodes: INodes, suffix: string | undefined) { + return ( + suffix && + Object.keys(nodes).some( + (node) => + nodes[node].id === suffix && nodes[node].parameters.operation === SEND_AND_WAIT_OPERATION, + ) + ); + } + + private getWorkflow(workflowData: IWorkflowBase) { + return new Workflow({ + id: workflowData.id, + name: workflowData.name, + nodes: workflowData.nodes, + connections: workflowData.connections, + active: workflowData.active, + nodeTypes: this.nodeTypes, + staticData: workflowData.staticData, + settings: workflowData.settings, + }); + } + + protected async getExecution(executionId: string) { + return await this.executionRepository.findSingleExecution(executionId, { + includeData: true, + unflattenData: true, + }); + } + + async executeWebhook( + req: WaitingWebhookRequest, + res: express.Response, + ): Promise { + const { path: executionId, suffix } = req.params; + + this.logReceivedWebhook(req.method, executionId); + + // Reset request parameters + req.params = {} as WaitingWebhookRequest['params']; + + const execution = await this.getExecution(executionId); + + if (!execution) { + throw new NotFoundError(`The execution "${executionId}" does not exist.`); + } + + if (execution.status === 'running') { + throw new ConflictError(`The execution "${executionId}" is running already.`); + } + + if (execution.data?.resultData?.error) { + throw new ConflictError(`The execution "${executionId} has finished already.`); + } + + if (execution.finished) { + const { workflowData } = execution; + const { nodes } = this.getWorkflow(workflowData); + if (this.isSendAndWaitRequest(nodes, suffix)) { + res.render('send-and-wait-no-action-required', { isTestWebhook: false }); + return { noWebhookResponse: true }; + } else { + throw new ConflictError(`The execution "${executionId} has finished already.`); + } + } + + const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; + + return await this.getWebhookExecutionData({ + execution, + req, + res, + lastNodeExecuted, + executionId, + suffix, + }); + } + protected async getWebhookExecutionData({ execution, req, @@ -101,8 +185,13 @@ export class WaitingWebhooks implements IWebhookManager { if (webhookData === undefined) { // If no data got found it means that the execution can not be started via a webhook. // Return 404 because we do not want to give any data if the execution exists or not. - const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`; - throw new NotFoundError(errorMessage); + if (this.isSendAndWaitRequest(workflow.nodes, suffix)) { + res.render('send-and-wait-no-action-required', { isTestWebhook: false }); + return { noWebhookResponse: true }; + } else { + const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`; + throw new NotFoundError(errorMessage); + } } const runExecutionData = execution.data; @@ -130,48 +219,4 @@ export class WaitingWebhooks implements IWebhookManager { ); }); } - - protected async getExecution(executionId: string) { - return await this.executionRepository.findSingleExecution(executionId, { - includeData: true, - unflattenData: true, - }); - } - - async executeWebhook( - req: WaitingWebhookRequest, - res: express.Response, - ): Promise { - const { path: executionId, suffix } = req.params; - - this.logReceivedWebhook(req.method, executionId); - - // Reset request parameters - req.params = {} as WaitingWebhookRequest['params']; - - const execution = await this.getExecution(executionId); - - if (!execution) { - throw new NotFoundError(`The execution "${executionId}" does not exist.`); - } - - if (execution.status === 'running') { - throw new ConflictError(`The execution "${executionId}" is running already.`); - } - - if (execution.finished || execution.data.resultData.error) { - throw new ConflictError(`The execution "${executionId}" has finished already.`); - } - - const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; - - return await this.getWebhookExecutionData({ - execution, - req, - res, - lastNodeExecuted, - executionId, - suffix, - }); - } } From 0ab06a680150c89503cecf7f02b91ee731c87a9a Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 9 Oct 2024 15:53:55 +0300 Subject: [PATCH 44/56] page form popup fix, warnings update --- packages/cli/src/webhooks/waiting-webhooks.ts | 6 +++--- packages/editor-ui/src/components/Node.vue | 4 ++++ .../src/composables/useCanvasMapping.ts | 6 ++++++ .../src/composables/useRunWorkflow.ts | 20 ++++++++++++++++--- .../editor-ui/src/utils/executionUtils.ts | 8 +++++++- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index fdc6ce077b906..6941a484fb575 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -58,7 +58,7 @@ export class WaitingWebhooks implements IWebhookManager { ); } - private getWorkflow(workflowData: IWorkflowBase) { + private createWorkflow(workflowData: IWorkflowBase) { return new Workflow({ id: workflowData.id, name: workflowData.name, @@ -105,7 +105,7 @@ export class WaitingWebhooks implements IWebhookManager { if (execution.finished) { const { workflowData } = execution; - const { nodes } = this.getWorkflow(workflowData); + const { nodes } = this.createWorkflow(workflowData); if (this.isSendAndWaitRequest(nodes, suffix)) { res.render('send-and-wait-no-action-required', { isTestWebhook: false }); return { noWebhookResponse: true }; @@ -152,7 +152,7 @@ export class WaitingWebhooks implements IWebhookManager { execution.data.resultData.runData[lastNodeExecuted].pop(); const { workflowData } = execution; - const workflow = this.getWorkflow(workflowData); + const workflow = this.createWorkflow(workflowData); const workflowStartNode = workflow.getNode(lastNodeExecuted); if (workflowStartNode === null) { diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 6fcc93be76891..f84eac56df0d2 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -2,6 +2,7 @@ import { useStorage } from '@/composables/useStorage'; import { CUSTOM_API_CALL_KEY, + FORM_NODE_TYPE, LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, MANUAL_TRIGGER_NODE_TYPE, NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS, @@ -339,6 +340,9 @@ const waiting = computed(() => { if (node?.parameters.operation === SEND_AND_WAIT_OPERATION) { return i18n.baseText('node.theNodeIsWaitingUserInput'); } + if (node?.type === FORM_NODE_TYPE) { + return i18n.baseText('node.theNodeIsWaitingFormCall'); + } const waitDate = new Date(workflowExecution.waitTill); if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall'); diff --git a/packages/editor-ui/src/composables/useCanvasMapping.ts b/packages/editor-ui/src/composables/useCanvasMapping.ts index fe33c1854cc27..587984fa35bc6 100644 --- a/packages/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/editor-ui/src/composables/useCanvasMapping.ts @@ -40,6 +40,7 @@ import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-wo import type { INodeUi } from '@/Interface'; import { CUSTOM_API_CALL_KEY, + FORM_NODE_TYPE, STICKY_NODE_TYPE, WAIT_NODE_TYPE, WAIT_TIME_UNLIMITED, @@ -353,6 +354,11 @@ export function useCanvasMapping({ return acc; } + if (node?.type === FORM_NODE_TYPE) { + acc[node.id] = i18n.baseText('node.theNodeIsWaitingFormCall'); + return acc; + } + const waitDate = new Date(workflowExecution.waitTill); if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index 86f6f7cd6a632..d6691183e15e3 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -379,9 +379,23 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { + return node.type === FORM_TRIGGER_NODE_TYPE; + }); + const runNodeFilter = execution?.data?.startData?.runNodeFilter || []; + if (formTrigger && !runNodeFilter.includes(formTrigger.name)) { + isFormShown = true; + } + } + if (!isFormShown) { + const testUrl = `${rootStore.formWaitingUrl}/${executionId}`; + isFormShown = true; + openPopUpWindow(testUrl); + } } if ( diff --git a/packages/editor-ui/src/utils/executionUtils.ts b/packages/editor-ui/src/utils/executionUtils.ts index d0f8916c8ffae..e076c8c18efe7 100644 --- a/packages/editor-ui/src/utils/executionUtils.ts +++ b/packages/editor-ui/src/utils/executionUtils.ts @@ -8,7 +8,7 @@ import { } from 'n8n-workflow'; import type { ExecutionFilterType, ExecutionsQueryFilter } from '@/Interface'; import { isEmpty } from '@/utils/typesUtils'; -import { FORM_TRIGGER_NODE_TYPE } from '../constants'; +import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE } from '../constants'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useRootStore } from '@/stores/root.store'; import { i18n } from '@/plugins/i18n'; @@ -168,6 +168,12 @@ export const waitingNodeTooltip = () => { } } + if (lastNode?.type === FORM_NODE_TYPE) { + const message = i18n.baseText('ndv.output.waitNodeWaitingForFormSubmission'); + const resumeUrl = `${useRootStore().formWaitingUrl}/${useWorkflowsStore().activeExecutionId}`; + return `${message}${resumeUrl}`; + } + if (lastNode?.parameters.operation === SEND_AND_WAIT_OPERATION) { return i18n.baseText('ndv.output.sendAndWaitWaitingApproval'); } From b6a1721486b51e127eb7cdcd104bdb3181c51c94 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 10 Oct 2024 10:58:31 +0300 Subject: [PATCH 45/56] form duplication fix, path moved to options, review fixes --- .../src/composables/useCanvasOperations.ts | 1 + .../src/composables/useRunWorkflow.ts | 6 ++++- packages/editor-ui/src/views/NodeView.vue | 1 + .../nodes-base/nodes/Form/FormTrigger.node.ts | 3 ++- .../nodes/Form/v2/FormTriggerV2.node.ts | 25 +++++++++++++++---- packages/workflow/src/WorkflowDataProxy.ts | 1 + 6 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts index 3cae57885b8b6..f8a27b09fd3f9 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.ts @@ -1687,6 +1687,7 @@ export function useCanvasOperations({ router }: { router: ReturnType { return (node: INode) => { - return `${rootStore.formTestUrl}/${node.parameters.path}`; + const path = + node.parameters.path || + (node.parameters.options as IDataObject)?.path || + node.webhookId; + return `${rootStore.formTestUrl}/${path as string}`; }; })(); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index b932d9842a049..83228fb465a09 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -1928,6 +1928,7 @@ export default defineComponent({ ).some((n) => n.webhookId === node.webhookId); if (isDuplicate) { node.webhookId = uuid(); + node.parameters.path = node.webhookId as string; } } diff --git a/packages/nodes-base/nodes/Form/FormTrigger.node.ts b/packages/nodes-base/nodes/Form/FormTrigger.node.ts index 9e86e4407adb7..a486d1b72749a 100644 --- a/packages/nodes-base/nodes/Form/FormTrigger.node.ts +++ b/packages/nodes-base/nodes/Form/FormTrigger.node.ts @@ -11,13 +11,14 @@ export class FormTrigger extends VersionedNodeType { icon: 'file:form.svg', group: ['trigger'], description: 'Generate webforms in n8n and pass their responses to the workflow', - defaultVersion: 2.1, + defaultVersion: 2.2, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { 1: new FormTriggerV1(baseDescription), 2: new FormTriggerV2(baseDescription), 2.1: new FormTriggerV2(baseDescription), + 2.2: new FormTriggerV2(baseDescription), }; super(nodeVersions, baseDescription); diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index 133b9aa4919b4..4e2ebd94a30e8 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -1,5 +1,6 @@ import { ADD_FORM_NOTICE, + type INodePropertyOptions, NodeConnectionType, type INodeProperties, type INodeType, @@ -34,7 +35,7 @@ const descriptionV2: INodeTypeDescription = { name: 'formTrigger', icon: 'file:form.svg', group: ['trigger'], - version: [2, 2.1], + version: [2, 2.1, 2.2], description: 'Generate webforms in n8n and pass their responses to the workflow', defaults: { name: 'On form submission', @@ -48,7 +49,7 @@ const descriptionV2: INodeTypeDescription = { httpMethod: 'GET', responseMode: 'onReceived', isFullPath: true, - path: '={{$parameter["path"]}}', + path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}', ndvHideUrl: true, isForm: true, }, @@ -58,7 +59,7 @@ const descriptionV2: INodeTypeDescription = { responseMode: '={{$parameter["responseMode"]}}', responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}', isFullPath: true, - path: '={{$parameter["path"]}}', + path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}', ndvHideMethod: true, isForm: true, }, @@ -95,11 +96,18 @@ const descriptionV2: INodeTypeDescription = { ], default: 'none', }, - webhookPath, + { ...webhookPath, displayOptions: { show: { '@version': [{ _cnd: { lte: 2.1 } }] } } }, formTitle, formDescription, formFields, - formRespondMode, + { ...formRespondMode, displayOptions: { show: { '@version': [{ _cnd: { lte: 2.1 } }] } } }, + { + ...formRespondMode, + options: (formRespondMode.options as INodePropertyOptions[])?.filter( + (option) => option.value !== 'responseNode', + ), + displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } }, + }, { displayName: "In the 'Respond to Webhook' node, select 'Respond With JSON' and set the formSubmittedText key to display a custom response in the form, or the redirectURL key to redirect users to a URL", @@ -147,9 +155,15 @@ const descriptionV2: INodeTypeDescription = { default: false, description: 'Whether to ignore requests from bots like link previewers and web crawlers', }, + { + ...webhookPath, + required: false, + displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } }, + }, { ...useWorkflowTimezone, default: false, + description: "Whether to use the workflow timezone in 'submittedAt' field or UTC", displayOptions: { show: { '@version': [2], @@ -159,6 +173,7 @@ const descriptionV2: INodeTypeDescription = { { ...useWorkflowTimezone, default: true, + description: "Whether to use the workflow timezone in 'submittedAt' field or UTC", displayOptions: { show: { '@version': [{ _cnd: { gt: 2 } }], diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 1efede36a560a..5fc6a8a8a1e2c 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -1392,6 +1392,7 @@ export class WorkflowDataProxy { $thisRunIndex: this.runIndex, $nodeVersion: that.workflow.getNode(that.activeNodeName)?.typeVersion, $nodeId: that.workflow.getNode(that.activeNodeName)?.id, + $webhookId: that.workflow.getNode(that.activeNodeName)?.webhookId, }; return new Proxy(base, { From 8f3fea1824278d0748478b3041e823572dacf605 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 10 Oct 2024 12:26:46 +0300 Subject: [PATCH 46/56] update path in options --- packages/editor-ui/src/composables/useCanvasOperations.ts | 8 +++++++- packages/editor-ui/src/views/NodeView.vue | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts index f8a27b09fd3f9..425ebe42e5d63 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.ts @@ -76,6 +76,7 @@ import type { Connection } from '@vue-flow/core'; import type { IConnection, IConnections, + IDataObject, INode, INodeConnections, INodeCredentials, @@ -1687,7 +1688,12 @@ export function useCanvasOperations({ router }: { router: ReturnType n.webhookId === node.webhookId); if (isDuplicate) { node.webhookId = uuid(); - node.parameters.path = node.webhookId as string; + + if (node.parameters.path) { + node.parameters.path = node.webhookId as string; + } else if ((node.parameters.options as IDataObject).path) { + (node.parameters.options as IDataObject).path = node.webhookId as string; + } } } From 7c37fa0669d2f3b7ee20c765ef94edf071e38c3f Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 10 Oct 2024 13:07:22 +0300 Subject: [PATCH 47/56] reorder options in form trigger --- .../nodes-base/nodes/Form/v2/FormTriggerV2.node.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index 4e2ebd94a30e8..7e09170d6a04c 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -140,6 +140,11 @@ const descriptionV2: INodeTypeDescription = { type: 'string', default: 'Submit', }, + { + ...webhookPath, + required: false, + displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } }, + }, { ...respondWithOptions, displayOptions: { @@ -155,11 +160,6 @@ const descriptionV2: INodeTypeDescription = { default: false, description: 'Whether to ignore requests from bots like link previewers and web crawlers', }, - { - ...webhookPath, - required: false, - displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } }, - }, { ...useWorkflowTimezone, default: false, From 8bb19433cce86bab24399de8d0566ae123fd958a Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 10 Oct 2024 17:02:47 +0300 Subject: [PATCH 48/56] redirection form fix --- packages/cli/src/webhooks/waiting-webhooks.ts | 19 ++++++- .../src/composables/useRunWorkflow.ts | 49 ++++++++++--------- packages/nodes-base/nodes/Form/Form.node.ts | 5 +- packages/nodes-base/nodes/Form/utils.ts | 12 +++++ 4 files changed, 59 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index 6941a484fb575..a14563eea2e58 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -1,9 +1,11 @@ import type express from 'express'; import { + FORM_NODE_TYPE, type INodes, type IWorkflowBase, NodeHelpers, SEND_AND_WAIT_OPERATION, + WAIT_NODE_TYPE, Workflow, } from 'n8n-workflow'; import { Service } from 'typedi'; @@ -175,11 +177,26 @@ export class WaitingWebhooks implements IWebhookManager { if (webhookData === undefined) { // If no data got found it means that the execution can not be started via a webhook. // Return 404 because we do not want to give any data if the execution exists or not. + const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`; + if (this.isSendAndWaitRequest(workflow.nodes, suffix)) { res.render('send-and-wait-no-action-required', { isTestWebhook: false }); return { noWebhookResponse: true }; + } else if (!execution.data.resultData.error && execution.status === 'waiting') { + const childNodes = workflow.getChildNodes( + execution.data.resultData.lastNodeExecuted as string, + ); + const hasChildForms = childNodes.some( + (node) => + workflow.nodes[node].type === FORM_NODE_TYPE || + workflow.nodes[node].type === WAIT_NODE_TYPE, + ); + if (hasChildForms) { + return { noWebhookResponse: true }; + } else { + throw new NotFoundError(errorMessage); + } } else { - const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`; throw new NotFoundError(errorMessage); } } diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index decc2cd21177c..c9072b3b665dd 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -382,26 +382,6 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { - return node.type === FORM_TRIGGER_NODE_TYPE; - }); - const runNodeFilter = execution?.data?.startData?.runNodeFilter || []; - if (formTrigger && !runNodeFilter.includes(formTrigger.name)) { - isFormShown = true; - } - } - if (!isFormShown) { - const testUrl = `${rootStore.formWaitingUrl}/${executionId}`; - isFormShown = true; - openPopUpWindow(testUrl); - } - } - if ( execution.finished || ['error', 'canceled', 'crashed', 'success'].includes(execution.status) @@ -421,16 +401,37 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { + return node.type === FORM_TRIGGER_NODE_TYPE; + }); + const runNodeFilter = execution?.data?.startData?.runNodeFilter || []; + if (formTrigger && !runNodeFilter.includes(formTrigger.name)) { + isFormShown = true; + } + } + if (!isFormShown) { + let testUrl = ''; + if (lastNode.type === FORM_NODE_TYPE) { + testUrl = `${rootStore.formWaitingUrl}/${executionId}`; + } else { + testUrl = getFormResumeUrl(lastNode, executionId as string); + } + + isFormShown = true; + if (testUrl) openPopUpWindow(testUrl); + } } } } diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index e679e13403af3..4335825a89cc3 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -15,6 +15,7 @@ import { FORM_TRIGGER_NODE_TYPE, tryToParseFormFields, NodeConnectionType, + WAIT_NODE_TYPE, } from 'n8n-workflow'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; @@ -337,7 +338,9 @@ export class Form extends Node { const connectedNodes = context.getChildNodes(context.getNode().name); - const hasNextPage = connectedNodes.some((node) => node.type === FORM_NODE_TYPE); + const hasNextPage = connectedNodes.some( + (node) => node.type === FORM_NODE_TYPE || node.type === WAIT_NODE_TYPE, + ); if (hasNextPage) { redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string; diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 56569a1207613..2809024e2b6d1 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -10,6 +10,7 @@ import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, NodeOperationError, + WAIT_NODE_TYPE, jsonParse, } from 'n8n-workflow'; @@ -400,6 +401,17 @@ export async function formWebhook( buttonLabel = options.buttonLabel; } + if (!redirectUrl && node.type !== FORM_TRIGGER_NODE_TYPE) { + const connectedNodes = context.getChildNodes(context.getNode().name); + const hasNextPage = connectedNodes.some( + (n) => n.type === FORM_NODE_TYPE || n.type === WAIT_NODE_TYPE, + ); + + if (hasNextPage) { + redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string; + } + } + renderForm({ context, res, From 160f05552b62c04b6210c14d52718994315e0603 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Fri, 11 Oct 2024 10:54:32 +0300 Subject: [PATCH 49/56] reload on sendAndWait, remove suffix and responseWith options from wait node when form page connected --- .../cli/templates/form-trigger.handlebars | 9 +++ .../src/components/ParameterInputList.vue | 60 ++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 55b3fa46c7f2c..39627c0204f29 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -751,6 +751,15 @@ .catch(function (error) { console.error('Error:', error); }); + + const interval = setInterval(function() { + const isSubmited = document.querySelector('#submitted-form').style.display; + if(isSubmited === 'block') { + clearInterval(interval); + return; + } + window.location.reload(); + }, 2000); } }); diff --git a/packages/editor-ui/src/components/ParameterInputList.vue b/packages/editor-ui/src/components/ParameterInputList.vue index d406aa5350181..78932fb9b00c9 100644 --- a/packages/editor-ui/src/components/ParameterInputList.vue +++ b/packages/editor-ui/src/components/ParameterInputList.vue @@ -19,7 +19,12 @@ import ParameterInputFull from '@/components/ParameterInputFull.vue'; import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; -import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants'; +import { + FORM_NODE_TYPE, + FORM_TRIGGER_NODE_TYPE, + KEEP_AUTH_IN_NDV_FOR_NODES, + WAIT_NODE_TYPE, +} from '@/constants'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { @@ -100,6 +105,9 @@ const filteredParameters = computed(() => { if (activeNode && activeNode.type === FORM_TRIGGER_NODE_TYPE) { return updateFormTriggerParameters(parameters, activeNode.name); } + if (activeNode && activeNode.type === WAIT_NODE_TYPE && activeNode.parameters.resume === 'form') { + return updateWaitParameters(parameters, activeNode.name); + } return parameters; }); @@ -187,6 +195,17 @@ function updateFormTriggerParameters(parameters: INodeProperties[], triggerName: if (parameter.name === ADD_FORM_NOTICE) continue; + if (parameter.name === 'options') { + const options = (parameter.options as INodeProperties[]).filter( + (option) => option.name !== 'respondWithOptions', + ); + triggerParameters.push({ + ...parameter, + options, + }); + continue; + } + triggerParameters.push(parameter); } return triggerParameters; @@ -195,6 +214,45 @@ function updateFormTriggerParameters(parameters: INodeProperties[], triggerName: return parameters; } +function updateWaitParameters(parameters: INodeProperties[], nodeName: string) { + const workflow = workflowHelpers.getCurrentWorkflow(); + const parentNodes = workflow.getParentNodes(nodeName); + + const formTriggerName = parentNodes.find( + (node) => workflow.nodes[node].type === FORM_TRIGGER_NODE_TYPE, + ); + if (!formTriggerName) return parameters; + + const connectedNodes = workflow.getChildNodes(formTriggerName); + + const hasFormPage = connectedNodes.some((nodeName) => { + const node = workflow.getNode(nodeName); + return node && node.type === FORM_NODE_TYPE; + }); + + if (hasFormPage) { + const waitNodeParameters: INodeProperties[] = []; + + for (const parameter of parameters) { + if (parameter.name === 'options') { + const options = (parameter.options as INodeProperties[]).filter( + (option) => option.name !== 'respondWithOptions' && option.name !== 'webhookSuffix', + ); + waitNodeParameters.push({ + ...parameter, + options, + }); + continue; + } + + waitNodeParameters.push(parameter); + } + return waitNodeParameters; + } + + return parameters; +} + function onParameterBlur(parameterName: string) { emit('parameterBlur', parameterName); } From 2794bc0db5d5ea76bac42b8e109e89360856d2e9 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Fri, 11 Oct 2024 11:04:06 +0300 Subject: [PATCH 50/56] rmoved unused import --- packages/editor-ui/src/composables/usePushConnection.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/editor-ui/src/composables/usePushConnection.ts b/packages/editor-ui/src/composables/usePushConnection.ts index f6d9ed84857a2..6e1d993c0e0e0 100644 --- a/packages/editor-ui/src/composables/usePushConnection.ts +++ b/packages/editor-ui/src/composables/usePushConnection.ts @@ -36,7 +36,6 @@ import { useTelemetry } from '@/composables/useTelemetry'; import type { PushMessageQueueItem } from '@/types'; import { useAssistantStore } from '@/stores/assistant.store'; import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue'; -import { useRootStore } from '@/stores/root.store'; type IPushDataExecutionFinishedPayload = PushPayload<'executionFinished'>; From 491582170b7d3f61e48f01d16295edcc727d49b7 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:30:13 +0300 Subject: [PATCH 51/56] Update packages/cli/src/webhooks/waiting-forms.ts Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> --- packages/cli/src/webhooks/waiting-forms.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index dd540d4ed88ef..3464ff5b0c280 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -75,14 +75,11 @@ export class WaitingForms extends WaitingWebhooks { `); - return { - noWebhookResponse: true, - }; - } else { - return { - noWebhookResponse: true, - }; - } + } + return { + noWebhookResponse: true, + }; + } throw new ConflictError(`The execution "${executionId}" is running already.`); } From bb781889bae224721a562582a7dedf0079c6cb67 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:30:23 +0300 Subject: [PATCH 52/56] Update packages/cli/src/webhooks/webhook-helpers.ts Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> --- packages/cli/src/webhooks/webhook-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 1e18afb0cc614..027c965e4e5fb 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -273,7 +273,7 @@ export async function executeWebhook( : 'Webhook'; let errorMessage = `Workflow ${webhookType} Error: Workflow could not be started!`; - // if workflow started manually, show and actual error message + // if workflow started manually, show an actual error message if (err instanceof NodeOperationError && err.type === 'manual-form-test') { errorMessage = err.message; } From 3fd09974d35f77671e60f0fa238aa3d19e932ba2 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:34:10 +0300 Subject: [PATCH 53/56] Update packages/nodes-base/nodes/Form/Form.node.ts Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> --- packages/nodes-base/nodes/Form/Form.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 4335825a89cc3..2e8077b0d3f31 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -92,7 +92,7 @@ const completionProperties = updateDisplayOptions( }, [ { - displayName: 'On N8n Form Submission', + displayName: 'On n8n Form Submission', name: 'respondWith', type: 'options', default: 'text', From 26e2b0cdba290c4db6bb385195d70f86c671a92e Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Wed, 16 Oct 2024 11:49:51 +0300 Subject: [PATCH 54/56] review update --- packages/cli/src/webhooks/waiting-forms.ts | 9 ++++----- packages/editor-ui/src/composables/useRunWorkflow.ts | 3 +-- packages/nodes-base/nodes/Form/Form.node.ts | 5 +++-- packages/workflow/src/TypeValidation.ts | 4 ++-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index 3464ff5b0c280..72963f21dadf9 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -74,12 +74,11 @@ export class WaitingForms extends WaitingWebhooks { }, 1); `); + } - } - return { - noWebhookResponse: true, - }; - + return { + noWebhookResponse: true, + }; } throw new ConflictError(`The execution "${executionId}" is running already.`); } diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index c9072b3b665dd..32d2b86553c1f 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -404,7 +404,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { +export const tryToParseJsonToFormFields = (value: unknown): FormFieldsParameter => { const fields: FormFieldsParameter = []; try { @@ -422,7 +422,7 @@ export function validateFieldType( } case 'form-fields': { try { - return { valid: true, newValue: tryToParseFormFields(value) }; + return { valid: true, newValue: tryToParseJsonToFormFields(value) }; } catch (e) { return { valid: false, From 756f5d6177e2668d85ee3f13b31a52c6549225b1 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 17 Oct 2024 11:53:57 +0300 Subject: [PATCH 55/56] test fix --- .../editor-ui/src/composables/__tests__/useRunWorkflow.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor-ui/src/composables/__tests__/useRunWorkflow.spec.ts b/packages/editor-ui/src/composables/__tests__/useRunWorkflow.spec.ts index 0004354f50c2e..608fc36e870e7 100644 --- a/packages/editor-ui/src/composables/__tests__/useRunWorkflow.spec.ts +++ b/packages/editor-ui/src/composables/__tests__/useRunWorkflow.spec.ts @@ -333,6 +333,7 @@ describe('useRunWorkflow({ router })', () => { vi.mocked(workflowsStore).allNodes = []; vi.mocked(workflowsStore).getExecution.mockResolvedValue({ finished: true, + workflowData: { nodes: [] }, } as unknown as IExecutionResponse); vi.mocked(workflowsStore).workflowExecutionData = { id: '123', From 470f149f3d5a53649e5a3731a95c186143eebba5 Mon Sep 17 00:00:00 2001 From: Michael Kret Date: Thu, 17 Oct 2024 16:29:27 +0300 Subject: [PATCH 56/56] e2e test fix --- cypress/e2e/16-form-trigger-node.cy.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/16-form-trigger-node.cy.ts b/cypress/e2e/16-form-trigger-node.cy.ts index 0162479f7c559..60fbd7c419586 100644 --- a/cypress/e2e/16-form-trigger-node.cy.ts +++ b/cypress/e2e/16-form-trigger-node.cy.ts @@ -16,12 +16,14 @@ describe('n8n Form Trigger', () => { ndv.getters.parameterInput('formDescription').type('Test Form Description'); ndv.getters.parameterInput('fieldLabel').type('Test Field 1'); ndv.getters.backToCanvas().click(); - workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist'); + workflowPage.getters.nodeIssuesByName('On form submission').should('not.exist'); }); it('should fill up form fields', () => { - workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger'); - workflowPage.getters.canvasNodes().first().dblclick(); + workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger', { + isTrigger: true, + action: 'On new n8n Form event', + }); ndv.getters.parameterInput('formTitle').type('Test Form'); ndv.getters.parameterInput('formDescription').type('Test Form Description'); //fill up first field of type number @@ -96,6 +98,6 @@ describe('n8n Form Trigger', () => { .type('Your test form was successfully submitted'); ndv.getters.backToCanvas().click(); - workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist'); + workflowPage.getters.nodeIssuesByName('On form submission').should('not.exist'); }); });