feat(n8n Form Page Node): New node (#10390)

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
Michael Kret
2024-10-17 16:59:53 +03:00
committed by GitHub
parent 86a94b5523
commit 643d66c0ae
39 changed files with 2101 additions and 229 deletions

View File

@@ -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"]
}
}

View File

@@ -0,0 +1,422 @@
import type {
FormFieldsParameter,
IExecuteFunctions,
INodeExecutionData,
INodeTypeDescription,
IWebhookFunctions,
NodeTypeAndVersion,
} from 'n8n-workflow';
import {
WAIT_TIME_UNLIMITED,
Node,
updateDisplayOptions,
NodeOperationError,
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
tryToParseJsonToFormFields,
NodeConnectionType,
WAIT_NODE_TYPE,
} from 'n8n-workflow';
import { formDescription, formFields, formTitle } from '../Form/common.descriptions';
import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils';
const pageProperties = updateDisplayOptions(
{
show: {
operation: ['page'],
},
},
[
{
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 you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]',
validateType: 'form-fields',
ignoreValidationDuringExecution: true,
hint: '<a href="hhttps://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: { defineForm: ['fields'] } } },
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{ ...formTitle, required: false },
formDescription,
{
displayName: 'Button Label',
name: 'buttonLabel',
type: 'string',
default: 'Submit',
},
],
},
],
);
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',
},
],
},
{
displayName: 'URL',
name: 'redirectUrl',
validateType: 'url',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
respondWith: ['redirect'],
},
},
},
{
displayName: 'Completion Title',
name: 'completionTitle',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
respondWith: ['text'],
},
},
},
{
displayName: 'Completion Message',
name: 'completionMessage',
type: 'string',
default: '',
typeOptions: {
rows: 2,
},
displayOptions: {
show: {
respondWith: ['text'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [{ ...formTitle, required: false, displayName: 'Completion Page Title' }],
displayOptions: {
show: {
respondWith: ['text'],
},
},
},
],
);
export class Form extends Node {
nodeInputData: INodeExecutionData[] = [];
description: INodeTypeDescription = {
displayName: 'n8n Form',
name: 'form',
icon: 'file:form.svg',
group: ['input'],
version: 1,
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'Form',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
webhooks: [
{
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
path: '',
restartWebhook: true,
isFullPath: true,
isForm: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: '',
restartWebhook: true,
isFullPath: true,
isForm: true,
},
],
properties: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
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) {
const res = context.getResponseObject();
const operation = context.getNodeParameter('operation', '') as string;
const parentNodes = context.getParentNodes(context.getNode().name);
const trigger = parentNodes.find(
(node) => node.type === 'n8n-nodes-base.formTrigger',
) as NodeTypeAndVersion;
const mode = context.evaluateExpression(`{{ $('${trigger?.name}').first().json.formMode }}`) as
| 'test'
| 'production';
const defineForm = context.getNodeParameter('defineForm', false) as string;
let fields: FormFieldsParameter = [];
if (defineForm === 'json') {
try {
const jsonOutput = context.getNodeParameter('jsonOutput', '', {
rawExpressions: true,
}) as string;
fields = tryToParseJsonToFormFields(resolveRawData(context, jsonOutput));
} catch (error) {
throw new NodeOperationError(context.getNode(), error.message, {
description: error.message,
type: mode === 'test' ? 'manual-form-test' : undefined,
});
}
} else {
fields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter;
}
const method = context.getRequestObject().method;
if (operation === 'completion') {
const respondWith = context.getNodeParameter('respondWith', '') as string;
if (respondWith === 'redirect') {
const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
res.redirect(redirectUrl);
return {
noWebhookResponse: true,
};
}
const completionTitle = context.getNodeParameter('completionTitle', '') as string;
const completionMessage = context.getNodeParameter('completionMessage', '') as string;
const options = context.getNodeParameter('options', {}) as {
formTitle: string;
};
let title = options.formTitle;
if (!title) {
title = context.evaluateExpression(
`{{ $('${trigger?.name}').params.formTitle }}`,
) as string;
}
const appendAttribution = context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`,
) as boolean;
res.render('form-trigger-completion', {
title: completionTitle,
message: completionMessage,
formTitle: title,
appendAttribution,
});
return {
noWebhookResponse: true,
};
}
if (method === 'GET') {
const options = context.getNodeParameter('options', {}) as {
formTitle: string;
formDescription: string;
buttonLabel: string;
};
let title = options.formTitle;
if (!title) {
title = context.evaluateExpression(
`{{ $('${trigger?.name}').params.formTitle }}`,
) as string;
}
let description = options.formDescription;
if (!description) {
description = context.evaluateExpression(
`{{ $('${trigger?.name}').params.formDescription }}`,
) as string;
}
let buttonLabel = options.buttonLabel;
if (!buttonLabel) {
buttonLabel =
(context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.buttonLabel }}`,
) as string) || 'Submit';
}
const responseMode = 'onReceived';
let redirectUrl;
const connectedNodes = context.getChildNodes(context.getNode().name);
const hasNextPage = connectedNodes.some(
(node) => node.type === FORM_NODE_TYPE || node.type === WAIT_NODE_TYPE,
);
if (hasNextPage) {
redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string;
}
const appendAttribution = context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`,
) as boolean;
renderForm({
context,
res,
formTitle: title,
formDescription: description,
formFields: fields,
responseMode,
mode,
redirectUrl,
appendAttribution,
buttonLabel,
});
return {
noWebhookResponse: true,
};
}
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',
);
}
if (operation !== 'completion') {
const waitTill = new Date(WAIT_TIME_UNLIMITED);
await context.putExecutionToWait(waitTill);
}
return [context.getInputData()];
}
}

View File

@@ -10,14 +10,15 @@ export class FormTrigger extends VersionedNodeType {
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
description: 'Runs the flow when an n8n generated webform is submitted',
defaultVersion: 2.1,
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaultVersion: 2.2,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new FormTriggerV1(baseDescription),
2: new FormTriggerV2(baseDescription),
2.1: new FormTriggerV2(baseDescription),
2.2: new FormTriggerV2(baseDescription),
};
super(nodeVersions, baseDescription);

View File

@@ -1,15 +1,3 @@
export type FormField = {
fieldLabel: string;
fieldType: string;
requiredField: boolean;
fieldOptions?: { values: Array<{ option: string }> };
multiselect?: boolean;
multipleFiles?: boolean;
acceptFileTypes?: string;
formatDate?: string;
placeholder?: string;
};
export type FormTriggerInput = {
isSelect?: boolean;
isMultiSelect?: boolean;
@@ -40,6 +28,7 @@ export type FormTriggerData = {
formFields: FormTriggerInput[];
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
};
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';

View File

@@ -0,0 +1,190 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type {
IExecuteFunctions,
INode,
INodeExecutionData,
IWebhookFunctions,
NodeTypeAndVersion,
} from 'n8n-workflow';
import type { Response, Request } from 'express';
import { Form } from '../Form.node';
describe('Form Node', () => {
let form: Form;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockWebhookFunctions: MockProxy<IWebhookFunctions>;
beforeEach(() => {
form = new Form();
mockExecuteFunctions = mock<IExecuteFunctions>();
mockWebhookFunctions = mock<IWebhookFunctions>();
});
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>());
const result = await form.execute(mockExecuteFunctions);
expect(result).toEqual([inputData]);
});
});
describe('webhook method', () => {
it('should render form for GET request', async () => {
const mockResponseObject = {
render: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getParentNodes.mockReturnValue([
{ type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 },
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
mockWebhookFunctions.getNode.mockReturnValue(mock<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.getChildNodes.mockReturnValue([]);
await form.webhook(mockWebhookFunctions);
expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger', expect.any(Object));
});
it('should return form data for POST request', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as Request);
mockWebhookFunctions.getParentNodes.mockReturnValue([
{ type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 },
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
mockWebhookFunctions.getNode.mockReturnValue(mock<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', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
if (paramName === 'operation') return 'completion';
if (paramName === 'useJson') return false;
if (paramName === 'jsonOutput') return '[]';
if (paramName === 'respondWith') return 'text';
if (paramName === 'completionTitle') return 'Test Title';
if (paramName === 'completionMessage') return 'Test Message';
return {};
});
mockWebhookFunctions.getParentNodes.mockReturnValue([
{ type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 },
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
const mockResponseObject = {
render: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.render).toHaveBeenCalledWith(
'form-trigger-completion',
expect.any(Object),
);
});
});
});

View File

@@ -4,7 +4,6 @@ import { NodeOperationError, type INode } from 'n8n-workflow';
import { testVersionedWebhookTriggerNode } from '@test/nodes/TriggerHelpers';
import { FormTrigger } from '../FormTrigger.node';
import type { FormField } from '../interfaces';
describe('FormTrigger', () => {
beforeEach(() => {
@@ -12,7 +11,7 @@ describe('FormTrigger', () => {
});
it('should render a form template with correct fields', async () => {
const formFields: FormField[] = [
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
{ fieldLabel: 'Notes', fieldType: 'textarea', requiredField: false },
@@ -49,6 +48,7 @@ describe('FormTrigger', () => {
expect(response.render).toHaveBeenCalledWith('form-trigger', {
appendAttribution: false,
buttonLabel: 'Submit',
formDescription: 'Test Description',
formFields: [
{
@@ -115,7 +115,7 @@ describe('FormTrigger', () => {
});
it('should return workflowData on POST request', async () => {
const formFields: FormField[] = [
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
{ fieldLabel: 'Date', fieldType: 'date', formatDate: 'dd MMM', requiredField: false },
@@ -205,13 +205,13 @@ describe('FormTrigger', () => {
],
}),
).rejects.toEqual(
new NodeOperationError(mock<INode>(), 'n8n Form Trigger node not correctly configured'),
new NodeOperationError(mock<INode>(), 'On form submission node not correctly configured'),
);
});
});
it('should throw on invalid webhook authentication', async () => {
const formFields: FormField[] = [
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
];
@@ -239,7 +239,7 @@ describe('FormTrigger', () => {
});
it('should handle files', async () => {
const formFields: FormField[] = [
const formFields = [
{
fieldLabel: 'Resume',
fieldType: 'file',

View File

@@ -1,9 +1,163 @@
import type { FormField } from '../interfaces';
import { prepareFormData } from '../utils';
import { mock } from 'jest-mock-extended';
import type {
FormFieldsParameter,
INode,
IWebhookFunctions,
MultiPartFormData,
} from 'n8n-workflow';
import { DateTime } from 'luxon';
import { formWebhook, prepareFormData, prepareFormReturnItem, resolveRawData } from '../utils';
describe('FormTrigger, formWebhook', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call response render', async () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockRender = jest.fn();
const formFields: FormFieldsParameter = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', 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,
},
];
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({});
executeFunctions.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form');
executeFunctions.getNodeParameter
.calledWith('formDescription')
.mockReturnValue('Test Description');
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any);
executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');
executeFunctions.getBodyData.mockReturnValue({ data: {}, files: {} });
executeFunctions.getChildNodes.mockReturnValue([]);
await formWebhook(executeFunctions);
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
appendAttribution: true,
buttonLabel: 'Submit',
formDescription: '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: 'form-required',
isInput: true,
label: 'Gender',
placeholder: undefined,
type: 'select',
},
{
acceptFileTypes: '.pdf,.doc',
defaultValue: '',
errorId: 'error-field-3',
id: 'field-3',
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,
validForm: true,
});
});
it('should return workflowData on POST request', async () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockStatus = jest.fn();
const mockEnd = jest.fn();
const formFields: FormFieldsParameter = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
];
const bodyData = {
'field-0': 'John Doe',
'field-1': '30',
};
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({});
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
executeFunctions.getChildNodes.mockReturnValue([]);
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
executeFunctions.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any);
executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');
executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} });
const result = await formWebhook(executeFunctions);
expect(result).toEqual({
webhookResponse: { status: 200 },
workflowData: [
[
{
json: {
Name: 'John Doe',
Age: 30,
submittedAt: expect.any(String),
formMode: 'test',
},
},
],
],
});
});
});
describe('FormTrigger, prepareFormData', () => {
it('should return valid form data with given parameters', () => {
const formFields: FormField[] = [
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Name',
fieldType: 'text',
@@ -43,6 +197,7 @@ describe('FormTrigger, prepareFormData', () => {
query,
instanceId: 'test-instance',
useResponseData: true,
buttonLabel: 'Submit',
});
expect(result).toEqual({
@@ -98,12 +253,13 @@ describe('FormTrigger, prepareFormData', () => {
],
useResponseData: true,
appendAttribution: true,
buttonLabel: 'Submit',
redirectUrl: 'https://example.com/thank-you',
});
});
it('should handle missing optional fields gracefully', () => {
const formFields: FormField[] = [
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Name',
fieldType: 'text',
@@ -120,6 +276,7 @@ describe('FormTrigger, prepareFormData', () => {
formFields,
testRun: true,
query: {},
buttonLabel: 'Submit',
});
expect(result).toEqual({
@@ -143,11 +300,12 @@ describe('FormTrigger, prepareFormData', () => {
],
useResponseData: undefined,
appendAttribution: true,
buttonLabel: 'Submit',
});
});
it('should set redirectUrl with http if protocol is missing', () => {
const formFields: FormField[] = [
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Name',
fieldType: 'text',
@@ -187,7 +345,7 @@ describe('FormTrigger, prepareFormData', () => {
});
it('should correctly handle multiselect fields', () => {
const formFields: FormField[] = [
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Favorite Colors',
fieldType: 'text',
@@ -217,7 +375,7 @@ describe('FormTrigger, prepareFormData', () => {
]);
});
it('should correctly handle multiselect fields with unique ids', () => {
const formFields: FormField[] = [
const formFields = [
{
fieldLabel: 'Favorite Colors',
fieldType: 'text',
@@ -259,3 +417,306 @@ describe('FormTrigger, prepareFormData', () => {
]);
});
});
jest.mock('luxon', () => ({
DateTime: {
fromFormat: jest.fn().mockReturnValue({
toFormat: jest.fn().mockReturnValue('formatted-date'),
}),
now: jest.fn().mockReturnValue({
setZone: jest.fn().mockReturnValue({
toISO: jest.fn().mockReturnValue('2023-04-01T12:00:00.000Z'),
}),
}),
},
}));
describe('prepareFormReturnItem', () => {
const mockContext = mock<IWebhookFunctions>({
nodeHelpers: mock({
copyBinaryFile: jest.fn().mockResolvedValue({}),
}),
});
const formNode = mock<INode>({ type: 'n8n-nodes-base.formTrigger' });
beforeEach(() => {
jest.clearAllMocks();
mockContext.getBodyData.mockReturnValue({ data: {}, files: {} });
mockContext.getTimezone.mockReturnValue('UTC');
mockContext.getNode.mockReturnValue(formNode);
mockContext.getWorkflowStaticData.mockReturnValue({});
});
it('should handle empty form submission', async () => {
const result = await prepareFormReturnItem(mockContext, [], 'test');
expect(result).toEqual({
json: {
submittedAt: '2023-04-01T12:00:00.000Z',
formMode: 'test',
},
});
});
it('should process text fields correctly', async () => {
mockContext.getBodyData.mockReturnValue({
data: { 'field-0': ' test value ' },
files: {},
});
const formFields = [{ fieldLabel: 'Text Field', fieldType: 'text' }];
const result = await prepareFormReturnItem(mockContext, formFields, 'production');
expect(result.json['Text Field']).toBe('test value');
expect(result.json.formMode).toBe('production');
});
it('should process number fields correctly', async () => {
mockContext.getBodyData.mockReturnValue({
data: { 'field-0': '42' },
files: {},
});
const formFields = [{ fieldLabel: 'Number Field', fieldType: 'number' }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json['Number Field']).toBe(42);
});
it('should handle file uploads', async () => {
const mockFile: Partial<MultiPartFormData.File> = {
filepath: '/tmp/uploaded-file',
originalFilename: 'test.txt',
mimetype: 'text/plain',
size: 1024,
};
mockContext.getBodyData.mockReturnValue({
data: {},
files: { 'field-0': mockFile },
});
const formFields = [{ fieldLabel: 'File Upload', fieldType: 'file' }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json['File Upload']).toEqual({
filename: 'test.txt',
mimetype: 'text/plain',
size: 1024,
});
expect(result.binary).toBeDefined();
expect(result.binary!.File_Upload).toEqual({});
});
it('should handle multiple file uploads', async () => {
const mockFiles: Array<Partial<MultiPartFormData.File>> = [
{ filepath: '/tmp/file1', originalFilename: 'file1.txt', mimetype: 'text/plain', size: 1024 },
{ filepath: '/tmp/file2', originalFilename: 'file2.txt', mimetype: 'text/plain', size: 2048 },
];
mockContext.getBodyData.mockReturnValue({
data: {},
files: { 'field-0': mockFiles },
});
const formFields = [{ fieldLabel: 'Multiple Files', fieldType: 'file', multipleFiles: true }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json['Multiple Files']).toEqual([
{ filename: 'file1.txt', mimetype: 'text/plain', size: 1024 },
{ filename: 'file2.txt', mimetype: 'text/plain', size: 2048 },
]);
expect(result.binary).toBeDefined();
expect(result.binary!.Multiple_Files_0).toEqual({});
expect(result.binary!.Multiple_Files_1).toEqual({});
});
it('should format date fields', async () => {
mockContext.getBodyData.mockReturnValue({
data: { 'field-0': '2023-04-01' },
files: {},
});
const formFields = [{ fieldLabel: 'Date Field', fieldType: 'date', formatDate: 'dd/MM/yyyy' }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json['Date Field']).toBe('formatted-date');
expect(DateTime.fromFormat).toHaveBeenCalledWith('2023-04-01', 'yyyy-mm-dd');
});
it('should handle multiselect fields', async () => {
mockContext.getBodyData.mockReturnValue({
data: { 'field-0': '["option1", "option2"]' },
files: {},
});
const formFields = [{ fieldLabel: 'Multiselect', fieldType: 'multiSelect', multiselect: true }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json.Multiselect).toEqual(['option1', 'option2']);
});
it('should use workflow timezone when specified', async () => {
mockContext.getTimezone.mockReturnValue('America/New_York');
await prepareFormReturnItem(mockContext, [], 'test', true);
expect(mockContext.getTimezone).toHaveBeenCalled();
expect(DateTime.now().setZone).toHaveBeenCalledWith('America/New_York');
});
it('should include workflow static data for form trigger node', async () => {
const staticData = { queryParam: 'value' };
mockContext.getWorkflowStaticData.mockReturnValue(staticData);
const result = await prepareFormReturnItem(mockContext, [], 'test');
expect(result.json.formQueryParameters).toEqual(staticData);
});
});
describe('resolveRawData', () => {
const mockContext = mock<IWebhookFunctions>();
const dummyData = {
name: 'Hanna',
age: 30,
city: 'New York',
isStudent: false,
hasJob: true,
grades: {
math: 95,
science: 88,
history: 92,
},
hobbies: ['reading', 'painting', 'coding'],
address: {
street: '123 Main St',
zipCode: '10001',
country: 'USA',
},
languages: ['English', 'Spanish'],
projects: [
{ name: 'Project A', status: 'completed' },
{ name: 'Project B', status: 'in-progress' },
],
emptyArray: [],
};
beforeEach(() => {
jest.clearAllMocks();
mockContext.evaluateExpression.mockImplementation((expression: string) => {
const key = expression.replace(/[{}]/g, '').trim();
return key.split('.').reduce((obj, prop) => obj?.[prop], dummyData as any);
});
});
it('should return the input string if it does not start with "="', () => {
const input = 'Hello, world!';
expect(resolveRawData(mockContext, input)).toBe(input);
});
it('should remove leading "=" characters', () => {
const input = '=Hello, world!';
expect(resolveRawData(mockContext, input)).toBe('Hello, world!');
});
it('should resolve a single expression', () => {
const input = '=Hello, {{name}}!';
expect(resolveRawData(mockContext, input)).toBe('Hello, Hanna!');
});
it('should resolve multiple expressions', () => {
const input = '={{name}} is {{age}} years old and lives in {{city}}.';
expect(resolveRawData(mockContext, input)).toBe('Hanna is 30 years old and lives in New York.');
});
it('should handle object resolutions', () => {
const input = '=Grades: {{grades}}';
expect(resolveRawData(mockContext, input)).toBe(
'Grades: {"math":95,"science":88,"history":92}',
);
});
it('should handle nested object properties', () => {
const input = "={{name}}'s math grade is {{grades.math}}.";
expect(resolveRawData(mockContext, input)).toBe("Hanna's math grade is 95.");
});
it('should handle boolean values', () => {
const input = '=Is {{name}} a student? {{isStudent}}';
expect(resolveRawData(mockContext, input)).toBe('Is Hanna a student? false');
});
it('should handle expressions with whitespace', () => {
const input = '={{ name }} is {{ age }} years old.';
expect(resolveRawData(mockContext, input)).toBe('Hanna is 30 years old.');
});
it('should return the original string if no resolvables are found', () => {
const input = '=Hello, world!';
expect(resolveRawData(mockContext, input)).toBe('Hello, world!');
});
it('should handle non-existent properties gracefully', () => {
const input = "={{name}}'s favorite color is {{favoriteColor}}.";
expect(resolveRawData(mockContext, input)).toBe("Hanna's favorite color is undefined.");
});
it('should handle mixed resolvable and non-resolvable content', () => {
const input = '={{name}} lives in {{city}} and enjoys programming.';
expect(resolveRawData(mockContext, input)).toBe(
'Hanna lives in New York and enjoys programming.',
);
});
it('should handle boolean values correctly', () => {
const input = '={{name}} is a student: {{isStudent}}. {{name}} has a job: {{hasJob}}.';
expect(resolveRawData(mockContext, input)).toBe(
'Hanna is a student: false. Hanna has a job: true.',
);
});
it('should handle arrays correctly', () => {
const input = "={{name}}'s hobbies are {{hobbies}}.";
expect(resolveRawData(mockContext, input)).toBe(
'Hanna\'s hobbies are ["reading","painting","coding"].',
);
});
it('should handle nested objects correctly', () => {
const input = '={{name}} lives at {{address.street}}, {{address.zipCode}}.';
expect(resolveRawData(mockContext, input)).toBe('Hanna lives at 123 Main St, 10001.');
});
it('should handle arrays of objects correctly', () => {
const input = '=Project statuses: {{projects.0.status}}, {{projects.1.status}}.';
expect(resolveRawData(mockContext, input)).toBe('Project statuses: completed, in-progress.');
});
it('should handle empty arrays correctly', () => {
const input = '=Empty array: {{emptyArray}}.';
expect(resolveRawData(mockContext, input)).toBe('Empty array: [].');
});
it('should handle a mix of different data types', () => {
const input =
'={{name}} ({{age}}) knows {{languages.length}} languages. First project: {{projects.0.name}}.';
expect(resolveRawData(mockContext, input)).toBe(
'Hanna (30) knows 2 languages. First project: Project A.',
);
});
it('should handle nested array access', () => {
const input = '=First hobby: {{hobbies.0}}, Last hobby: {{hobbies.2}}.';
expect(resolveRawData(mockContext, input)).toBe('First hobby: reading, Last hobby: coding.');
});
it('should handle object-to-string conversion', () => {
const input = '=Address object: {{address}}.';
expect(resolveRawData(mockContext, input)).toBe(
'Address object: {"street":"123 Main St","zipCode":"10001","country":"USA"}.',
);
});
});

View File

@@ -3,15 +3,27 @@ import type {
MultiPartFormData,
IDataObject,
IWebhookFunctions,
FormFieldsParameter,
NodeTypeAndVersion,
} from 'n8n-workflow';
import { NodeOperationError, jsonParse } from 'n8n-workflow';
import {
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
NodeOperationError,
WAIT_NODE_TYPE,
jsonParse,
} from 'n8n-workflow';
import type { FormTriggerData, FormTriggerInput } from './interfaces';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces';
import { WebhookAuthorizationError } from '../Webhook/error';
import { validateWebhookAuthentication } from '../Webhook/utils';
import { DateTime } from 'luxon';
import isbot from 'isbot';
import { WebhookAuthorizationError } from '../Webhook/error';
import { validateWebhookAuthentication } from '../Webhook/utils';
import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces';
import type { Response } from 'express';
import { getResolvables } from '../../utils/utilities';
export function prepareFormData({
formTitle,
@@ -24,17 +36,19 @@ export function prepareFormData({
instanceId,
useResponseData,
appendAttribution = true,
buttonLabel,
}: {
formTitle: string;
formDescription: string;
formSubmittedText: string | undefined;
redirectUrl: string | undefined;
formFields: FormField[];
formFields: FormFieldsParameter;
testRun: boolean;
query: IDataObject;
instanceId?: string;
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
}) {
const validForm = formFields.length > 0;
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
@@ -54,6 +68,7 @@ export function prepareFormData({
formFields: [],
useResponseData,
appendAttribution,
buttonLabel,
};
if (redirectUrl) {
@@ -138,101 +153,12 @@ const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
}
};
export async function formWebhook(
export async function prepareFormReturnItem(
context: IWebhookFunctions,
authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY,
formFields: FormFieldsParameter,
mode: 'test' | 'production',
useWorkflowTimezone: boolean = false,
) {
const node = context.getNode();
const options = context.getNodeParameter('options', {}) as {
ignoreBots?: boolean;
respondWithOptions?: {
values: {
respondWith: 'text' | 'redirect';
formSubmittedText: string;
redirectUrl: string;
};
};
formSubmittedText?: string;
useWorkflowTimezone?: boolean;
appendAttribution?: boolean;
};
const res = context.getResponseObject();
const req = context.getRequestObject();
try {
if (options.ignoreBots && isbot(req.headers['user-agent'])) {
throw new WebhookAuthorizationError(403);
}
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 FormField[];
const method = context.getRequestObject().method;
checkResponseModeConfiguration(context);
//Show the form on GET request
if (method === 'GET') {
const formTitle = context.getNodeParameter('formTitle', '') as string;
const formDescription = (context.getNodeParameter('formDescription', '') as string)
.replace(/\\n/g, '\n')
.replace(/<br>/g, '\n');
const instanceId = context.getInstanceId();
const responseMode = context.getNodeParameter('responseMode', '') as string;
let formSubmittedText;
let redirectUrl;
let appendAttribution = true;
if (options.respondWithOptions) {
const values = (options.respondWithOptions as IDataObject).values as IDataObject;
if (values.respondWith === 'text') {
formSubmittedText = values.formSubmittedText as string;
}
if (values.respondWith === 'redirect') {
redirectUrl = values.redirectUrl as string;
}
} else {
formSubmittedText = options.formSubmittedText as string;
}
if (options.appendAttribution === false) {
appendAttribution = false;
}
const useResponseData = responseMode === 'responseNode';
const query = context.getRequestObject().query as IDataObject;
const data = prepareFormData({
formTitle,
formDescription,
formSubmittedText,
redirectUrl,
formFields,
testRun: mode === 'test',
query,
instanceId,
useResponseData,
appendAttribution,
});
res.render('form-trigger', data);
return {
noWebhookResponse: true,
};
}
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {};
@@ -312,21 +238,233 @@ export async function formWebhook(
returnItem.json[field.fieldLabel] = value;
}
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
returnItem.json.formMode = mode;
const workflowStaticData = context.getWorkflowStaticData('node');
if (
Object.keys(workflowStaticData || {}).length &&
context.getNode().type === FORM_TRIGGER_NODE_TYPE
) {
returnItem.json.formQueryParameters = workflowStaticData;
}
return returnItem;
}
export function renderForm({
context,
res,
formTitle,
formDescription,
formFields,
responseMode,
mode,
formSubmittedText,
redirectUrl,
appendAttribution,
buttonLabel,
}: {
context: IWebhookFunctions;
res: Response;
formTitle: string;
formDescription: string;
formFields: FormFieldsParameter;
responseMode: string;
mode: 'test' | 'production';
formSubmittedText?: string;
redirectUrl?: string;
appendAttribution?: boolean;
buttonLabel?: string;
}) {
formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/<br>/g, '\n');
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;
const workflowStaticData = context.getWorkflowStaticData('node');
for (const key of Object.keys(query)) {
workflowStaticData[key] = query[key];
}
} else if (context.getNode().type === FORM_NODE_TYPE) {
const parentNodes = context.getParentNodes(context.getNode().name);
const trigger = parentNodes.find(
(node) => node.type === FORM_TRIGGER_NODE_TYPE,
) as NodeTypeAndVersion;
try {
const triggerQueryParameters = context.evaluateExpression(
`{{ $('${trigger?.name}').first().json.formQueryParameters }}`,
) as IDataObject;
if (triggerQueryParameters) {
query = triggerQueryParameters;
}
} catch (error) {}
}
const data = prepareFormData({
formTitle,
formDescription,
formSubmittedText,
redirectUrl,
formFields,
testRun: mode === 'test',
query,
instanceId,
useResponseData,
appendAttribution,
buttonLabel,
});
res.render('form-trigger', data);
}
export async function formWebhook(
context: IWebhookFunctions,
authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY,
) {
const node = context.getNode();
const options = context.getNodeParameter('options', {}) as {
ignoreBots?: boolean;
respondWithOptions?: {
values: {
respondWith: 'text' | 'redirect';
formSubmittedText: string;
redirectUrl: string;
};
};
formSubmittedText?: string;
useWorkflowTimezone?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
};
const res = context.getResponseObject();
const req = context.getRequestObject();
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;
checkResponseModeConfiguration(context);
//Show the form on GET request
if (method === 'GET') {
const formTitle = context.getNodeParameter('formTitle', '') as string;
const formDescription = context.getNodeParameter('formDescription', '') as string;
const responseMode = context.getNodeParameter('responseMode', '') as string;
let formSubmittedText;
let redirectUrl;
let appendAttribution = true;
if (options.respondWithOptions) {
const values = (options.respondWithOptions as IDataObject).values as IDataObject;
if (values.respondWith === 'text') {
formSubmittedText = values.formSubmittedText as string;
}
if (values.respondWith === 'redirect') {
redirectUrl = values.redirectUrl as string;
}
} else {
formSubmittedText = options.formSubmittedText as string;
}
if (options.appendAttribution === false) {
appendAttribution = false;
}
let buttonLabel = 'Submit';
if (options.buttonLabel) {
buttonLabel = options.buttonLabel;
}
if (!redirectUrl && node.type !== FORM_TRIGGER_NODE_TYPE) {
const connectedNodes = context.getChildNodes(context.getNode().name);
const hasNextPage = connectedNodes.some(
(n) => n.type === FORM_NODE_TYPE || n.type === WAIT_NODE_TYPE,
);
if (hasNextPage) {
redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string;
}
}
renderForm({
context,
res,
formTitle,
formDescription,
formFields,
responseMode,
mode,
formSubmittedText,
redirectUrl,
appendAttribution,
buttonLabel,
});
return {
noWebhookResponse: true,
};
}
let { useWorkflowTimezone } = options;
if (useWorkflowTimezone === undefined && node.typeVersion > 2) {
useWorkflowTimezone = true;
}
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
returnItem.json.formMode = mode;
const webhookResponse: IDataObject = { status: 200 };
const returnItem = await prepareFormReturnItem(context, formFields, mode, useWorkflowTimezone);
return {
webhookResponse,
webhookResponse: { status: 200 },
workflowData: [[returnItem]],
};
}
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;
}

View File

@@ -23,7 +23,7 @@ const descriptionV1: INodeTypeDescription = {
icon: 'file:form.svg',
group: ['trigger'],
version: 1,
description: 'Runs the flow when an n8n generated webform is submitted',
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'n8n Form Trigger',
},

View File

@@ -1,4 +1,6 @@
import {
ADD_FORM_NOTICE,
type INodePropertyOptions,
NodeConnectionType,
type INodeProperties,
type INodeType,
@@ -33,10 +35,10 @@ const descriptionV2: INodeTypeDescription = {
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
version: [2, 2.1],
description: 'Runs the flow when an n8n generated webform is submitted',
version: [2, 2.1, 2.2],
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'n8n Form Trigger',
name: 'On form submission',
},
inputs: [],
@@ -47,7 +49,7 @@ const descriptionV2: INodeTypeDescription = {
httpMethod: 'GET',
responseMode: 'onReceived',
isFullPath: true,
path: '={{$parameter["path"]}}',
path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}',
ndvHideUrl: true,
isForm: true,
},
@@ -57,7 +59,7 @@ const descriptionV2: INodeTypeDescription = {
responseMode: '={{$parameter["responseMode"]}}',
responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}',
isFullPath: true,
path: '={{$parameter["path"]}}',
path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}',
ndvHideMethod: true,
isForm: true,
},
@@ -94,11 +96,18 @@ const descriptionV2: INodeTypeDescription = {
],
default: 'none',
},
webhookPath,
{ ...webhookPath, displayOptions: { show: { '@version': [{ _cnd: { lte: 2.1 } }] } } },
formTitle,
formDescription,
formFields,
formRespondMode,
{ ...formRespondMode, displayOptions: { show: { '@version': [{ _cnd: { lte: 2.1 } }] } } },
{
...formRespondMode,
options: (formRespondMode.options as INodePropertyOptions[])?.filter(
(option) => option.value !== 'responseNode',
),
displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } },
},
{
displayName:
"In the 'Respond to Webhook' node, select 'Respond With JSON' and set the <strong>formSubmittedText</strong> key to display a custom response in the form, or the <strong>redirectURL</strong> key to redirect users to a URL",
@@ -109,6 +118,13 @@ const descriptionV2: INodeTypeDescription = {
},
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',
@@ -117,6 +133,18 @@ const descriptionV2: INodeTypeDescription = {
default: {},
options: [
appendAttributionToForm,
{
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: {
@@ -135,6 +163,7 @@ const descriptionV2: INodeTypeDescription = {
{
...useWorkflowTimezone,
default: false,
description: "Whether to use the workflow timezone in 'submittedAt' field or UTC",
displayOptions: {
show: {
'@version': [2],
@@ -144,6 +173,7 @@ const descriptionV2: INodeTypeDescription = {
{
...useWorkflowTimezone,
default: true,
description: "Whether to use the workflow timezone in 'submittedAt' field or UTC",
displayOptions: {
show: {
'@version': [{ _cnd: { gt: 2 } }],

View File

@@ -503,6 +503,7 @@
"dist/nodes/Filter/Filter.node.js",
"dist/nodes/Flow/Flow.node.js",
"dist/nodes/Flow/FlowTrigger.node.js",
"dist/nodes/Form/Form.node.js",
"dist/nodes/Form/FormTrigger.node.js",
"dist/nodes/FormIo/FormIoTrigger.node.js",
"dist/nodes/Formstack/FormstackTrigger.node.js",