mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
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:
18
packages/nodes-base/nodes/Form/Form.node.json
Normal file
18
packages/nodes-base/nodes/Form/Form.node.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
422
packages/nodes-base/nodes/Form/Form.node.ts
Normal file
422
packages/nodes-base/nodes/Form/Form.node.ts
Normal 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()];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
190
packages/nodes-base/nodes/Form/test/Form.node.test.ts
Normal file
190
packages/nodes-base/nodes/Form/test/Form.node.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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"}.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 } }],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user