mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(Form Node): Split form name and label (#22304)
This commit is contained in:
@@ -269,7 +269,7 @@ export class Form extends Node {
|
||||
group: ['input'],
|
||||
// since trigger and node are sharing descriptions and logic we need to sync the versions
|
||||
// and keep them aligned in both nodes
|
||||
version: [1, 2.3],
|
||||
version: [1, 2.3, 2.4],
|
||||
description: 'Generate webforms in n8n and pass their responses to the workflow',
|
||||
defaults: {
|
||||
name: 'Form',
|
||||
|
||||
@@ -12,7 +12,7 @@ export class FormTrigger extends VersionedNodeType {
|
||||
icon: 'file:form.svg',
|
||||
group: ['trigger'],
|
||||
description: 'Generate webforms in n8n and pass their responses to the workflow',
|
||||
defaultVersion: 2.3,
|
||||
defaultVersion: 2.4,
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
@@ -21,6 +21,7 @@ export class FormTrigger extends VersionedNodeType {
|
||||
2.1: new FormTriggerV2(baseDescription),
|
||||
2.2: new FormTriggerV2(baseDescription),
|
||||
2.3: new FormTriggerV2(baseDescription),
|
||||
2.4: new FormTriggerV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
|
||||
@@ -58,6 +58,23 @@ export const formFields: INodeProperties = {
|
||||
values: [
|
||||
{
|
||||
displayName: 'Field Name',
|
||||
name: 'fieldName',
|
||||
description:
|
||||
'The name of the field, used in input attributes and referenced by the workflow',
|
||||
required: true,
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
fieldType: ['html'],
|
||||
},
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.4 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Label',
|
||||
name: 'fieldLabel',
|
||||
type: 'string',
|
||||
default: '',
|
||||
@@ -68,6 +85,26 @@ export const formFields: INodeProperties = {
|
||||
hide: {
|
||||
fieldType: ['hiddenField', 'html'],
|
||||
},
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.4 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Field Name',
|
||||
name: 'fieldLabel',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. What is your name?',
|
||||
description: 'Label that appears above the input field',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
fieldType: ['hiddenField', 'html'],
|
||||
},
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 2.4 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -80,6 +117,7 @@ export const formFields: INodeProperties = {
|
||||
displayOptions: {
|
||||
show: {
|
||||
fieldType: ['hiddenField'],
|
||||
'@version': [{ _cnd: { lt: 2.4 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1155,6 +1155,82 @@ describe('FormTrigger, prepareFormData - Checkbox and Radio Fields', () => {
|
||||
{ id: 'option1_field-1', label: 'Office' },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Version 2.4+ fieldName support', () => {
|
||||
it('should use fieldName for query parameters in v2.4+', () => {
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldName: 'userName',
|
||||
fieldLabel: 'User Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
const query: IDataObject = { userName: 'John Doe' };
|
||||
|
||||
const result = prepareFormData({
|
||||
formTitle: 'Test Form',
|
||||
formDescription: 'Test Description',
|
||||
formSubmittedText: undefined,
|
||||
redirectUrl: undefined,
|
||||
formFields,
|
||||
testRun: true,
|
||||
query,
|
||||
nodeVersion: 2.4,
|
||||
});
|
||||
|
||||
expect(result.formFields[0].defaultValue).toBe('John Doe');
|
||||
expect(result.formFields[0].label).toBe('User Name'); // Label should still be fieldLabel for rendering
|
||||
});
|
||||
|
||||
it('should use fieldLabel for query parameters in v2.3 and earlier', () => {
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldName: 'userName',
|
||||
fieldLabel: 'User Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
const query: IDataObject = { 'User Name': 'John Doe' };
|
||||
|
||||
const result = prepareFormData({
|
||||
formTitle: 'Test Form',
|
||||
formDescription: 'Test Description',
|
||||
formSubmittedText: undefined,
|
||||
redirectUrl: undefined,
|
||||
formFields,
|
||||
testRun: true,
|
||||
query,
|
||||
nodeVersion: 2.3,
|
||||
});
|
||||
|
||||
expect(result.formFields[0].defaultValue).toBe('John Doe');
|
||||
expect(result.formFields[0].label).toBe('User Name');
|
||||
});
|
||||
|
||||
it('should fallback to fieldLabel if fieldName is missing in v2.4+', () => {
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldLabel: 'User Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
const query: IDataObject = { 'User Name': 'John Doe' };
|
||||
|
||||
const result = prepareFormData({
|
||||
formTitle: 'Test Form',
|
||||
formDescription: 'Test Description',
|
||||
formSubmittedText: undefined,
|
||||
redirectUrl: undefined,
|
||||
formFields,
|
||||
testRun: true,
|
||||
query,
|
||||
nodeVersion: 2.4,
|
||||
});
|
||||
|
||||
expect(result.formFields[0].defaultValue).toBe('John Doe');
|
||||
expect(result.formFields[0].label).toBe('User Name');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFormResponseDataToReturnItem - Checkbox and Radio Fields', () => {
|
||||
@@ -1494,6 +1570,126 @@ describe('prepareFormReturnItem', () => {
|
||||
expect(result.json.formQueryParameters).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('Version 2.4+ fieldName support', () => {
|
||||
it('should use fieldName for binary property names in v2.4+', async () => {
|
||||
const mockFile: Partial<MultiPartFormData.File> = {
|
||||
filepath: '/tmp/uploaded-file',
|
||||
originalFilename: 'test.txt',
|
||||
mimetype: 'text/plain',
|
||||
size: 1024,
|
||||
newFilename: 'test.txt',
|
||||
};
|
||||
|
||||
mockContext.getBodyData.mockReturnValue({
|
||||
data: {},
|
||||
files: { 'field-0': mockFile },
|
||||
});
|
||||
|
||||
mockContext.getNode.mockReturnValue({
|
||||
...formNode,
|
||||
typeVersion: 2.4,
|
||||
} as INode);
|
||||
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldName: 'resume',
|
||||
fieldLabel: 'Resume Upload',
|
||||
fieldType: 'file',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
|
||||
|
||||
expect(result.binary).toBeDefined();
|
||||
expect(result.binary!.resume).toBeDefined();
|
||||
expect(result.binary!['Resume_Upload']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use fieldLabel for binary property names in v2.3 and earlier', async () => {
|
||||
const mockFile: Partial<MultiPartFormData.File> = {
|
||||
filepath: '/tmp/uploaded-file',
|
||||
originalFilename: 'test.txt',
|
||||
mimetype: 'text/plain',
|
||||
size: 1024,
|
||||
newFilename: 'test.txt',
|
||||
};
|
||||
|
||||
mockContext.getBodyData.mockReturnValue({
|
||||
data: {},
|
||||
files: { 'field-0': mockFile },
|
||||
});
|
||||
|
||||
mockContext.getNode.mockReturnValue({
|
||||
...formNode,
|
||||
typeVersion: 2.3,
|
||||
} as INode);
|
||||
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldName: 'resume',
|
||||
fieldLabel: 'Resume Upload',
|
||||
fieldType: 'file',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
|
||||
|
||||
expect(result.binary).toBeDefined();
|
||||
expect(result.binary!['Resume_Upload']).toBeDefined();
|
||||
expect(result.binary!.resume).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use fieldName for output data keys in v2.4+', async () => {
|
||||
mockContext.getBodyData.mockReturnValue({
|
||||
data: { 'field-0': 'John Doe' },
|
||||
files: {},
|
||||
});
|
||||
|
||||
mockContext.getNode.mockReturnValue({
|
||||
...formNode,
|
||||
typeVersion: 2.4,
|
||||
} as INode);
|
||||
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldName: 'userName',
|
||||
fieldLabel: 'User Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
|
||||
|
||||
expect(result.json.userName).toBe('John Doe');
|
||||
expect(result.json['User Name']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use fieldLabel for output data keys in v2.3 and earlier', async () => {
|
||||
mockContext.getBodyData.mockReturnValue({
|
||||
data: { 'field-0': 'John Doe' },
|
||||
files: {},
|
||||
});
|
||||
|
||||
mockContext.getNode.mockReturnValue({
|
||||
...formNode,
|
||||
typeVersion: 2.3,
|
||||
} as INode);
|
||||
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldName: 'userName',
|
||||
fieldLabel: 'User Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
|
||||
|
||||
expect(result.json['User Name']).toBe('John Doe');
|
||||
expect(result.json.userName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return html if field name is set', async () => {
|
||||
mockContext.getBodyData.mockReturnValue({
|
||||
data: { 'field-0': '<div>hi</div>', 'field-1': '<h1><haha/hi>' },
|
||||
@@ -1867,6 +2063,88 @@ describe('addFormResponseDataToReturnItem', () => {
|
||||
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
|
||||
expect(returnItem.json['File Field']).toEqual(['file1.pdf']);
|
||||
});
|
||||
|
||||
describe('Version 2.4+ fieldName support', () => {
|
||||
it('should use fieldName for output data keys in v2.4+', () => {
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldName: 'userName',
|
||||
fieldLabel: 'User Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
const bodyData: IDataObject = { 'field-0': 'John Doe' };
|
||||
|
||||
addFormResponseDataToReturnItem(returnItem, formFields, bodyData, 2.4);
|
||||
|
||||
expect(returnItem.json.userName).toBe('John Doe');
|
||||
expect(returnItem.json['User Name']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use fieldLabel for output data keys in v2.3 and earlier', () => {
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldName: 'userName',
|
||||
fieldLabel: 'User Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
const bodyData: IDataObject = { 'field-0': 'John Doe' };
|
||||
|
||||
addFormResponseDataToReturnItem(returnItem, formFields, bodyData, 2.3);
|
||||
|
||||
expect(returnItem.json['User Name']).toBe('John Doe');
|
||||
expect(returnItem.json.userName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fallback to fieldLabel if fieldName is missing in v2.4+', () => {
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldLabel: 'User Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
];
|
||||
const bodyData: IDataObject = { 'field-0': 'John Doe' };
|
||||
|
||||
addFormResponseDataToReturnItem(returnItem, formFields, bodyData, 2.4);
|
||||
|
||||
expect(returnItem.json['User Name']).toBe('John Doe');
|
||||
});
|
||||
|
||||
it('should handle multiple fields with fieldName in v2.4+', () => {
|
||||
const formFields: FormFieldsParameter = [
|
||||
{
|
||||
fieldName: 'firstName',
|
||||
fieldLabel: 'First Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
fieldName: 'lastName',
|
||||
fieldLabel: 'Last Name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
fieldName: 'email',
|
||||
fieldLabel: 'Email Address',
|
||||
fieldType: 'email',
|
||||
},
|
||||
];
|
||||
const bodyData: IDataObject = {
|
||||
'field-0': 'John',
|
||||
'field-1': 'Doe',
|
||||
'field-2': 'john@example.com',
|
||||
};
|
||||
|
||||
addFormResponseDataToReturnItem(returnItem, formFields, bodyData, 2.4);
|
||||
|
||||
expect(returnItem.json.firstName).toBe('John');
|
||||
expect(returnItem.json.lastName).toBe('Doe');
|
||||
expect(returnItem.json.email).toBe('john@example.com');
|
||||
expect(returnItem.json['First Name']).toBeUndefined();
|
||||
expect(returnItem.json['Last Name']).toBeUndefined();
|
||||
expect(returnItem.json['Email Address']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormTrigger, prepareFormData - Default Value', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Response } from 'express';
|
||||
import isbot from 'isbot';
|
||||
import * as a from 'node:assert';
|
||||
import { DateTime } from 'luxon';
|
||||
import type {
|
||||
INodeExecutionData,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
WorkflowConfigurationError,
|
||||
jsonParse,
|
||||
} from 'n8n-workflow';
|
||||
import * as a from 'node:assert';
|
||||
import sanitize from 'sanitize-html';
|
||||
|
||||
import { getResolvables } from '../../../utils/utilities';
|
||||
@@ -138,6 +138,19 @@ export function createDescriptionMetadata(description: string) {
|
||||
: description.replace(/^\s*\n+|<\/?[^>]+(>|$)/g, '').slice(0, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the field identifier to use based on node version.
|
||||
* For v2.4+, uses fieldName as the primary identifier.
|
||||
* For earlier versions, falls back to fieldLabel.
|
||||
*/
|
||||
function getFieldIdentifier(field: FormFieldsParameter[number], nodeVersion?: number): string {
|
||||
if (nodeVersion && nodeVersion >= 2.4 && field.fieldName) {
|
||||
return field.fieldName;
|
||||
}
|
||||
|
||||
return field.fieldLabel ?? field.fieldName ?? '';
|
||||
}
|
||||
|
||||
export function prepareFormData({
|
||||
formTitle,
|
||||
formDescription,
|
||||
@@ -152,6 +165,7 @@ export function prepareFormData({
|
||||
appendAttribution = true,
|
||||
buttonLabel,
|
||||
customCss,
|
||||
nodeVersion,
|
||||
}: {
|
||||
formTitle: string;
|
||||
formDescription: string;
|
||||
@@ -166,6 +180,7 @@ export function prepareFormData({
|
||||
buttonLabel?: string;
|
||||
formSubmittedHeader?: string;
|
||||
customCss?: string;
|
||||
nodeVersion?: number;
|
||||
}) {
|
||||
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
||||
const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`;
|
||||
@@ -198,13 +213,14 @@ export function prepareFormData({
|
||||
|
||||
for (const [index, field] of formFields.entries()) {
|
||||
const { fieldType, requiredField, multiselect, placeholder, defaultValue } = field;
|
||||
const queryParam = getFieldIdentifier(field, nodeVersion);
|
||||
|
||||
const input: FormField = {
|
||||
id: `field-${index}`,
|
||||
errorId: `error-field-${index}`,
|
||||
label: field.fieldLabel,
|
||||
inputRequired: requiredField ? 'form-required' : '',
|
||||
defaultValue: query[field.fieldLabel] ?? defaultValue ?? '',
|
||||
defaultValue: query[queryParam] ?? defaultValue ?? '',
|
||||
placeholder,
|
||||
};
|
||||
|
||||
@@ -302,10 +318,11 @@ export function addFormResponseDataToReturnItem(
|
||||
returnItem: INodeExecutionData,
|
||||
formFields: FormFieldsParameter,
|
||||
bodyData: IDataObject,
|
||||
nodeVersion?: number,
|
||||
) {
|
||||
for (const [index, field] of formFields.entries()) {
|
||||
const key = `field-${index}`;
|
||||
const name = field.fieldLabel ?? field.fieldName;
|
||||
const name = getFieldIdentifier(field, nodeVersion);
|
||||
let value = bodyData[key] ?? null;
|
||||
|
||||
if (value === null) {
|
||||
@@ -389,7 +406,8 @@ export async function prepareFormReturnItem(
|
||||
}
|
||||
|
||||
const entryIndex = Number(key.replace(/field-/g, ''));
|
||||
const fieldLabel = isNaN(entryIndex) ? key : formFields[entryIndex].fieldLabel;
|
||||
const field = isNaN(entryIndex) ? null : formFields[entryIndex];
|
||||
const fieldLabel = field ? getFieldIdentifier(field, context.getNode().typeVersion) : key;
|
||||
|
||||
let fileCount = 0;
|
||||
for (const file of processFiles) {
|
||||
@@ -407,7 +425,7 @@ export async function prepareFormReturnItem(
|
||||
}
|
||||
}
|
||||
|
||||
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
|
||||
addFormResponseDataToReturnItem(returnItem, formFields, bodyData, context.getNode().typeVersion);
|
||||
|
||||
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
|
||||
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
|
||||
@@ -491,6 +509,7 @@ export function renderForm({
|
||||
appendAttribution,
|
||||
buttonLabel,
|
||||
customCss,
|
||||
nodeVersion: context.getNode().typeVersion,
|
||||
});
|
||||
|
||||
res.render('form-trigger', data);
|
||||
|
||||
@@ -38,7 +38,7 @@ const descriptionV2: INodeTypeDescription = {
|
||||
group: ['trigger'],
|
||||
// since trigger and node are sharing descriptions and logic we need to sync the versions
|
||||
// and keep them aligned in both nodes
|
||||
version: [2, 2.1, 2.2, 2.3],
|
||||
version: [2, 2.1, 2.2, 2.3, 2.4],
|
||||
description: 'Generate webforms in n8n and pass their responses to the workflow',
|
||||
defaults: {
|
||||
name: 'On form submission',
|
||||
|
||||
@@ -26,21 +26,25 @@ test.describe('Form Trigger', () => {
|
||||
|
||||
// Add first field - Number type with required flag
|
||||
await n8n.ndv.addFixedCollectionItem();
|
||||
await n8n.ndv.fillParameterInputByName('fieldName', 'testField1');
|
||||
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Test Field 1');
|
||||
await n8n.ndv.selectOptionInParameterDropdown('fieldType', 'Number');
|
||||
await n8n.ndv.setParameterSwitch('requiredField', true);
|
||||
|
||||
// Add second field - Text type
|
||||
await n8n.ndv.addFixedCollectionItem();
|
||||
await n8n.ndv.fillParameterInputByName('fieldName', 'testField2', 1);
|
||||
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Test Field 2', 1);
|
||||
|
||||
// Add third field - Date type
|
||||
await n8n.ndv.addFixedCollectionItem();
|
||||
await n8n.ndv.fillParameterInputByName('fieldName', 'testField3', 2);
|
||||
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Test Field 3', 2);
|
||||
await n8n.ndv.selectOptionInParameterDropdown('fieldType', 'Date', 2);
|
||||
|
||||
// Add fourth field - Dropdown type with options
|
||||
await n8n.ndv.addFixedCollectionItem();
|
||||
await n8n.ndv.fillParameterInputByName('fieldName', 'testField4', 3);
|
||||
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Test Field 4', 3);
|
||||
await n8n.ndv.selectOptionInParameterDropdown('fieldType', 'Dropdown', 3);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user