diff --git a/packages/cli/templates/form-trigger-completion.handlebars b/packages/cli/templates/form-trigger-completion.handlebars index a15855d371752..880a7f91d152a 100644 --- a/packages/cli/templates/form-trigger-completion.handlebars +++ b/packages/cli/templates/form-trigger-completion.handlebars @@ -26,49 +26,53 @@ -
-
-
-
-

{{title}}

-

{{message}}

+ {{#if responseText}} + {{{responseText}}} + {{else}} +
+
+
+
+

{{title}}

+

{{message}}

+
-
- {{#if appendAttribution}} - - {{/if}} -
-
+ {{#if appendAttribution}} + + {{/if}} + + + {{/if}} diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 03f27219fe060..b9e1340f13df6 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -5,6 +5,7 @@ import type { INodeProperties, INodeTypeDescription, IWebhookFunctions, + IWebhookResponseData, NodeTypeAndVersion, } from 'n8n-workflow'; import { @@ -15,12 +16,13 @@ import { FORM_TRIGGER_NODE_TYPE, tryToParseJsonToFormFields, NodeConnectionType, - WAIT_NODE_TYPE, WAIT_INDEFINITELY, } from 'n8n-workflow'; +import { renderFormCompletion } from './formCompletionUtils'; +import { renderFormNode } from './formNodeUtils'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; -import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils'; +import { prepareFormReturnItem, resolveRawData } from '../Form/utils'; export const formFieldsProperties: INodeProperties[] = [ { @@ -113,6 +115,11 @@ const completionProperties = updateDisplayOptions( value: 'redirect', description: 'Redirect the user to a URL', }, + { + name: 'Show Text', + value: 'showText', + description: 'Display simple text or HTML', + }, ], }, { @@ -154,6 +161,22 @@ const completionProperties = updateDisplayOptions( }, }, }, + { + displayName: 'Text', + name: 'responseText', + type: 'string', + displayOptions: { + show: { + respondWith: ['showText'], + }, + }, + typeOptions: { + rows: 2, + }, + default: '', + placeholder: 'e.g. Thanks for filling the form', + description: 'The text to display on the page. Use HTML to show a customized web page.', + }, { displayName: 'Options', name: 'options', @@ -235,7 +258,7 @@ export class Form extends Node { ], }; - async webhook(context: IWebhookFunctions) { + async webhook(context: IWebhookFunctions): Promise { const res = context.getResponseObject(); const operation = context.getNodeParameter('operation', '') as string; @@ -280,36 +303,7 @@ export class Form extends Node { const method = context.getRequestObject().method; if (operation === 'completion' && method === 'GET') { - const completionTitle = context.getNodeParameter('completionTitle', '') as string; - const completionMessage = context.getNodeParameter('completionMessage', '') as string; - const redirectUrl = context.getNodeParameter('redirectUrl', '') as string; - const options = context.getNodeParameter('options', {}) as { formTitle: string }; - - if (redirectUrl) { - res.send( - ``, - ); - return { noWebhookResponse: true }; - } - - 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 { noWebhookResponse: true }; + return await renderFormCompletion(context, res, trigger); } if (operation === 'completion' && method === 'POST') { @@ -319,68 +313,7 @@ export class Form extends Node { } if (method === 'GET') { - 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'; - } - - const responseMode = 'onReceived'; - - let redirectUrl; - - const connectedNodes = context.getChildNodes(context.getNode().name); - - const hasNextPage = connectedNodes.some( - (node) => !node.disabled && (node.type === FORM_NODE_TYPE || node.type === WAIT_NODE_TYPE), - ); - - if (hasNextPage) { - redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string; - } - - const appendAttribution = context.evaluateExpression( - `{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`, - ) as boolean; - - renderForm({ - context, - res, - formTitle: title, - formDescription: description, - formFields: fields, - responseMode, - mode, - redirectUrl, - appendAttribution, - buttonLabel, - }); - - return { - noWebhookResponse: true, - }; + return await renderFormNode(context, res, trigger, fields, mode); } let useWorkflowTimezone = context.evaluateExpression( diff --git a/packages/nodes-base/nodes/Form/formCompletionUtils.ts b/packages/nodes-base/nodes/Form/formCompletionUtils.ts new file mode 100644 index 0000000000000..e952380495047 --- /dev/null +++ b/packages/nodes-base/nodes/Form/formCompletionUtils.ts @@ -0,0 +1,45 @@ +import { type Response } from 'express'; +import { + type NodeTypeAndVersion, + type IWebhookFunctions, + type IWebhookResponseData, +} from 'n8n-workflow'; + +import { sanitizeHtml } from './utils'; + +export const renderFormCompletion = async ( + context: IWebhookFunctions, + res: Response, + trigger: NodeTypeAndVersion, +): Promise => { + const completionTitle = context.getNodeParameter('completionTitle', '') as string; + const completionMessage = context.getNodeParameter('completionMessage', '') as string; + const redirectUrl = context.getNodeParameter('redirectUrl', '') as string; + const options = context.getNodeParameter('options', {}) as { formTitle: string }; + const responseText = context.getNodeParameter('responseText', '') as string; + + if (redirectUrl) { + res.send( + ``, + ); + return { noWebhookResponse: true }; + } + + 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, + responseText: sanitizeHtml(responseText), + }); + + return { noWebhookResponse: true }; +}; diff --git a/packages/nodes-base/nodes/Form/formNodeUtils.ts b/packages/nodes-base/nodes/Form/formNodeUtils.ts new file mode 100644 index 0000000000000..ff8a01a6dcfd7 --- /dev/null +++ b/packages/nodes-base/nodes/Form/formNodeUtils.ts @@ -0,0 +1,80 @@ +import { type Response } from 'express'; +import { + type NodeTypeAndVersion, + type IWebhookFunctions, + FORM_NODE_TYPE, + WAIT_NODE_TYPE, + type FormFieldsParameter, + type IWebhookResponseData, +} from 'n8n-workflow'; + +import { renderForm } from './utils'; + +export const renderFormNode = async ( + context: IWebhookFunctions, + res: Response, + trigger: NodeTypeAndVersion, + fields: FormFieldsParameter, + mode: 'test' | 'production', +): Promise => { + 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'; + } + + const responseMode = 'onReceived'; + + let redirectUrl; + + const connectedNodes = context.getChildNodes(context.getNode().name); + + const hasNextPage = connectedNodes.some( + (node) => !node.disabled && (node.type === FORM_NODE_TYPE || node.type === WAIT_NODE_TYPE), + ); + + if (hasNextPage) { + redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string; + } + + const appendAttribution = context.evaluateExpression( + `{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`, + ) as boolean; + + renderForm({ + context, + res, + formTitle: title, + formDescription: description, + formFields: fields, + responseMode, + mode, + redirectUrl, + appendAttribution, + buttonLabel, + }); + + return { + noWebhookResponse: true, + }; +}; diff --git a/packages/nodes-base/nodes/Form/test/Form.node.test.ts b/packages/nodes-base/nodes/Form/test/Form.node.test.ts index 537804dea3a3f..5065df20c9c83 100644 --- a/packages/nodes-base/nodes/Form/test/Form.node.test.ts +++ b/packages/nodes-base/nodes/Form/test/Form.node.test.ts @@ -217,47 +217,86 @@ describe('Form Node', () => { }); it('should handle completion operation and render completion page', 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'; - if (paramName === 'redirectUrl') return ''; - if (paramName === 'formFields.values') return []; - return {}; - }); - mockWebhookFunctions.getParentNodes.mockReturnValue([ + const formExpected = [ { - type: 'n8n-nodes-base.formTrigger', - name: 'Form Trigger', - typeVersion: 2.1, - disabled: false, + formParam: { + responseText: '', + }, + expected: { + appendAttribution: 'test', + formTitle: 'test', + message: 'Test Message', + title: 'Test Title', + responseText: '', + }, }, - ]); - mockWebhookFunctions.evaluateExpression.mockReturnValue('test'); + { + formParam: { + responseText: '
hey
', + }, + expected: { + appendAttribution: 'test', + formTitle: 'test', + message: 'Test Message', + title: 'Test Title', + responseText: '
hey
', + }, + }, + { + formParam: { + responseText: 'my text over here', + }, + expected: { + appendAttribution: 'test', + formTitle: 'test', + message: 'Test Message', + title: 'Test Title', + responseText: 'my text over here', + }, + }, + ]; - const mockResponseObject = { - render: jest.fn(), - redirect: jest.fn(), - }; - mockWebhookFunctions.getResponseObject.mockReturnValue( - mockResponseObject as unknown as Response, - ); - mockWebhookFunctions.getNode.mockReturnValue(mock({ name: formCompletionNodeName })); - mockWebhookFunctions.getExecutionId.mockReturnValue(testExecutionId); + for (const { formParam, expected } of formExpected) { + 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'; + if (paramName === 'redirectUrl') return ''; + if (paramName === 'formFields.values') return []; + if (paramName === 'responseText') return formParam.responseText; + return {}; + }); + mockWebhookFunctions.getParentNodes.mockReturnValue([ + { + type: 'n8n-nodes-base.formTrigger', + name: 'Form Trigger', + typeVersion: 2.1, + disabled: false, + }, + ]); + mockWebhookFunctions.evaluateExpression.mockReturnValue('test'); - const result = await form.webhook(mockWebhookFunctions); + const mockResponseObject = { + render: jest.fn(), + redirect: jest.fn(), + }; + mockWebhookFunctions.getResponseObject.mockReturnValue( + mockResponseObject as unknown as Response, + ); + mockWebhookFunctions.getNode.mockReturnValue(mock({ name: formCompletionNodeName })); + mockWebhookFunctions.getExecutionId.mockReturnValue(testExecutionId); - expect(result).toEqual({ noWebhookResponse: true }); - expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger-completion', { - appendAttribution: 'test', - formTitle: 'test', - message: 'Test Message', - title: 'Test Title', - }); + const result = await form.webhook(mockWebhookFunctions); + + expect(result).toEqual({ noWebhookResponse: true }); + expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger-completion', { + ...expected, + }); + } }); it('should handle completion operation and redirect', async () => { diff --git a/packages/nodes-base/nodes/Form/utils.ts b/packages/nodes-base/nodes/Form/utils.ts index 6967701c8c66c..114ccef35a9d8 100644 --- a/packages/nodes-base/nodes/Form/utils.ts +++ b/packages/nodes-base/nodes/Form/utils.ts @@ -187,7 +187,7 @@ export function prepareFormData({ return formData; } -const checkResponseModeConfiguration = (context: IWebhookFunctions) => { +const validateResponseModeConfiguration = (context: IWebhookFunctions) => { const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string; const connectedNodes = context.getChildNodes(context.getNode().name); @@ -456,7 +456,7 @@ export async function formWebhook( ); const method = context.getRequestObject().method; - checkResponseModeConfiguration(context); + validateResponseModeConfiguration(context); //Show the form on GET request if (method === 'GET') {