feat(Form Node): Split form name and label (#22304)

This commit is contained in:
Dawid Myslak
2025-12-03 22:49:58 +02:00
committed by GitHub
parent 44e2bc0beb
commit 271a9dd218
7 changed files with 348 additions and 8 deletions
+1 -1
View File
@@ -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', () => {
+24 -5
View File
@@ -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);