first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,18 @@
{
"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": []
},
"subcategories": {
"Core Nodes": ["Helpers"]
}
}
+452
View File
@@ -0,0 +1,452 @@
import type {
FormFieldsParameter,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
INodeTypeDescription,
IWebhookFunctions,
IWebhookResponseData,
} from 'n8n-workflow';
import {
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
Node,
NodeConnectionTypes,
NodeOperationError,
updateDisplayOptions,
} from 'n8n-workflow';
import { configureWaitTillDate } from '../../utils/sendAndWait/configureWaitTillDate.util';
import { limitWaitTimeProperties } from '../../utils/sendAndWait/descriptions';
import {
formDescription,
formFields,
formFieldsDynamic,
formTitle,
} from '../Form/common.descriptions';
import { cssVariables } from './cssVariables';
import { renderFormCompletion } from './utils/formCompletionUtils';
import { getFormTriggerNode, renderFormNode } from './utils/formNodeUtils';
import { parseFormFields, prepareFormReturnItem } from './utils/utils';
const waitTimeProperties: INodeProperties[] = [
{
displayName: 'Limit Wait Time',
name: 'limitWaitTime',
type: 'boolean',
default: false,
description:
'Whether to limit the time this node should wait for a user response before execution resumes',
},
...updateDisplayOptions(
{
show: {
limitWaitTime: [true],
},
},
limitWaitTimeProperties,
),
];
export const formFieldsProperties: INodeProperties[] = [
{
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',
name: 'jsonOutput',
type: 'json',
typeOptions: {
rows: 5,
},
default:
'[\n {\n "fieldLabel": "Name",\n "placeholder": "enter your 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 {\n "fieldLabel": "Textarea",\n "fieldType": "textarea"\n },\n {\n "fieldLabel": "Dropdown Options",\n "fieldType": "dropdown",\n "fieldOptions": {\n "values": [\n {\n "option": "option 1"\n },\n {\n "option": "option 2"\n }\n ]\n },\n "requiredField": true\n },\n {\n "fieldLabel": "Checkboxes",\n "fieldType": "checkbox",\n "fieldOptions": {\n "values": [\n {\n "option": "option 1"\n },\n {\n "option": "option 2"\n }\n ]\n }\n },\n {\n "fieldLabel": "Radio",\n "fieldType": "radio",\n "fieldOptions": {\n "values": [\n {\n "option": "option 1"\n },\n {\n "option": "option 2"\n }\n ]\n }\n },\n {\n "fieldLabel": "Email",\n "fieldType": "email",\n "placeholder": "me@mail.con"\n },\n {\n "fieldLabel": "File",\n "fieldType": "file",\n "multipleFiles": true,\n "acceptFileTypes": ".jpg, .png"\n },\n {\n "fieldLabel": "Number",\n "fieldType": "number"\n },\n {\n "fieldLabel": "Password",\n "fieldType": "password"\n }\n]\n',
validateType: 'form-fields',
ignoreValidationDuringExecution: true,
hint: '<a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/" target="_blank">See docs</a> for field syntax',
displayOptions: {
show: {
defineForm: ['json'],
},
},
},
{
...formFields,
displayOptions: {
show: { '@version': [{ _cnd: { lt: 2.5 } }], defineForm: ['fields'] },
},
},
{
...formFieldsDynamic,
displayOptions: {
show: { '@version': [{ _cnd: { gte: 2.5 } }], defineForm: ['fields'] },
},
},
];
const pageProperties = updateDisplayOptions(
{
show: {
operation: ['page'],
},
},
[
...formFieldsProperties,
...waitTimeProperties,
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{ ...formTitle, required: false },
formDescription,
{
displayName: 'Button Label',
name: 'buttonLabel',
type: 'string',
default: 'Submit',
},
{
displayName: 'Custom Form Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
default: cssVariables.trim(),
description: 'Override default styling of the public form interface with CSS',
},
],
},
],
);
const completionProperties = updateDisplayOptions(
{
show: {
operation: ['completion'],
},
},
[
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'On n8n Form Submission',
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',
},
{
name: 'Show Text',
value: 'showText',
description: 'Display simple text or HTML',
},
{
name: 'Return Binary File',
value: 'returnBinary',
description: 'Return incoming binary file',
},
],
},
{
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', 'returnBinary'],
},
},
},
{
displayName: 'Completion Message',
name: 'completionMessage',
type: 'string',
default: '',
typeOptions: {
rows: 2,
},
displayOptions: {
show: {
respondWith: ['text', 'returnBinary'],
},
},
},
{
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: 'Input Data Field Name',
name: 'inputDataFieldName',
type: 'string',
displayOptions: {
show: {
respondWith: ['returnBinary'],
},
},
default: 'data',
placeholder: 'e.g. data',
description:
'Find the name of input field containing the binary data to return in the Input panel on the left, in the Binary tab',
hint: 'The name of the input field containing the binary file data to be returned',
},
...waitTimeProperties,
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{ ...formTitle, required: false, displayName: 'Completion Page Title' },
{
displayName: 'Custom Form Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
default: cssVariables.trim(),
description: 'Override default styling of the public form interface with CSS',
},
],
displayOptions: {
show: {
respondWith: ['text', 'returnBinary', 'redirect'],
},
},
},
],
);
export class Form extends Node {
nodeInputData: INodeExecutionData[] = [];
description: INodeTypeDescription = {
displayName: 'n8n Form',
name: 'form',
icon: 'file:form.svg',
group: ['input'],
// since trigger and node are sharing descriptions and logic we need to sync the versions
// and keep them aligned in both nodes
version: [1, 2.3, 2.4, 2.5],
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'Form',
},
builderHint: {
relatedNodes: [
{
nodeType: 'n8n-nodes-base.formTrigger',
relationHint: 'Creates additional pages/steps after the trigger',
},
],
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
waitingNodeTooltip:
'=Execution will continue when form is submitted on <a href="{{ $execution.resumeFormUrl }}" target="_blank">{{ $execution.resumeFormUrl }}</a>',
webhooks: [
{
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
path: '',
restartWebhook: true,
isFullPath: true,
nodeType: 'form',
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'responseNode',
path: '',
restartWebhook: true,
isFullPath: true,
nodeType: 'form',
},
],
properties: [
{
displayName: 'An n8n Form Trigger node must be set up before this node',
name: 'triggerNotice',
type: 'notice',
default: '',
},
{
displayName: 'Page Type',
name: 'operation',
type: 'options',
default: 'page',
noDataExpression: true,
options: [
{
name: 'Next Form Page',
value: 'page',
},
{
name: 'Form Ending',
value: 'completion',
},
],
},
...pageProperties,
...completionProperties,
],
};
async webhook(context: IWebhookFunctions): Promise<IWebhookResponseData> {
const res = context.getResponseObject();
const operation = context.getNodeParameter('operation', '') as string;
const trigger = getFormTriggerNode(context);
const mode = context.evaluateExpression(`{{ $('${trigger.name}').first().json.formMode }}`) as
| 'test'
| 'production';
const defineForm = context.getNodeParameter('defineForm', false) as string;
let fields: FormFieldsParameter = [];
if (defineForm === 'json') {
fields = parseFormFields(context, {
defineForm: 'json',
fieldsParameterName: 'jsonOutput',
mode,
});
} else {
fields = parseFormFields(context, {
defineForm: 'fields',
fieldsParameterName: 'formFields.values',
mode,
});
}
const method = context.getRequestObject().method;
if (operation === 'completion' && method === 'GET') {
return await renderFormCompletion(context, res, trigger);
}
if (operation === 'completion' && method === 'POST') {
return {
workflowData: [context.evaluateExpression('{{ $input.all() }}') as INodeExecutionData[]],
};
}
if (method === 'GET') {
return await renderFormNode(context, res, trigger, fields, mode);
}
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 },
workflowData: [[returnItem]],
};
}
async execute(context: IExecuteFunctions): Promise<INodeExecutionData[][]> {
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 === FORM_TRIGGER_NODE_TYPE);
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 === FORM_NODE_TYPE);
if (operation === 'completion' && hasNextPage) {
throw new NodeOperationError(
context.getNode(),
'Completion has to be the last Form node in the workflow',
);
}
const waitTill = configureWaitTillDate(context, 'root');
await context.putExecutionToWait(waitTill);
context.sendResponse({
headers: {
location: context.evaluateExpression('{{ $execution.resumeFormUrl }}', 0),
},
statusCode: 307,
});
return [context.getInputData()];
}
}
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.formTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Core Nodes"],
"alias": ["table", "submit", "post"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.formtrigger/"
}
],
"generic": []
},
"subcategories": {
"Core Nodes": ["Other Trigger Nodes"]
}
}
@@ -0,0 +1,30 @@
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { FormTriggerV1 } from './v1/FormTriggerV1.node';
import { FormTriggerV2 } from './v2/FormTriggerV2.node';
export class FormTrigger extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'n8n Form Trigger',
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaultVersion: 2.5,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new FormTriggerV1(baseDescription),
2: new FormTriggerV2(baseDescription),
2.1: new FormTriggerV2(baseDescription),
2.2: new FormTriggerV2(baseDescription),
2.3: new FormTriggerV2(baseDescription),
2.4: new FormTriggerV2(baseDescription),
2.5: new FormTriggerV2(baseDescription),
};
super(nodeVersions, baseDescription);
}
}
@@ -0,0 +1,706 @@
import type { INodeProperties, INodePropertyCollection, INodePropertyOptions } from 'n8n-workflow';
import { appendAttributionOption } from '../../utils/descriptions';
// Shared form element types used in both formFields and formFieldsDynamic
export const formElementTypes: INodePropertyOptions[] = [
{
name: 'Checkboxes',
value: 'checkbox',
},
{
name: 'Custom HTML',
value: 'html',
},
{
name: 'Date',
value: 'date',
},
{
name: 'Dropdown',
value: 'dropdown',
},
{
name: 'Email',
value: 'email',
},
{
name: 'File',
value: 'file',
},
{
name: 'Hidden Field',
value: 'hiddenField',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Password',
value: 'password',
},
{
name: 'Radio Buttons',
value: 'radio',
},
{
name: 'Text Input',
value: 'text',
},
{
name: 'Textarea',
value: 'textarea',
},
];
export const placeholder: string = `
<!-- Your custom HTML here --->
`.trimStart();
export const webhookPath: INodeProperties = {
displayName: 'Form Path',
name: 'path',
type: 'string',
default: '',
placeholder: 'webhook',
required: true,
description: "The final segment of the form's URL, both for test and production",
};
export const formTitle: INodeProperties = {
displayName: 'Form Title',
name: 'formTitle',
type: 'string',
default: '',
placeholder: 'e.g. Contact us',
required: true,
description: 'Shown at the top of the form',
};
export const formDescription: INodeProperties = {
displayName: 'Form Description',
name: 'formDescription',
type: 'string',
default: '',
placeholder: "e.g. We'll get back to you soon",
description:
'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form. Accepts HTML. Does not accept <code>&lt;script&gt;</code>, <code>&lt;style&gt;</code> or <code>&lt;input&gt;</code> tags.',
typeOptions: {
rows: 2,
},
};
export const ipAllowlist: INodeProperties = {
displayName: 'IP(s) Allowlist',
name: 'ipWhitelist',
type: 'string',
placeholder: 'e.g. 127.0.0.1, 192.168.1.0/24',
default: '',
description:
'Comma-separated list of allowed IP addresses or CIDR ranges. Leave empty to allow all IPs.',
};
const formOptions: INodePropertyCollection[] = [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Field Name',
name: 'fieldName',
description:
'The name of the field, used in input attributes and referenced by the workflow',
required: true,
type: 'string',
default: '',
displayOptions: {
hide: {
fieldType: ['html'],
},
show: {
'@version': [2.4],
},
},
},
{
displayName: 'Label',
name: 'fieldLabel',
type: 'string',
default: '',
placeholder: 'e.g. What is your name?',
description: 'Label that appears above the input field',
required: true,
displayOptions: {
hide: {
fieldType: ['hiddenField', 'html'],
},
show: {
'@version': [{ _cnd: { gte: 2.4 } }],
},
},
},
{
displayName: 'Field Name',
name: 'fieldLabel',
type: 'string',
default: '',
placeholder: 'e.g. What is your name?',
description: 'Label that appears above the input field',
required: true,
displayOptions: {
hide: {
fieldType: ['hiddenField', 'html'],
},
show: {
'@version': [{ _cnd: { lt: 2.4 } }],
},
},
},
{
displayName: 'Field Name',
name: 'fieldName',
description:
'The name of the field, used in input attributes and referenced by the workflow',
type: 'string',
default: '',
displayOptions: {
show: {
fieldType: ['hiddenField'],
'@version': [{ _cnd: { lt: 2.4 } }],
},
},
},
{
displayName: 'Element Type',
name: 'fieldType',
type: 'options',
default: 'text',
description: 'The type of field to add to the form',
// Update ALLOWED_FIELD_TYPES in packages/workflow/src/type-validation.ts when adding new field types
options: formElementTypes,
required: true,
},
{
displayName: 'Element Name',
name: 'elementName',
type: 'string',
default: '',
placeholder: 'e.g. content-section',
description: 'Optional field. It can be used to include the html in the output.',
displayOptions: {
show: {
fieldType: ['html'],
},
},
},
{
displayName: 'Custom Field Name',
name: 'fieldName',
description:
'The name of the field, used in input attributes and referenced by the workflow',
type: 'string',
default: '',
displayOptions: {
hide: {
fieldType: ['html'],
},
show: {
'@version': [{ _cnd: { gte: 2.5 } }],
},
},
},
{
displayName: 'Placeholder',
name: 'placeholder',
description: 'Sample text to display inside the field',
type: 'string',
default: '',
displayOptions: {
hide: {
fieldType: ['dropdown', 'date', 'file', 'html', 'hiddenField', 'radio', 'checkbox'],
},
},
},
{
displayName: 'Default Value',
name: 'defaultValue',
description: 'Default value that will be pre-filled in the form field',
type: 'string',
default: '',
displayOptions: {
show: {
fieldType: ['text', 'number', 'email', 'textarea'],
},
},
},
{
displayName: 'Default Value',
name: 'defaultValue',
description:
'Default date value that will be pre-filled in the form field (format: YYYY-MM-DD)',
type: 'dateTime',
typeOptions: {
dateOnly: true,
},
default: '',
displayOptions: {
show: {
fieldType: ['date'],
},
},
},
{
displayName: 'Default Value',
name: 'defaultValue',
description:
'Default value that will be pre-selected. Must match one of the option labels.',
type: 'string',
default: '',
displayOptions: {
show: {
fieldType: ['dropdown', 'radio'],
},
},
},
{
displayName: 'Default Value',
name: 'defaultValue',
description:
'Default value(s) that will be pre-selected. Must match one or multiple of the option labels. Separate multiple pre-selected options with a comma.',
type: 'string',
default: '',
displayOptions: {
show: {
fieldType: ['checkbox'],
},
},
},
{
displayName: 'Field Value',
name: 'fieldValue',
description:
'Input value can be set here or will be passed as a query parameter via Field Name if no value is set',
type: 'string',
default: '',
displayOptions: {
show: {
fieldType: ['hiddenField'],
},
},
},
{
displayName: 'Field Options',
name: 'fieldOptions',
placeholder: 'Add Field Option',
description: 'List of options that can be selected from the dropdown',
type: 'fixedCollection',
default: { values: [{ option: '' }] },
required: true,
displayOptions: {
show: {
fieldType: ['dropdown'],
},
},
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Option',
name: 'option',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Checkboxes',
name: 'fieldOptions',
placeholder: 'Add Checkbox',
type: 'fixedCollection',
default: { values: [{ option: '' }] },
required: true,
displayOptions: {
show: {
fieldType: ['checkbox'],
},
},
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Checkbox Label',
name: 'option',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Radio Buttons',
name: 'fieldOptions',
placeholder: 'Add Radio Button',
type: 'fixedCollection',
default: { values: [{ option: '' }] },
required: true,
displayOptions: {
show: {
fieldType: ['radio'],
},
},
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Radio Button Label',
name: 'option',
type: 'string',
default: '',
},
],
},
],
},
{
displayName:
'Multiple Choice is a legacy option, please use Checkboxes or Radio Buttons field type instead',
name: 'multiselectLegacyNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
multiselect: [true],
fieldType: ['dropdown'],
'@version': [{ _cnd: { lt: 2.3 } }],
},
},
},
{
displayName: 'Multiple Choice',
name: 'multiselect',
type: 'boolean',
default: false,
description: 'Whether to allow the user to select multiple options from the dropdown list',
displayOptions: {
show: {
fieldType: ['dropdown'],
'@version': [{ _cnd: { lt: 2.3 } }],
},
},
},
{
displayName: 'Limit Selection',
name: 'limitSelection',
type: 'options',
default: 'unlimited',
options: [
{
name: 'Exact Number',
value: 'exact',
},
{
name: 'Range',
value: 'range',
},
{
name: 'Unlimited',
value: 'unlimited',
},
],
displayOptions: {
show: {
fieldType: ['checkbox'],
},
},
},
{
displayName: 'Number of Selections',
name: 'numberOfSelections',
type: 'number',
default: 1,
typeOptions: {
numberPrecision: 0,
minValue: 1,
showEvenWhenOptional: true,
},
displayOptions: {
show: {
fieldType: ['checkbox'],
limitSelection: ['exact'],
},
},
},
{
displayName: 'Minimum Selections',
name: 'minSelections',
type: 'number',
default: 0,
typeOptions: {
numberPrecision: 0,
minValue: 0,
showEvenWhenOptional: true,
},
displayOptions: {
show: {
fieldType: ['checkbox'],
limitSelection: ['range'],
},
},
},
{
displayName: 'Maximum Selections',
name: 'maxSelections',
type: 'number',
default: 1,
typeOptions: {
numberPrecision: 0,
minValue: 1,
showEvenWhenOptional: true,
},
displayOptions: {
show: {
fieldType: ['checkbox'],
limitSelection: ['range'],
},
},
},
{
displayName: 'HTML',
name: 'html',
typeOptions: {
editor: 'htmlEditor',
},
type: 'string',
noDataExpression: true,
default: placeholder,
description: 'HTML elements to display on the form page',
hint: 'Does not accept <code>&lt;script&gt;</code>, <code>&lt;style&gt;</code> or <code>&lt;input&gt;</code> tags',
displayOptions: {
show: {
fieldType: ['html'],
},
},
},
{
displayName: 'Multiple Files',
name: 'multipleFiles',
type: 'boolean',
default: true,
description:
'Whether to allow the user to select multiple files from the file input or just one',
displayOptions: {
show: {
fieldType: ['file'],
},
},
},
{
displayName: 'Accepted File Types',
name: 'acceptFileTypes',
type: 'string',
default: '',
description: 'Comma-separated list of allowed file extensions',
hint: 'Leave empty to allow all file types',
placeholder: 'e.g. .jpg, .png',
displayOptions: {
show: {
fieldType: ['file'],
},
},
},
{
displayName: "The displayed date is formatted based on the locale of the user's browser",
name: 'formatDate',
type: 'notice',
default: '',
displayOptions: {
show: {
fieldType: ['date'],
},
},
},
{
displayName: 'Required Field',
name: 'requiredField',
type: 'boolean',
default: false,
description:
'Whether to require the user to enter a value for this field before submitting the form',
displayOptions: {
hide: {
fieldType: ['html', 'hiddenField'],
},
},
},
],
},
];
export const formFields: INodeProperties = {
displayName: 'Form Elements',
name: 'formFields',
placeholder: 'Add Form Element',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
sortable: true,
fixedCollection: {
itemTitle:
'={{ $collection.item.properties.find(p => p.name === "fieldType").options.find(o => o.value === $collection.item.value.fieldType).name }}',
},
},
options: formOptions,
};
export const formFieldsDynamic: INodeProperties = {
displayName: 'Form Elements',
name: 'formFields',
placeholder: 'Add Form Element',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
sortable: true,
hideOptionalFields: true,
addOptionalFieldButtonText: 'Add Attributes',
fixedCollection: {
itemTitle:
'={{ $collection.item.properties.find(p => p.name === "fieldType").options.find(o => o.value === $collection.item.value.fieldType).name }}',
},
},
options: formOptions,
};
export const formRespondMode: INodeProperties = {
displayName: 'Respond When',
name: 'responseMode',
type: 'options',
options: [
{
name: 'Form Is Submitted',
value: 'onReceived',
description: 'As soon as this node receives the form submission',
},
{
name: 'Workflow Finishes',
value: 'lastNode',
description: 'When the last node of the workflow is executed',
},
{
name: "Using 'Respond to Webhook' Node",
value: 'responseNode',
description: "When the 'Respond to Webhook' node is executed",
},
],
default: 'onReceived',
description: 'When to respond to the form submission',
};
export const formTriggerPanel = {
header: 'Pull in a test form submission',
executionsHelp: {
inactive:
"Form Trigger has two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the 'Execute step' button, then fill out the test form that opens in a popup tab. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Publish the workflow, then make requests to the production URL. Then every time there's a form submission via the Production Form URL, the workflow will execute. These executions will show up in the executions list, but not in the editor.",
active:
"Form Trigger has two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the 'Execute step' button, then fill out the test form that opens in a popup tab. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. Publish the workflow, then make requests to the production URL. Then every time there's a form submission via the Production Form URL, the workflow will execute. These executions will show up in the executions list, but not in the editor.",
},
activationHint: {
active:
"This node will also trigger automatically on new form submissions (but those executions won't show up here).",
inactive:
'Publish this workflow to have it also run automatically for new form submissions created via the Production URL.',
},
};
export const respondWithOptions: INodeProperties = {
displayName: 'Form Response',
name: 'respondWithOptions',
type: 'fixedCollection',
placeholder: 'Add option',
default: { values: { respondWith: 'text' } },
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Respond With',
name: 'respondWith',
type: 'options',
default: 'text',
options: [
{
name: 'Form Submitted Text',
value: 'text',
description: 'Show a response text to the user',
},
{
name: 'Redirect URL',
value: 'redirect',
description: 'Redirect the user to a URL',
},
],
},
{
displayName: 'Text to Show',
name: 'formSubmittedText',
description:
"The text displayed to users after they fill the form. Leave it empty if don't want to show any additional text.",
type: 'string',
default: 'Your response has been recorded',
displayOptions: {
show: {
respondWith: ['text'],
},
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'URL to Redirect to',
name: 'redirectUrl',
description:
'The URL to redirect users to after they fill the form. Must be a valid URL.',
type: 'string',
default: '',
validateType: 'url',
placeholder: 'e.g. http://www.n8n.io',
displayOptions: {
show: {
respondWith: ['redirect'],
},
},
},
],
},
],
};
export const appendAttributionToForm: INodeProperties = {
...appendAttributionOption,
description: 'Whether to include the link “Form automated with n8n” at the bottom of the form',
};
@@ -0,0 +1,70 @@
export const cssVariables = `
:root {
--font-family: 'Open Sans', sans-serif;
--font-weight-normal: 400;
--font-weight-bold: 600;
--font-size-body: 12px;
--font-size-label: 14px;
--font-size-test-notice: 12px;
--font-size-input: 14px;
--font-size-header: 20px;
--font-size-paragraph: 14px;
--font-size-link: 12px;
--font-size-error: 12px;
--font-size-html-h1: 28px;
--font-size-html-h2: 20px;
--font-size-html-h3: 16px;
--font-size-html-h4: 14px;
--font-size-html-h5: 12px;
--font-size-html-h6: 10px;
--font-size-subheader: 14px;
/* Colors */
--color-background: #fbfcfe;
--color-test-notice-text: #e6a23d;
--color-test-notice-bg: #fefaf6;
--color-test-notice-border: #f6dcb7;
--color-card-bg: #ffffff;
--color-card-border: #dbdfe7;
--color-card-shadow: rgba(99, 77, 255, 0.06);
--color-link: #7e8186;
--color-header: #525356;
--color-label: #555555;
--color-input-border: #dbdfe7;
--color-input-text: #71747A;
--color-focus-border: rgb(90, 76, 194);
--color-submit-btn-bg: #ff6d5a;
--color-submit-btn-text: #ffffff;
--color-error: #ea1f30;
--color-required: #ff6d5a;
--color-clear-button-bg: #7e8186;
--color-html-text: #555;
--color-html-link: #ff6d5a;
--color-header-subtext: #7e8186;
/* Border Radii */
--border-radius-card: 8px;
--border-radius-input: 6px;
--border-radius-clear-btn: 50%;
--card-border-radius: 8px;
/* Spacing */
--padding-container-top: 24px;
--padding-card: 24px;
--padding-test-notice-vertical: 12px;
--padding-test-notice-horizontal: 24px;
--margin-bottom-card: 16px;
--padding-form-input: 12px;
--card-padding: 24px;
--card-margin-bottom: 16px;
/* Dimensions */
--container-width: 448px;
--submit-btn-height: 48px;
--checkbox-size: 18px;
/* Others */
--box-shadow-card: 0px 4px 16px 0px var(--color-card-shadow);
--opacity-placeholder: 0.5;
}
`;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="46" height="40" fill="none"><path fill="#00B7BC" fill-rule="evenodd" d="M34.978 37.732a1.56 1.56 0 0 1-1.562 1.563H6.26a1.56 1.56 0 0 1-1.563-1.563V9.607c0-.405.157-.794.438-1.086l6.304-6.531v5.344H8.213a1.172 1.172 0 1 0 0 2.343h4.43a1.17 1.17 0 0 0 1.171-1.171V.232h19.602a1.56 1.56 0 0 1 1.562 1.563v10.327l-2.86 2.86-8.252 8.276a413.006 413.006 0 0 1-1.654 1.662l-.337.337a2 2 0 0 0-.557 1.08L20.3 31.922c-.108.638-.215 1.079.211 1.418.403.32.9.174 1.54.066l5.408-.928a2 2 0 0 0 1.08-.556l6.44-6.429zm-24.03-21.265a1.18 1.18 0 0 0 1.171 1.172h13.163a1.172 1.172 0 1 0 0-2.344H12.119a1.17 1.17 0 0 0-1.172 1.172m7.294 14.766a1.17 1.17 0 0 0-1.172-1.172H12.12a1.172 1.172 0 1 0 0 2.343h4.951a1.17 1.17 0 0 0 1.172-1.172m.86-7.391a1.17 1.17 0 0 0-1.172-1.172h-5.811a1.172 1.172 0 1 0 0 2.343h5.81a1.164 1.164 0 0 0 1.173-1.171" clip-rule="evenodd"/><path fill="#00B7BC" d="m33.532 16.397 4.289-4.289 3.758 3.71 1.617-1.616 2.258 2.257c.218.218.34.513.343.82-.002.311-.125.608-.344.83l-6.804 6.796a1.13 1.13 0 0 1-.828.343 1.15 1.15 0 0 1-.828-.343 1.18 1.18 0 0 1 0-1.657l5.976-5.968-1.312-1.313-1.383 1.414-13.149 13.125-4.617.782.782-4.617.336-.337 2.562 2.555a1.1 1.1 0 0 0 .828.344c.312.005.612-.12.828-.344a1.18 1.18 0 0 0 0-1.656l-2.562-2.562zM44.736 12.24c0 .414-.163.81-.454 1.102l-.922.914-3.852-3.828.93-.93a1.563 1.563 0 0 1 2.203 0l1.64 1.641c.291.293.455.69.455 1.102"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,54 @@
import type { GenericValue } from 'n8n-workflow';
export type FormField = {
id: string;
errorId: string;
label: string;
placeholder?: string;
inputRequired: 'form-required' | '';
type?: 'text' | 'number' | 'date' | 'email';
defaultValue: GenericValue;
isInput?: boolean;
isTextarea?: boolean;
isSelect?: boolean;
selectOptions?: string[];
isMultiSelect?: boolean;
radioSelect?: 'radio';
exactSelectedOptions?: number;
minSelectedOptions?: number;
maxSelectedOptions?: number;
multiSelectOptions?: Array<{ id: string; label: string }>;
isFileInput?: boolean;
acceptFileTypes?: string;
multipleFiles?: 'multiple' | '';
isHtml?: boolean;
html?: string;
isHidden?: boolean;
hiddenName?: string;
hiddenValue?: GenericValue;
};
export type FormTriggerData = {
testRun: boolean;
formTitle: string;
formDescription?: string;
formDescriptionMetadata?: string;
formSubmittedHeader?: string;
formSubmittedText?: string;
redirectUrl?: string;
n8nWebsiteLink: string;
formFields: FormField[];
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
dangerousCustomCss?: string;
authToken?: string;
};
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';
@@ -0,0 +1,538 @@
import type { Response, Request } from 'express';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type {
IExecuteFunctions,
INode,
INodeExecutionData,
IWebhookFunctions,
IWorkflowSettings,
NodeTypeAndVersion,
} from 'n8n-workflow';
import { Form } from '../Form.node';
jest.mock('../../../utils/sendAndWait/configureWaitTillDate.util', () => ({
configureWaitTillDate: jest.fn(), // Mocked function
}));
describe('Form Node', () => {
let form: Form;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockWebhookFunctions: MockProxy<IWebhookFunctions>;
const formCompletionNodeName = 'Form Completion';
const testExecutionId = 'test_execution_id';
beforeEach(() => {
form = new Form();
mockExecuteFunctions = mock<IExecuteFunctions>();
mockWebhookFunctions = mock<IWebhookFunctions>();
mockExecuteFunctions.getWorkflowSettings.mockReturnValue(mock<IWorkflowSettings>({}));
mockWebhookFunctions.getWorkflowSettings.mockReturnValue(mock<IWorkflowSettings>({}));
});
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<INode>());
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<NodeTypeAndVersion>({ type: 'n8n-nodes-base.formTrigger' }),
]);
mockExecuteFunctions.getChildNodes.mockReturnValue([]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>());
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<NodeTypeAndVersion>({ type: 'n8n-nodes-base.formTrigger' }),
]);
mockExecuteFunctions.getChildNodes.mockReturnValue([
mock<NodeTypeAndVersion>({ type: 'n8n-nodes-base.form' }),
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>());
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<NodeTypeAndVersion>({ type: 'n8n-nodes-base.formTrigger' }),
]);
mockExecuteFunctions.getChildNodes.mockReturnValue([]);
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ name: formCompletionNodeName }));
mockExecuteFunctions.getExecutionId.mockReturnValue(testExecutionId);
mockExecuteFunctions.getWorkflowStaticData.mockReturnValue({
[`${testExecutionId}-${formCompletionNodeName}`]: { redirectUrl: 'test' },
});
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(),
setHeader: 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,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'operation') return 'page';
if (paramName === 'useJson') return false;
if (paramName === 'formFields.values')
return [
{ fieldLabel: 'test' },
{
fieldName: 'Powerpuff Girl',
fieldValue: 'Blossom',
fieldType: 'hiddenField',
fieldLabel: '',
},
];
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', {
appendAttribution: 'test',
buttonLabel: 'Form Button',
formDescription: 'Form Description',
formDescriptionMetadata: 'Form Description',
formFields: [
{
id: 'field-0',
errorId: 'error-field-0',
label: 'test',
inputRequired: '',
defaultValue: '',
isInput: true,
placeholder: undefined,
type: undefined,
},
{
id: 'field-1',
errorId: 'error-field-1',
label: 'Powerpuff Girl',
inputRequired: '',
defaultValue: '',
placeholder: undefined,
hiddenName: 'Powerpuff Girl',
hiddenValue: 'Blossom',
isHidden: true,
},
],
formSubmittedText: 'Your response has been recorded',
formTitle: 'Form Title',
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
testRun: true,
useResponseData: true,
formSubmittedHeader: undefined,
});
});
it('should return form data for POST request', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({
method: 'POST',
contentType: 'multipart/form-data',
} as Request);
mockWebhookFunctions.getParentNodes.mockReturnValue([
{
type: 'n8n-nodes-base.formTrigger',
name: 'Form Trigger',
typeVersion: 2.1,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
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 and render completion page', async () => {
const formExpected = [
{
formParam: {
responseText: '',
},
expected: {
appendAttribution: 'test',
formTitle: 'test',
message: 'Test Message',
redirectUrl: undefined,
title: 'Test Title',
responseBinary: encodeURIComponent(JSON.stringify('')),
responseText: '',
dangerousCustomCss: undefined,
},
},
{
formParam: {
responseText: '<div>hey</div><script>alert("hi")</script>',
},
expected: {
appendAttribution: 'test',
formTitle: 'test',
message: 'Test Message',
redirectUrl: undefined,
title: 'Test Title',
responseText: '<div>hey</div><script>alert("hi")</script>',
responseBinary: encodeURIComponent(JSON.stringify('')),
dangerousCustomCss: undefined,
},
},
{
formParam: {
responseText: 'my text over here',
},
expected: {
appendAttribution: 'test',
formTitle: 'test',
message: 'Test Message',
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
title: 'Test Title',
responseText: 'my text over here',
dangerousCustomCss: undefined,
},
},
];
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 mockResponseObject = {
render: jest.fn(),
redirect: jest.fn(),
setHeader: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>({ name: formCompletionNodeName }));
mockWebhookFunctions.getExecutionId.mockReturnValue(testExecutionId);
const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger-completion', {
...expected,
});
}
});
it('should pass customCss to form template', async () => {
const mockResponseObject = {
render: jest.fn(),
setHeader: 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,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'operation') return 'page';
if (paramName === 'formFields.values') return [];
if (paramName === 'options') {
return {
customCss: '.form-container { background-color: #f5f5f5; }',
};
}
return undefined;
});
mockWebhookFunctions.getChildNodes.mockReturnValue([]);
await form.webhook(mockWebhookFunctions);
expect(mockResponseObject.render).toHaveBeenCalledWith(
'form-trigger',
expect.objectContaining({
dangerousCustomCss: '.form-container { background-color: #f5f5f5; }',
}),
);
});
it('should pass customCss to form completion template', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
if (paramName === 'operation') return 'completion';
if (paramName === 'respondWith') return 'text';
if (paramName === 'completionTitle') return 'Completion Title';
if (paramName === 'completionMessage') return 'Completion Message';
if (paramName === 'redirectUrl') return '';
if (paramName === 'responseText') return '';
if (paramName === 'options')
return {
customCss: '.completion-container { color: blue; }',
};
if (paramName === 'formFields.values') return [];
return {};
});
mockWebhookFunctions.getParentNodes.mockReturnValue([
{
type: 'n8n-nodes-base.formTrigger',
name: 'Form Trigger',
typeVersion: 2.1,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
const mockResponseObject = {
render: jest.fn(),
setHeader: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.render).toHaveBeenCalledWith(
'form-trigger-completion',
expect.objectContaining({
dangerousCustomCss: '.completion-container { color: blue; }',
}),
);
});
it.each(['json', 'fields'])(
'should evaluate expressions only once in %s mode, preserving nested braces',
async (defineForm) => {
const formFields = [
{
fieldLabel: 'Custom HTML',
fieldType: 'html',
elementName: 'test',
html: '<h2>Hello {{ $json.world }} </h2>',
},
];
const mockResponseObject = {
render: jest.fn(),
setHeader: 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,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockImplementation((expression: string) => {
console.log('expression', expression);
if (expression.includes('formMode')) {
return 'test';
}
if (expression === '{{ $json.world }}') {
return "{{ 'World' }}";
}
if (expression === "{{ 'World' }}") {
fail('Should not be called');
}
return expression;
});
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'operation') return 'page';
if (paramName === 'defineForm') return defineForm;
if (paramName === 'jsonOutput') return `=${JSON.stringify(formFields)}`;
if (paramName === 'formFields.values') return formFields;
if (paramName === 'options') {
return {
formTitle: 'Form Title',
formDescription: 'Form Description',
buttonLabel: 'Form Button',
};
}
return undefined;
});
mockWebhookFunctions.getChildNodes.mockReturnValue([]);
await form.webhook(mockWebhookFunctions);
expect(mockWebhookFunctions.evaluateExpression).not.toHaveBeenCalledWith("{{ 'World' }}");
expect(mockResponseObject.render).toHaveBeenCalledWith(
'form-trigger',
expect.objectContaining({
formFields: expect.arrayContaining([
expect.objectContaining({
html: "<h2>Hello {{ 'World' }} </h2>",
}),
]),
}),
);
},
);
});
describe('webhook method - completion and redirect', () => {
it('should handle completion operation and redirect', 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 'https://n8n.io';
if (paramName === 'formFields.values') return [];
if (paramName === 'responseText') return '';
return {};
});
mockWebhookFunctions.getParentNodes.mockReturnValue([
{
type: 'n8n-nodes-base.formTrigger',
name: 'Form Trigger',
typeVersion: 2.1,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
const mockResponseObject = {
render: jest.fn(),
redirect: jest.fn(),
send: jest.fn(),
setHeader: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>({ name: formCompletionNodeName }));
const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: 'test',
formTitle: 'test',
message: 'Test Message',
redirectUrl: 'https://n8n.io',
responseText: '',
title: 'Test Title',
responseBinary: encodeURIComponent(JSON.stringify('')),
});
});
});
});
@@ -0,0 +1,421 @@
import crypto from 'crypto';
import { mock } from 'jest-mock-extended';
import { NodeOperationError, type INode } from 'n8n-workflow';
import { testVersionedWebhookTriggerNode } from '@test/nodes/TriggerHelpers';
import { FormTrigger } from '../FormTrigger.node';
describe('FormTrigger', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render a form template with correct fields', async () => {
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
{ fieldLabel: 'Notes', fieldType: 'textarea', requiredField: false },
{
fieldLabel: 'Gender',
fieldType: 'select',
requiredField: true,
fieldOptions: { values: [{ option: 'Male' }, { option: 'Female' }] },
},
{
fieldLabel: 'Resume',
fieldType: 'file',
requiredField: true,
acceptFileTypes: '.pdf,.doc',
multipleFiles: false,
},
];
const { response, responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
mode: 'manual',
node: {
parameters: {
formTitle: 'Test Form',
formDescription: 'Test Description',
responseMode: 'onReceived',
authentication: 'none',
formFields: { values: formFields },
options: {
appendAttribution: false,
respondWithOptions: { values: { respondWith: 'text' } },
},
},
},
});
expect(response.render).toHaveBeenCalledWith('form-trigger', {
appendAttribution: false,
buttonLabel: 'Submit',
formDescription: 'Test Description',
formDescriptionMetadata: 'Test Description',
formFields: [
{
defaultValue: '',
errorId: 'error-field-0',
id: 'field-0',
inputRequired: 'form-required',
isInput: true,
label: 'Name',
placeholder: undefined,
type: 'text',
},
{
defaultValue: '',
errorId: 'error-field-1',
id: 'field-1',
inputRequired: '',
isInput: true,
label: 'Age',
placeholder: undefined,
type: 'number',
},
{
defaultValue: '',
errorId: 'error-field-2',
id: 'field-2',
inputRequired: '',
label: 'Notes',
placeholder: undefined,
isTextarea: true,
},
{
defaultValue: '',
errorId: 'error-field-3',
id: 'field-3',
inputRequired: 'form-required',
isInput: true,
label: 'Gender',
placeholder: undefined,
type: 'select',
},
{
acceptFileTypes: '.pdf,.doc',
defaultValue: '',
errorId: 'error-field-4',
id: 'field-4',
inputRequired: 'form-required',
isFileInput: true,
label: 'Resume',
multipleFiles: '',
placeholder: undefined,
},
],
formSubmittedText: 'Your response has been recorded',
formTitle: 'Test Form',
n8nWebsiteLink:
'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=instanceId',
testRun: true,
useResponseData: false,
});
expect(responseData).toEqual({ noWebhookResponse: true });
});
it('should return workflowData on POST request', async () => {
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
{ fieldLabel: 'Date', fieldType: 'date', formatDate: 'dd MMM', requiredField: false },
{ fieldLabel: 'Empty', fieldType: 'number', requiredField: false },
{
fieldLabel: 'Tags',
fieldType: 'select',
multiselect: true,
requiredField: false,
fieldOptions: { values: [{ option: 'Popular' }, { option: 'Recent' }] },
},
];
const bodyData = {
data: {
'field-0': 'John Doe',
'field-1': '30',
'field-2': '2024-08-31',
'field-4': '{}',
},
};
const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
mode: 'manual',
node: {
parameters: {
formTitle: 'Test Form',
formDescription: 'Test Description',
responseMode: 'onReceived',
authentication: 'none',
formFields: { values: formFields },
},
},
request: {
method: 'POST',
headers: { 'content-type': 'multipart/form-data' },
contentType: 'multipart/form-data',
},
bodyData,
});
expect(responseData).toEqual({
webhookResponse: { status: 200 },
workflowData: [
[
{
json: {
Name: 'John Doe',
Age: 30,
Date: '31 Jan',
Empty: null,
Tags: {},
submittedAt: expect.any(String),
formMode: 'test',
},
},
],
],
});
});
describe('Respond to Webhook', () => {
it('should throw when misconfigured', async () => {
await expect(
testVersionedWebhookTriggerNode(FormTrigger, 2, {
node: {
parameters: {
responseMode: 'responseNode',
},
},
request: { method: 'POST' },
childNodes: [],
}),
).rejects.toEqual(
new NodeOperationError(mock<INode>(), 'No Respond to Webhook node found in the workflow'),
);
await expect(
testVersionedWebhookTriggerNode(FormTrigger, 2.1, {
node: {
typeVersion: 2.1,
parameters: {
responseMode: 'onReceived',
},
},
request: { method: 'POST' },
childNodes: [
{
name: 'Test Respond To Webhook',
type: 'n8n-nodes-base.respondToWebhook',
typeVersion: 1,
disabled: false,
},
],
}),
).rejects.toEqual(
new NodeOperationError(
mock<INode>(),
'Unused Respond to Webhook node found in the workflow',
),
);
});
});
it('webhook execution not successful when token is invalid', async () => {
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
];
const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
mode: 'manual',
node: {
parameters: {
formTitle: 'Test Form',
formDescription: 'Test Description',
responseMode: 'onReceived',
formFields: { values: formFields },
authentication: 'basicAuth',
},
},
request: { method: 'POST', query: {}, headers: {} },
credential: {
user: 'testuser',
password: 'testpass',
},
});
expect(responseData).toEqual({ noWebhookResponse: true });
});
it('should validate POST requests with correct authentication token', async () => {
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
];
const nodeId = 'test-node-id';
const webhookId = 'test-webhook-id';
const credentials = { user: 'testuser', password: 'testpass' };
const token = crypto
.createHmac('sha256', `${credentials.user}:${credentials.password}`)
.update(`${nodeId}-${webhookId}`)
.digest('hex');
const bodyData = {
data: {
'field-0': 'John Doe',
'field-1': '30',
},
};
const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
mode: 'manual',
node: {
id: nodeId,
webhookId,
parameters: {
formTitle: 'Test Form',
formDescription: 'Test Description',
responseMode: 'onReceived',
formFields: { values: formFields },
authentication: 'basicAuth',
},
},
request: {
method: 'POST',
contentType: 'multipart/form-data',
headers: { 'content-type': 'multipart/form-data', 'x-auth-token': token },
},
bodyData,
credential: credentials,
});
expect(responseData).toEqual({
webhookResponse: { status: 200 },
workflowData: [
[
{
json: {
Name: 'John Doe',
Age: 30,
submittedAt: expect.any(String),
formMode: 'test',
},
},
],
],
});
});
it('should apply customCss property to form render', async () => {
const formFields = [{ fieldLabel: 'Name', fieldType: 'text', requiredField: true }];
const { response } = await testVersionedWebhookTriggerNode(FormTrigger, 2.2, {
mode: 'manual',
node: {
typeVersion: 2.2,
parameters: {
formTitle: 'Custom CSS Test',
formDescription: 'Testing custom CSS',
responseMode: 'onReceived',
authentication: 'none',
formFields: { values: formFields },
options: {
customCss: '.form-input { border-color: red; }',
},
},
},
});
expect(response.render).toHaveBeenCalledWith(
'form-trigger',
expect.objectContaining({
dangerousCustomCss: '.form-input { border-color: red; }',
}),
);
});
it('should handle files', async () => {
const formFields = [
{
fieldLabel: 'Resume',
fieldType: 'file',
requiredField: true,
acceptFileTypes: '.pdf,.doc',
multipleFiles: false,
},
{
fieldLabel: 'Attachments',
fieldType: 'file',
requiredField: true,
acceptFileTypes: '.pdf,.doc',
multipleFiles: true,
},
];
const bodyData = {
files: {
'field-0': {
originalFilename: 'resume.pdf',
mimetype: 'application/json',
filepath: '/resume.pdf',
size: 200,
},
'field-1': [
{
originalFilename: 'attachment1.pdf',
mimetype: 'application/json',
filepath: '/attachment1.pdf',
size: 201,
},
],
},
};
const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
mode: 'trigger',
node: {
parameters: {
formTitle: 'Test Form',
formDescription: 'Test Description',
responseMode: 'onReceived',
authentication: 'none',
formFields: { values: formFields },
},
},
request: {
method: 'POST',
headers: { 'content-type': 'multipart/form-data' },
contentType: 'multipart/form-data',
},
bodyData,
});
expect(responseData?.webhookResponse).toEqual({ status: 200 });
expect(responseData?.workflowData).toEqual([
[
expect.objectContaining({
json: {
Resume: {
filename: 'resume.pdf',
mimetype: 'application/json',
size: 200,
},
Attachments: [
{
filename: 'attachment1.pdf',
mimetype: 'application/json',
size: 201,
},
],
formMode: 'production',
submittedAt: expect.any(String),
},
}),
],
]);
});
});
@@ -0,0 +1,407 @@
import { type Response } from 'express';
import { type MockProxy, mock } from 'jest-mock-extended';
import { type INode, type IWebhookFunctions } from 'n8n-workflow';
import { binaryResponse, renderFormCompletion } from '../utils/formCompletionUtils';
import * as utils from '../utils/utils';
describe('formCompletionUtils', () => {
let mockWebhookFunctions: MockProxy<IWebhookFunctions>;
const mockNode: INode = mock<INode>({
id: 'test-node',
name: 'Test Node',
type: 'test',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
const nodeNameWithFileToDownload = 'prevNode0';
const nodeNameWithFile = 'prevNode2';
const parentNodesWithAndWithoutFiles = [
{
name: nodeNameWithFileToDownload,
type: '',
typeVersion: 0,
disabled: false,
},
{
name: 'prevNode1',
type: '',
typeVersion: 0,
disabled: false,
},
];
const parentNodesWithMultipleBinaryFiles = [
{
name: nodeNameWithFileToDownload,
type: '',
typeVersion: 0,
disabled: false,
},
{
name: nodeNameWithFile,
type: '',
typeVersion: 0,
disabled: false,
},
];
const parentNodesWithSingleNodeFile = [
{
name: nodeNameWithFileToDownload,
type: '',
typeVersion: 0,
disabled: false,
},
];
const parentNodesTestCases = [
parentNodesWithAndWithoutFiles,
parentNodesWithMultipleBinaryFiles,
parentNodesWithSingleNodeFile,
];
beforeEach(() => {
mockWebhookFunctions = mock<IWebhookFunctions>();
mockWebhookFunctions.getNode.mockReturnValue(mockNode);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('renderFormCompletion', () => {
const mockResponse: Response = mock<Response>({
send: jest.fn(),
render: jest.fn(),
});
const trigger = {
name: 'triggerNode',
type: 'trigger',
typeVersion: 1,
disabled: false,
};
afterEach(() => {
jest.resetAllMocks();
});
it('should render the form completion', async () => {
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: 'Form has been submitted successfully',
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseText: '',
title: 'Form Completion',
});
});
it('should call sanitizeHtml on completionMessage', async () => {
const sanitizeHtmlSpy = jest.spyOn(utils, 'sanitizeHtml');
const maliciousMessage = '<script>alert("xss")</script>Safe message<b>bold</b>';
const responseText = 'Response text';
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: maliciousMessage,
responseText,
options: { formTitle: 'Form Title' },
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(sanitizeHtmlSpy).toHaveBeenCalledWith(maliciousMessage);
expect(sanitizeHtmlSpy).toHaveBeenCalledTimes(1);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: 'Safe message<b>bold</b>',
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseText: 'Response text',
title: 'Form Completion',
dangerousCustomCss: undefined,
});
sanitizeHtmlSpy.mockRestore();
});
it.each([
['\\n', '\n'],
['\\\\n', '\\n'],
])('should replace %j with %j in completionMessage', async (pattern, replacement) => {
const completionMessage = `Some message${pattern}Other text`;
const responseText = 'Response text';
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage,
responseText,
options: { formTitle: 'Form Title' },
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: `Some message${replacement}Other text`,
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseText: 'Response text',
title: 'Form Completion',
dangerousCustomCss: undefined,
});
});
it('throw an error if no binary data with the field name is found', async () => {
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
respondWith: 'returnBinary',
inputDataFieldName: 'inputData',
};
return params[parameterName];
});
mockWebhookFunctions.getParentNodes.mockReturnValueOnce([]);
await expect(
renderFormCompletion(mockWebhookFunctions, mockResponse, trigger),
).rejects.toThrowError('No binary data with field inputData found.');
});
it('should render if respond with binary is set and binary mode is filesystem', async () => {
const expectedBinaryResponse = {
inputData: {
data: 'IyAxLiBHbyBpbiBwb3N0Z3',
fileExtension: 'txt',
fileName: 'file.txt',
fileSize: '458 B',
fileType: 'text',
mimeType: 'text/plain',
id: 555,
},
};
const buffer = Buffer.from(expectedBinaryResponse.inputData.data);
for (const parentNodes of parentNodesTestCases) {
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodes);
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) {
return expectedBinaryResponse;
} else if (arg === `{{ $('${nodeNameWithFile}').first().binary }}`) {
return { someData: {} };
} else {
return undefined;
}
});
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
inputDataFieldName: 'inputData',
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
respondWith: 'returnBinary',
};
return params[parameterName];
});
mockWebhookFunctions.helpers.getBinaryStream = jest
.fn()
.mockResolvedValue(Promise.resolve({}));
mockWebhookFunctions.helpers.binaryToBuffer = jest
.fn()
.mockResolvedValue(Promise.resolve(buffer));
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: 'Form has been submitted successfully',
redirectUrl: undefined,
responseBinary: encodeURIComponent(
JSON.stringify({
data: buffer,
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
}),
),
responseText: '',
title: 'Form Completion',
});
}
});
it('should render if respond with binary is set and binary mode is default', async () => {
const expectedBinaryResponse = {
inputData: {
data: 'IyAxLiBHbyBpbiBwb3N0Z3',
fileExtension: 'txt',
fileName: 'file.txt',
fileSize: '458 B',
fileType: 'text',
mimeType: 'text/plain',
},
};
for (const parentNodes of parentNodesTestCases) {
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodes);
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) {
return expectedBinaryResponse;
} else if (arg === `{{ $('${nodeNameWithFile}').first().binary }}`) {
return { someData: {} };
} else {
return undefined;
}
});
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
inputDataFieldName: 'inputData',
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
respondWith: 'returnBinary',
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: undefined,
formTitle: 'Form Title',
message: 'Form has been submitted successfully',
redirectUrl: undefined,
responseBinary: encodeURIComponent(
JSON.stringify({
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
}),
),
responseText: '',
title: 'Form Completion',
});
}
});
it('should set Content-Security-Policy header with sandbox CSP', async () => {
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.setHeader).toHaveBeenCalledWith(
'Content-Security-Policy',
'sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-presentation allow-scripts allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols',
);
expect(mockResponse.render).toHaveBeenCalled();
});
it('should NOT set Content-Security-Policy header when respondWith is redirect', async () => {
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
completionTitle: 'Form Completion',
completionMessage: 'Form has been submitted successfully',
options: { formTitle: 'Form Title' },
respondWith: 'redirect',
};
return params[parameterName];
});
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
expect(mockResponse.setHeader).not.toHaveBeenCalledWith(
'Content-Security-Policy',
expect.any(String),
);
expect(mockResponse.render).toHaveBeenCalled();
});
});
describe('binaryResponse', () => {
it('should get the latest binary data from the parent nodes', async () => {
const expectedBinaryResponse = {
inputData: {
data: 'IyAxLiBHbyBpbiBwb3N0Z3',
fileExtension: 'txt',
fileName: 'file.txt',
fileSize: '458 B',
fileType: 'text',
mimeType: 'text/plain',
},
};
const notExpectedBinaryResponse = {
inputData: {
data: 'notexpected',
fileExtension: 'txt',
fileName: 'file.txt',
fileSize: '458 B',
fileType: 'text',
mimeType: 'text/plain',
},
};
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
inputDataFieldName: 'inputData',
};
return params[parameterName];
});
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodesWithMultipleBinaryFiles);
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
if (arg === `{{ $('${nodeNameWithFile}').first().binary }}`) {
return expectedBinaryResponse;
} else {
return notExpectedBinaryResponse;
}
});
const result = await binaryResponse(mockWebhookFunctions);
expect(result).toEqual({
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
});
});
});
});
@@ -0,0 +1,301 @@
import { type Response } from 'express';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import {
type FormFieldsParameter,
type IWebhookFunctions,
type NodeTypeAndVersion,
NodeOperationError,
FORM_TRIGGER_NODE_TYPE,
} from 'n8n-workflow';
import { renderFormNode, getFormTriggerNode } from '../utils/formNodeUtils';
describe('formNodeUtils', () => {
let webhookFunctions: MockProxy<IWebhookFunctions>;
beforeEach(() => {
webhookFunctions = mock<IWebhookFunctions>();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should sanitize custom html', async () => {
webhookFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
webhookFunctions.getNodeParameter.calledWith('options').mockReturnValue({
formTitle: 'Test Title',
formDescription: 'Test Description',
buttonLabel: 'Test Button Label',
});
const mockRender = jest.fn();
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Custom HTML',
fieldType: 'html',
html: '<div>Test HTML</div>',
requiredField: false,
},
{
fieldLabel: 'Custom HTML',
fieldType: 'html',
html: '<script>Test HTML</script>',
requiredField: false,
},
{
fieldLabel: 'Custom HTML',
fieldType: 'html',
html: '<style>Test HTML</style>',
requiredField: false,
},
{
fieldLabel: 'Custom HTML',
fieldType: 'html',
html: '<style>Test HTML</style><div>hihihi</div><script>Malicious script here</script>',
requiredField: false,
},
];
webhookFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
const responseMock = mock<Response>({ render: mockRender } as any);
const triggerMock = mock<NodeTypeAndVersion>({ name: 'triggerName' } as any);
await renderFormNode(webhookFunctions, responseMock, triggerMock, formFields, 'test');
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
appendAttribution: true,
buttonLabel: 'Test Button Label',
formDescription: 'Test Description',
formDescriptionMetadata: 'Test Description',
formFields: [
{
defaultValue: '',
errorId: 'error-field-0',
html: '<div>Test HTML</div>',
id: 'field-0',
inputRequired: '',
isHtml: true,
label: 'Custom HTML',
placeholder: undefined,
},
{
defaultValue: '',
errorId: 'error-field-1',
html: '',
id: 'field-1',
inputRequired: '',
isHtml: true,
label: 'Custom HTML',
placeholder: undefined,
},
{
defaultValue: '',
errorId: 'error-field-2',
html: '',
id: 'field-2',
inputRequired: '',
isHtml: true,
label: 'Custom HTML',
placeholder: undefined,
},
{
defaultValue: '',
errorId: 'error-field-3',
html: '<div>hihihi</div>',
id: 'field-3',
inputRequired: '',
isHtml: true,
label: 'Custom HTML',
placeholder: undefined,
},
],
formSubmittedHeader: undefined,
formSubmittedText: 'Your response has been recorded',
formTitle: 'Test Title',
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
testRun: true,
useResponseData: true,
});
});
describe('getFormTriggerNode', () => {
const mockCurrentNode = { name: 'currentNode' };
beforeEach(() => {
webhookFunctions.getNode.mockReturnValue(mockCurrentNode as any);
});
it('should return the first executed form trigger node', () => {
const formTrigger1: NodeTypeAndVersion = {
name: 'FormTrigger1',
type: FORM_TRIGGER_NODE_TYPE,
typeVersion: 1,
disabled: false,
};
const formTrigger2: NodeTypeAndVersion = {
name: 'FormTrigger2',
type: FORM_TRIGGER_NODE_TYPE,
typeVersion: 1,
disabled: false,
};
const otherNode: NodeTypeAndVersion = {
name: 'OtherNode',
type: 'n8n-nodes-base.other',
typeVersion: 1,
disabled: false,
};
const parentNodes = [otherNode, formTrigger1, formTrigger2];
webhookFunctions.getParentNodes.mockReturnValue(parentNodes);
webhookFunctions.evaluateExpression
.calledWith(`{{ $('${formTrigger1.name}').first() }}`)
.mockReturnValue('success');
const result = getFormTriggerNode(webhookFunctions);
expect(result).toBe(formTrigger1);
expect(webhookFunctions.getParentNodes).toHaveBeenCalledWith('currentNode');
expect(webhookFunctions.evaluateExpression).toHaveBeenCalledWith(
`{{ $('${formTrigger1.name}').first() }}`,
);
});
it('should return the second form trigger if the first one fails evaluation', () => {
const formTrigger1: NodeTypeAndVersion = {
name: 'FormTrigger1',
type: FORM_TRIGGER_NODE_TYPE,
typeVersion: 1,
disabled: false,
};
const formTrigger2: NodeTypeAndVersion = {
name: 'FormTrigger2',
type: FORM_TRIGGER_NODE_TYPE,
typeVersion: 1,
disabled: false,
};
const parentNodes = [formTrigger1, formTrigger2];
webhookFunctions.getParentNodes.mockReturnValue(parentNodes);
webhookFunctions.evaluateExpression
.calledWith(`{{ $('${formTrigger1.name}').first() }}`)
.mockImplementation(() => {
throw new Error('Evaluation failed');
});
webhookFunctions.evaluateExpression
.calledWith(`{{ $('${formTrigger2.name}').first() }}`)
.mockReturnValue('success');
const result = getFormTriggerNode(webhookFunctions);
expect(result).toBe(formTrigger2);
expect(webhookFunctions.evaluateExpression).toHaveBeenCalledWith(
`{{ $('${formTrigger1.name}').first() }}`,
);
expect(webhookFunctions.evaluateExpression).toHaveBeenCalledWith(
`{{ $('${formTrigger2.name}').first() }}`,
);
});
it('should throw NodeOperationError when no form trigger nodes are found', () => {
const otherNode: NodeTypeAndVersion = {
name: 'OtherNode',
type: 'n8n-nodes-base.other',
typeVersion: 1,
disabled: false,
};
const parentNodes = [otherNode];
webhookFunctions.getParentNodes.mockReturnValue(parentNodes);
expect(() => getFormTriggerNode(webhookFunctions)).toThrow(NodeOperationError);
expect(() => getFormTriggerNode(webhookFunctions)).toThrow(
'Form Trigger node must be set before this node',
);
});
it('should throw NodeOperationError when form trigger nodes exist but none are executed', () => {
const formTrigger1: NodeTypeAndVersion = {
name: 'FormTrigger1',
type: FORM_TRIGGER_NODE_TYPE,
typeVersion: 1,
disabled: false,
};
const formTrigger2: NodeTypeAndVersion = {
name: 'FormTrigger2',
type: FORM_TRIGGER_NODE_TYPE,
typeVersion: 1,
disabled: false,
};
const parentNodes = [formTrigger1, formTrigger2];
webhookFunctions.getParentNodes.mockReturnValue(parentNodes);
webhookFunctions.evaluateExpression.mockImplementation(() => {
throw new Error('Evaluation failed');
});
expect(() => getFormTriggerNode(webhookFunctions)).toThrow(NodeOperationError);
expect(() => getFormTriggerNode(webhookFunctions)).toThrow(
'Form Trigger node was not executed',
);
expect(webhookFunctions.evaluateExpression).toHaveBeenCalledWith(
`{{ $('${formTrigger1.name}').first() }}`,
);
expect(webhookFunctions.evaluateExpression).toHaveBeenCalledWith(
`{{ $('${formTrigger2.name}').first() }}`,
);
});
it('should handle empty parent nodes array', () => {
webhookFunctions.getParentNodes.mockReturnValue([]);
expect(() => getFormTriggerNode(webhookFunctions)).toThrow(NodeOperationError);
expect(() => getFormTriggerNode(webhookFunctions)).toThrow(
'Form Trigger node must be set before this node',
);
});
it('should filter out non-form-trigger nodes correctly', () => {
const formTrigger: NodeTypeAndVersion = {
name: 'FormTrigger',
type: FORM_TRIGGER_NODE_TYPE,
typeVersion: 1,
disabled: false,
};
const webhookNode: NodeTypeAndVersion = {
name: 'WebhookNode',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
disabled: false,
};
const httpNode: NodeTypeAndVersion = {
name: 'HttpNode',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
disabled: false,
};
const parentNodes = [webhookNode, formTrigger, httpNode];
webhookFunctions.getParentNodes.mockReturnValue(parentNodes);
webhookFunctions.evaluateExpression
.calledWith(`{{ $('${formTrigger.name}').first() }}`)
.mockReturnValue('success');
const result = getFormTriggerNode(webhookFunctions);
expect(result).toBe(formTrigger);
expect(webhookFunctions.evaluateExpression).toHaveBeenCalledTimes(1);
expect(webhookFunctions.evaluateExpression).toHaveBeenCalledWith(
`{{ $('${formTrigger.name}').first() }}`,
);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
import { type Response } from 'express';
import {
type NodeTypeAndVersion,
type IWebhookFunctions,
type IWebhookResponseData,
type IBinaryData,
type IDataObject,
OperationalError,
} from 'n8n-workflow';
import { handleNewlines, sanitizeCustomCss, sanitizeHtml, validateSafeRedirectUrl } from './utils';
const SANDBOX_CSP =
'sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-presentation allow-scripts allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols';
const getBinaryDataFromNode = (context: IWebhookFunctions, nodeName: string): IDataObject => {
return context.evaluateExpression(`{{ $('${nodeName}').first().binary }}`) as IDataObject;
};
export const binaryResponse = async (
context: IWebhookFunctions,
): Promise<{ data: string | Buffer; fileName: string; type: string }> => {
const inputDataFieldName = context.getNodeParameter('inputDataFieldName', '') as string;
const parentNodes = context.getParentNodes(context.getNode().name);
const binaryNode = parentNodes
.reverse()
.find((node) => getBinaryDataFromNode(context, node?.name)?.hasOwnProperty(inputDataFieldName));
if (!binaryNode) {
throw new OperationalError(`No binary data with field ${inputDataFieldName} found.`);
}
const binaryData = getBinaryDataFromNode(context, binaryNode?.name)[
inputDataFieldName
] as IBinaryData;
return {
// If a binaryData has an id, the following field is set:
// N8N_DEFAULT_BINARY_DATA_MODE=filesystem
data: binaryData.id
? await context.helpers.binaryToBuffer(await context.helpers.getBinaryStream(binaryData.id))
: atob(binaryData.data),
fileName: binaryData.fileName ?? 'file',
type: binaryData.mimeType,
};
};
export const renderFormCompletion = async (
context: IWebhookFunctions,
res: Response,
trigger: NodeTypeAndVersion,
): Promise<IWebhookResponseData> => {
const completionTitle = context.getNodeParameter('completionTitle', '') as string;
const completionMessage = handleNewlines(
sanitizeHtml(context.getNodeParameter('completionMessage', '') as string),
);
const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
const options = context.getNodeParameter('options', {}) as {
formTitle: string;
customCss?: string;
};
const responseText = (context.getNodeParameter('responseText', '') as string) ?? '';
const respondWith = context.getNodeParameter('respondWith', '') as
| 'text'
| 'redirect'
| 'showText'
| 'returnBinary';
const binary = respondWith === 'returnBinary' ? await binaryResponse(context) : '';
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;
if (respondWith !== 'redirect') {
res.setHeader('Content-Security-Policy', SANDBOX_CSP);
}
res.render('form-trigger-completion', {
title: completionTitle,
message: completionMessage,
formTitle: title,
appendAttribution,
responseText,
responseBinary: encodeURIComponent(JSON.stringify(binary)),
dangerousCustomCss: sanitizeCustomCss(options.customCss),
redirectUrl: validateSafeRedirectUrl(redirectUrl) ?? undefined,
});
return { noWebhookResponse: true };
};
@@ -0,0 +1,97 @@
import { type Response } from 'express';
import {
type NodeTypeAndVersion,
type IWebhookFunctions,
type FormFieldsParameter,
type IWebhookResponseData,
NodeOperationError,
FORM_TRIGGER_NODE_TYPE,
} from 'n8n-workflow';
import { renderForm } from './utils';
export const renderFormNode = async (
context: IWebhookFunctions,
res: Response,
trigger: NodeTypeAndVersion,
fields: FormFieldsParameter,
mode: 'test' | 'production',
): Promise<IWebhookResponseData> => {
const options = context.getNodeParameter('options', {}) as {
formTitle: string;
formDescription: string;
buttonLabel: string;
customCss?: string;
};
let title = options.formTitle;
if (!title) {
title = context.evaluateExpression(`{{ $('${trigger?.name}').params.formTitle }}`) as string;
}
const description = options.formDescription ?? '';
let buttonLabel = options.buttonLabel;
if (!buttonLabel) {
buttonLabel =
(context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.buttonLabel }}`,
) as string) || 'Submit';
}
const appendAttribution = context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`,
) as boolean;
renderForm({
context,
res,
formTitle: title,
formDescription: description,
formFields: fields,
responseMode: 'responseNode',
mode,
redirectUrl: undefined,
appendAttribution,
buttonLabel,
customCss: options.customCss,
});
return {
noWebhookResponse: true,
};
};
/**
* Retrieves the active Form Trigger node from the workflow's parent nodes.
*
* This function searches through the parent nodes to find Form Trigger nodes,
* then determines which one has been executed.
*
* @returns The NodeTypeAndVersion object representing the active Form Trigger node
* @throws {NodeOperationError} When no Form Trigger node is found in parent nodes
* @throws {NodeOperationError} When Form Trigger node exists but was not executed
*/
export function getFormTriggerNode(context: IWebhookFunctions): NodeTypeAndVersion {
const parentNodes = context.getParentNodes(context.getNode().name);
const formTriggers = parentNodes.filter((node) => node.type === FORM_TRIGGER_NODE_TYPE);
if (!formTriggers.length) {
throw new NodeOperationError(
context.getNode(),
'Form Trigger node must be set before this node',
);
}
for (const trigger of formTriggers) {
try {
context.evaluateExpression(`{{ $('${trigger.name}').first() }}`);
} catch (error) {
continue;
}
return trigger;
}
throw new NodeOperationError(context.getNode(), 'Form Trigger node was not executed');
}
@@ -0,0 +1,772 @@
import type { Response } from 'express';
import { rm } from 'fs/promises';
import isbot from 'isbot';
import { DateTime } from 'luxon';
import { getWebhookSandboxCSP } from 'n8n-core';
import type {
INodeExecutionData,
MultiPartFormData,
IDataObject,
IWebhookFunctions,
FormFieldsParameter,
NodeTypeAndVersion,
} from 'n8n-workflow';
import {
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
NodeOperationError,
WAIT_NODE_TYPE,
WorkflowConfigurationError,
jsonParse,
tryToParseUrl,
BINARY_MODE_COMBINED,
tryToParseJsonToFormFields,
} from 'n8n-workflow';
import * as a from 'node:assert';
import sanitize from 'sanitize-html';
import { getResolvables } from '../../../utils/utilities';
import { WebhookAuthorizationError } from '../../Webhook/error';
import {
generateFormPostBasicAuthToken,
isIpAllowed,
validateWebhookAuthentication,
} from '../../Webhook/utils';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
import type { FormTriggerData, FormField } from '../interfaces';
export function sanitizeHtml(text: string) {
return sanitize(text, {
allowedTags: [
'b',
'div',
'i',
'iframe',
'img',
'video',
'source',
'em',
'strong',
'a',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'u',
'sub',
'sup',
'code',
'pre',
'span',
'br',
'ul',
'ol',
'li',
'p',
'table',
'thead',
'tbody',
'tfoot',
'td',
'tr',
'th',
'br',
],
allowedAttributes: {
a: ['href', 'target', 'rel'],
img: ['src', 'alt', 'width', 'height'],
video: ['controls', 'autoplay', 'loop', 'muted', 'poster', 'width', 'height'],
iframe: [
'src',
'width',
'height',
'frameborder',
'allow',
'allowfullscreen',
'referrerpolicy',
],
source: ['src', 'type'],
td: ['colspan', 'rowspan', 'scope', 'headers'],
th: ['colspan', 'rowspan', 'scope', 'headers'],
},
allowedSchemes: ['https', 'http'],
allowedSchemesByTag: {
source: ['https', 'http'],
iframe: ['https', 'http'],
},
allowProtocolRelative: false,
transformTags: {
iframe: sanitize.simpleTransform('iframe', {
sandbox: '',
referrerpolicy: 'strict-origin-when-cross-origin',
allow: 'fullscreen; autoplay; encrypted-media',
}),
},
});
}
/**
* Replaces `\n` strings with actual newline characters.
* Also replaces `\\n` strings with `\n` string
* @param text - The text to replace newlines in
* @returns Updated text
*/
export const handleNewlines = (text: string) => {
return text.replace(/\\n|\\\\n/g, (match) => (match === '\\\\n' ? '\\n' : '\n'));
};
export const prepareFormFields = (fields: FormFieldsParameter) => {
return fields.map((field) => {
if (field.fieldType === 'html' && field.html) {
field.html = sanitizeHtml(field.html);
}
if (field.fieldType === 'hiddenField') {
field.fieldLabel = field.fieldName as string;
}
return field;
});
};
export function sanitizeCustomCss(css: string | undefined): string | undefined {
if (!css) return undefined;
// Use sanitize-html with custom settings for CSS
return sanitize(css, {
allowedTags: [], // No HTML tags allowed
allowedAttributes: {}, // No attributes allowed
// Decode HTML entities that sanitize-html encodes, as they break CSS selectors like ">"
textFilter: (text) => text.replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&'),
});
}
/**
* Validates that a URL uses a safe scheme.
* Returns the normalized URL if valid, or null if invalid.
*/
export function validateSafeRedirectUrl(url: string | undefined): string | null {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
try {
return tryToParseUrl(trimmed);
} catch {
return null;
}
}
export function createDescriptionMetadata(description: string) {
return description === ''
? 'n8n form'
: description.replace(/^\s*\n+|<\/?[^>]+(>|$)/g, '').slice(0, 150);
}
/**
* Gets the field identifier to use based on node version.
* For v2.4+, uses fieldName as the primary identifier.
* For earlier versions, falls back to fieldLabel.
*/
function getFieldIdentifier(field: FormFieldsParameter[number], nodeVersion?: number): string {
if (nodeVersion && nodeVersion >= 2.4 && field.fieldName) {
return field.fieldName;
}
return field.fieldLabel ?? field.fieldName ?? '';
}
export function prepareFormData({
formTitle,
formDescription,
formSubmittedHeader,
formSubmittedText,
redirectUrl,
formFields,
testRun,
query,
instanceId,
useResponseData,
appendAttribution = true,
buttonLabel,
customCss,
nodeVersion,
authToken,
}: {
formTitle: string;
formDescription: string;
formSubmittedText: string | undefined;
redirectUrl: string | undefined;
formFields: FormFieldsParameter;
testRun: boolean;
query: IDataObject;
instanceId?: string;
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
formSubmittedHeader?: string;
customCss?: string;
nodeVersion?: number;
authToken?: string;
}) {
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`;
if (formSubmittedText === undefined) {
formSubmittedText = 'Your response has been recorded';
}
const formData: FormTriggerData = {
testRun,
formTitle,
formDescription,
formDescriptionMetadata: createDescriptionMetadata(formDescription),
formSubmittedHeader,
formSubmittedText,
n8nWebsiteLink,
formFields: [],
useResponseData,
appendAttribution,
buttonLabel,
dangerousCustomCss: sanitizeCustomCss(customCss),
authToken,
};
if (redirectUrl) {
const safeUrl = validateSafeRedirectUrl(redirectUrl);
if (safeUrl) {
formData.redirectUrl = safeUrl;
}
}
for (const [index, field] of formFields.entries()) {
const { fieldType, requiredField, multiselect, placeholder, defaultValue } = field;
const queryParam = getFieldIdentifier(field, nodeVersion);
const input: FormField = {
id: `field-${index}`,
errorId: `error-field-${index}`,
label: field.fieldLabel,
inputRequired: requiredField ? 'form-required' : '',
defaultValue: query[queryParam] ?? defaultValue ?? '',
placeholder,
};
if (multiselect || (fieldType && ['radio', 'checkbox'].includes(fieldType))) {
input.isMultiSelect = true;
input.multiSelectOptions =
field.fieldOptions?.values.map((e, i) => ({
id: `option${i}_${input.id}`,
label: e.option,
})) ?? [];
if (fieldType === 'radio') {
input.radioSelect = 'radio';
} else if (field.limitSelection === 'exact') {
input.exactSelectedOptions = field.numberOfSelections;
} else if (field.limitSelection === 'range') {
input.minSelectedOptions = field.minSelections;
input.maxSelectedOptions = field.maxSelections;
}
} else if (fieldType === 'file') {
input.isFileInput = true;
input.acceptFileTypes = field.acceptFileTypes;
input.multipleFiles = field.multipleFiles ? 'multiple' : '';
} else if (fieldType === 'dropdown') {
input.isSelect = true;
const fieldOptions = field.fieldOptions?.values ?? [];
input.selectOptions = fieldOptions.map((e) => e.option);
} else if (fieldType === 'textarea') {
input.isTextarea = true;
} else if (fieldType === 'html') {
input.isHtml = true;
input.html = field.html as string;
} else if (fieldType === 'hiddenField') {
input.isHidden = true;
input.hiddenName = field.fieldName as string;
input.hiddenValue =
input.defaultValue === '' ? (field.fieldValue as string) : input.defaultValue;
} else {
input.isInput = true;
input.type = fieldType as 'text' | 'number' | 'date' | 'email';
}
formData.formFields.push(input);
}
return formData;
}
export const validateResponseModeConfiguration = (context: IWebhookFunctions) => {
const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string;
const connectedNodes = context.getChildNodes(context.getNode().name);
const nodeVersion = context.getNode().typeVersion;
const isRespondToWebhookConnected = connectedNodes.some(
(node) => node.type === 'n8n-nodes-base.respondToWebhook',
);
if (!isRespondToWebhookConnected && responseMode === 'responseNode') {
throw new NodeOperationError(
context.getNode(),
new Error('No Respond to Webhook node found in the workflow'),
{
description:
'Insert a Respond to Webhook node to your workflow to respond to the form submission or choose another option for the “Respond When” parameter',
},
);
}
if (isRespondToWebhookConnected && responseMode !== 'responseNode' && nodeVersion <= 2.1) {
throw new WorkflowConfigurationError(
context.getNode(),
new Error('Unused Respond to Webhook node found in the workflow'),
{
description:
'Set the “Respond When” parameter to “Using Respond to Webhook Node” or remove the Respond to Webhook node',
},
);
}
if (isRespondToWebhookConnected && nodeVersion > 2.1) {
throw new NodeOperationError(
context.getNode(),
new Error(
'The "Respond to Webhook" node is not supported in workflows initiated by the "n8n Form Trigger"',
),
{
description:
'To configure your response, add an "n8n Form" node and set the "Page Type" to "Form Ending"',
},
);
}
};
export function addFormResponseDataToReturnItem(
returnItem: INodeExecutionData,
formFields: FormFieldsParameter,
bodyData: IDataObject,
nodeVersion?: number,
) {
for (const [index, field] of formFields.entries()) {
const key = `field-${index}`;
const name = getFieldIdentifier(field, nodeVersion);
let value = bodyData[key] ?? null;
if (value === null) {
returnItem.json[name] = null;
continue;
}
if (field.fieldType === 'html') {
if (field.elementName) {
returnItem.json[field.elementName] = value;
}
continue;
}
if (field.fieldType === 'number') {
value = Number(value);
}
if (field.fieldType === 'text') {
value = String(value).trim();
}
if (
(field.multiselect || field.fieldType === 'checkbox' || field.fieldType === 'radio') &&
typeof value === 'string'
) {
value = jsonParse(value);
if (field.fieldType === 'radio' && Array.isArray(value)) {
value = value[0];
}
}
if (field.fieldType === 'date' && value && field.formatDate) {
const datetime = DateTime.fromFormat(String(value), 'yyyy-mm-dd');
value = datetime.toFormat(field.formatDate as string);
}
if (field.fieldType === 'file' && field.multipleFiles && !Array.isArray(value)) {
value = [value];
}
returnItem.json[name] = value;
}
}
export async function prepareFormReturnItem(
context: IWebhookFunctions,
formFields: FormFieldsParameter,
mode: 'test' | 'production',
useWorkflowTimezone: boolean = false,
) {
const req = context.getRequestObject() as MultiPartFormData.Request;
a.ok(req.contentType === 'multipart/form-data', 'Expected multipart/form-data');
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {};
const { binaryMode } = context.getWorkflowSettings();
const returnItem: INodeExecutionData = {
json: {},
};
if (files && Object.keys(files).length) {
returnItem.binary = {};
}
for (const key of Object.keys(files)) {
const processFiles: MultiPartFormData.File[] = [];
let multiFile = false;
const filesInput = files[key] as MultiPartFormData.File[] | MultiPartFormData.File;
if (Array.isArray(filesInput)) {
bodyData[key] =
binaryMode === BINARY_MODE_COMBINED
? []
: filesInput.map((file) => ({
filename: file.originalFilename,
mimetype: file.mimetype,
size: file.size,
}));
processFiles.push(...filesInput);
multiFile = true;
} else {
bodyData[key] =
binaryMode === BINARY_MODE_COMBINED
? {}
: {
filename: filesInput.originalFilename,
mimetype: filesInput.mimetype,
size: filesInput.size,
};
processFiles.push(filesInput);
}
const entryIndex = Number(key.replace(/field-/g, ''));
const field = isNaN(entryIndex) ? null : formFields[entryIndex];
const fieldLabel = field ? getFieldIdentifier(field, context.getNode().typeVersion) : key;
let fileCount = 0;
for (const file of processFiles) {
const binaryData = await context.nodeHelpers.copyBinaryFile(
file.filepath,
file.originalFilename ?? file.newFilename,
file.mimetype,
);
if (binaryMode === BINARY_MODE_COMBINED) {
if (Array.isArray(bodyData[key])) {
(bodyData[key] as IDataObject[]).push(binaryData);
} else {
bodyData[key] = binaryData;
}
} else {
let binaryPropertyName = fieldLabel.replace(/\W/g, '_');
if (multiFile) {
binaryPropertyName += `_${fileCount++}`;
}
returnItem.binary![binaryPropertyName] = binaryData;
}
// Delete original file to prevent tmp directory from growing too large
await rm(file.filepath, { force: true });
}
}
addFormResponseDataToReturnItem(returnItem, formFields, bodyData, context.getNode().typeVersion);
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
returnItem.json.formMode = mode;
if (
context.getNode().type === FORM_TRIGGER_NODE_TYPE &&
Object.keys(context.getRequestObject().query || {}).length
) {
returnItem.json.formQueryParameters = context.getRequestObject().query;
}
return returnItem;
}
export function renderForm({
context,
res,
formTitle,
formDescription,
formFields,
responseMode,
mode,
formSubmittedText,
redirectUrl,
appendAttribution,
buttonLabel,
customCss,
authToken,
}: {
context: IWebhookFunctions;
res: Response;
formTitle: string;
formDescription: string;
formFields: FormFieldsParameter;
responseMode: string;
mode: 'test' | 'production';
formSubmittedText?: string;
redirectUrl?: string;
appendAttribution?: boolean;
buttonLabel?: string;
customCss?: string;
authToken?: string;
}) {
const instanceId = context.getInstanceId();
const useResponseData = responseMode === 'responseNode';
let query: IDataObject = {};
if (context.getNode().type === FORM_TRIGGER_NODE_TYPE) {
query = context.getRequestObject().query as IDataObject;
} 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) {}
}
formFields = prepareFormFields(formFields);
const data = prepareFormData({
formTitle,
formDescription,
formSubmittedText,
redirectUrl,
formFields,
testRun: mode === 'test',
query,
instanceId,
useResponseData,
appendAttribution,
buttonLabel,
customCss,
nodeVersion: context.getNode().typeVersion,
authToken,
});
res.setHeader('Content-Security-Policy', getWebhookSandboxCSP());
res.render('form-trigger', data);
}
export const isFormConnected = (nodes: NodeTypeAndVersion[]) => {
return nodes.some(
(n) =>
n.type === FORM_NODE_TYPE || (n.type === WAIT_NODE_TYPE && n.parameters?.resume === 'form'),
);
};
export async function formWebhook(
context: IWebhookFunctions,
authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY,
) {
const node = context.getNode();
const options = context.getNodeParameter('options', {}) as {
ignoreBots?: boolean;
ipWhitelist?: string;
respondWithOptions?: {
values: {
respondWith: 'text' | 'redirect';
formSubmittedText: string;
redirectUrl: string;
};
};
formSubmittedText?: string;
useWorkflowTimezone?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
customCss?: string;
};
const res = context.getResponseObject();
const req = context.getRequestObject();
// Check IP allowlist first (before bot detection and authentication)
if (!isIpAllowed(options.ipWhitelist, req.ips, req.ip)) {
res.writeHead(403);
res.end('IP is not allowed to access this form!');
return { noWebhookResponse: true };
}
try {
if (options.ignoreBots && isbot(req.headers['user-agent'])) {
throw new WebhookAuthorizationError(403);
}
if (node.typeVersion > 1) {
await validateWebhookAuthentication(context, authProperty);
}
} catch (error) {
if (error instanceof WebhookAuthorizationError) {
res.setHeader('WWW-Authenticate', 'Basic realm="Enter credentials"');
res.status(401).send();
return { noWebhookResponse: true };
}
throw error;
}
const mode = context.getMode() === 'manual' ? 'test' : 'production';
const formFields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter;
const method = context.getRequestObject().method;
validateResponseModeConfiguration(context);
//Show the form on GET request
if (method === 'GET') {
const formTitle = context.getNodeParameter('formTitle', '') as string;
const formDescription = handleNewlines(
sanitizeHtml(context.getNodeParameter('formDescription', '') as string),
);
let 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;
}
let buttonLabel = 'Submit';
if (options.buttonLabel) {
buttonLabel = options.buttonLabel;
}
const connectedNodes = context.getChildNodes(context.getNode().name, {
includeNodeParameters: true,
});
const hasNextPage = isFormConnected(connectedNodes);
if (hasNextPage) {
redirectUrl = undefined;
responseMode = 'responseNode';
}
let authToken: string | undefined;
if (node.typeVersion > 1) {
authToken = await generateFormPostBasicAuthToken(context, authProperty);
}
renderForm({
context,
res,
formTitle,
formDescription,
formFields,
responseMode,
mode,
formSubmittedText,
redirectUrl,
appendAttribution,
buttonLabel,
customCss: options.customCss,
authToken,
});
return {
noWebhookResponse: true,
};
}
let { useWorkflowTimezone } = options;
if (useWorkflowTimezone === undefined && node.typeVersion > 2) {
useWorkflowTimezone = true;
}
const returnItem = await prepareFormReturnItem(context, formFields, mode, useWorkflowTimezone);
return {
webhookResponse: { status: 200 },
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;
}
type ParseFormFieldsOptions = {
defineForm: 'json' | 'fields';
fieldsParameterName: string;
mode?: 'test' | 'production';
};
export function parseFormFields(context: IWebhookFunctions, options: ParseFormFieldsOptions) {
let fields: FormFieldsParameter = [];
if (options.defineForm === 'json') {
try {
const jsonOutput = context.getNodeParameter(options.fieldsParameterName, '', {
rawExpressions: true,
}) as string;
fields = tryToParseJsonToFormFields(resolveRawData(context, jsonOutput));
} catch (error) {
throw new NodeOperationError(context.getNode(), error.message, {
description: error.message,
type: options.mode === 'test' ? 'manual-form-test' : undefined,
});
}
} else {
fields = context.getNodeParameter(options.fieldsParameterName, []) as FormFieldsParameter;
for (const field of fields) {
if (field.fieldType === 'html') {
let html = field.html ?? '';
for (const resolvable of getResolvables(html)) {
html = html.replace(resolvable, context.evaluateExpression(resolvable) as string);
}
field.html = html;
}
}
}
return fields;
}
@@ -0,0 +1,100 @@
import {
FORM_TRIGGER_PATH_IDENTIFIER,
NodeConnectionTypes,
type INodeType,
type INodeTypeBaseDescription,
type INodeTypeDescription,
type IWebhookFunctions,
} from 'n8n-workflow';
import {
formDescription,
formFields,
formRespondMode,
formTitle,
formTriggerPanel,
webhookPath,
ipAllowlist,
} from '../common.descriptions';
import { formWebhook } from '../utils/utils';
const descriptionV1: INodeTypeDescription = {
displayName: 'n8n Form Trigger',
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
version: 1,
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'n8n Form Trigger',
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
webhooks: [
{
name: 'setup',
httpMethod: 'GET',
responseMode: 'onReceived',
isFullPath: true,
path: `={{$parameter["path"]}}/${FORM_TRIGGER_PATH_IDENTIFIER}`,
ndvHideUrl: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: '={{$parameter["responseMode"]}}',
responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}',
isFullPath: true,
path: `={{$parameter["path"]}}/${FORM_TRIGGER_PATH_IDENTIFIER}`,
ndvHideMethod: true,
},
],
eventTriggerDescription: 'Waiting for you to submit the form',
activationMessage: 'You can now make calls to your production Form URL.',
triggerPanel: formTriggerPanel,
properties: [
webhookPath,
formTitle,
formDescription,
formFields,
formRespondMode,
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
hide: {
responseMode: ['responseNode'],
},
},
options: [
ipAllowlist,
{
displayName: 'Form Submitted Text',
name: 'formSubmittedText',
description: 'The text displayed to users after they filled the form',
type: 'string',
default: 'Your response has been recorded',
},
],
},
],
};
export class FormTriggerV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...descriptionV1,
};
}
async webhook(this: IWebhookFunctions) {
return await formWebhook(this);
}
}
@@ -0,0 +1,232 @@
import {
ADD_FORM_NOTICE,
type INodePropertyOptions,
NodeConnectionTypes,
type INodeProperties,
type INodeType,
type INodeTypeBaseDescription,
type INodeTypeDescription,
type IWebhookFunctions,
} from 'n8n-workflow';
import {
appendAttributionToForm,
formDescription,
formFields,
formFieldsDynamic,
formRespondMode,
formTitle,
formTriggerPanel,
ipAllowlist,
respondWithOptions,
webhookPath,
} from '../common.descriptions';
import { cssVariables } from '../cssVariables';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
import { formWebhook } from '../utils/utils';
const useWorkflowTimezone: INodeProperties = {
displayName: 'Use Workflow Timezone',
name: 'useWorkflowTimezone',
type: 'boolean',
default: false,
description: "Whether to use the workflow timezone set in node's settings rather than UTC",
};
const descriptionV2: INodeTypeDescription = {
displayName: 'n8n Form Trigger',
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
// since trigger and node are sharing descriptions and logic we need to sync the versions
// and keep them aligned in both nodes
version: [2, 2.1, 2.2, 2.3, 2.4, 2.5],
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'On form submission',
},
builderHint: {
relatedNodes: [
{
nodeType: 'n8n-nodes-base.form',
relationHint: 'Add pages and final page to the form',
},
],
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
webhooks: [
{
name: 'setup',
httpMethod: 'GET',
responseMode: 'onReceived',
isFullPath: true,
path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}',
ndvHideUrl: true,
nodeType: 'form',
},
{
name: 'default',
httpMethod: 'POST',
responseMode: '={{$parameter["responseMode"]}}',
responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}',
isFullPath: true,
path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}',
ndvHideMethod: true,
nodeType: 'form',
},
],
eventTriggerDescription: 'Waiting for you to submit the form',
activationMessage: 'You can now make calls to your production Form URL.',
triggerPanel: formTriggerPanel,
credentials: [
{
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
name: 'httpBasicAuth',
required: true,
displayOptions: {
show: {
[FORM_TRIGGER_AUTHENTICATION_PROPERTY]: ['basicAuth'],
},
},
},
],
properties: [
{
displayName: 'Authentication',
name: FORM_TRIGGER_AUTHENTICATION_PROPERTY,
type: 'options',
options: [
{
name: 'Basic Auth',
value: 'basicAuth',
},
{
name: 'None',
value: 'none',
},
],
default: 'none',
},
{ ...webhookPath, displayOptions: { show: { '@version': [{ _cnd: { lte: 2.1 } }] } } },
formTitle,
formDescription,
{ ...formFields, displayOptions: { show: { '@version': [{ _cnd: { lt: 2.5 } }] } } },
{ ...formFieldsDynamic, displayOptions: { show: { '@version': [{ _cnd: { gte: 2.5 } }] } } },
{ ...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 <strong>formSubmittedText</strong> key to display a custom response in the form, or the <strong>redirectURL</strong> key to redirect users to a URL",
name: 'formNotice',
type: 'notice',
displayOptions: {
show: { responseMode: ['responseNode'] },
},
default: '',
},
// notice would be shown if no Form node was connected to trigger
{
displayName: 'Build multi-step forms by adding a form page later in your workflow',
name: ADD_FORM_NOTICE,
type: 'notice',
default: '',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
appendAttributionToForm,
ipAllowlist,
{
displayName: 'Button Label',
description: 'The label of the submit button in the form',
name: 'buttonLabel',
type: 'string',
default: 'Submit',
},
{
...webhookPath,
required: false,
displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } },
},
{
...respondWithOptions,
displayOptions: {
hide: {
'/responseMode': ['responseNode'],
},
},
},
{
displayName: 'Ignore Bots',
name: 'ignoreBots',
type: 'boolean',
default: false,
description: 'Whether to ignore requests from bots like link previewers and web crawlers',
},
{
...useWorkflowTimezone,
default: false,
description: "Whether to use the workflow timezone in 'submittedAt' field or UTC",
displayOptions: {
show: {
'@version': [2],
},
},
},
{
...useWorkflowTimezone,
default: true,
description: "Whether to use the workflow timezone in 'submittedAt' field or UTC",
displayOptions: {
show: {
'@version': [{ _cnd: { gt: 2 } }],
},
},
},
{
displayName: 'Custom Form Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
displayOptions: {
show: {
'@version': [{ _cnd: { gt: 2 } }],
},
},
default: cssVariables.trim(),
description: 'Override default styling of the public form interface with CSS',
},
],
},
],
};
export class FormTriggerV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...descriptionV2,
};
}
async webhook(this: IWebhookFunctions) {
return await formWebhook(this);
}
}