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
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:
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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><script></code>, <code><style></code> or <code><input></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><script></code>, <code><style></code> or <code><input></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;
|
||||
}
|
||||
`;
|
||||
@@ -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(/>/g, '>').replace(/</g, '<').replace(/&/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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user