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 @@
-
-
-
-
+ {{#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') {