From 271a9dd2181e163a4fcd72d995598bb0e48ccee8 Mon Sep 17 00:00:00 2001 From: Dawid Myslak Date: Wed, 3 Dec 2025 21:49:58 +0100 Subject: [PATCH] feat(Form Node): Split form name and label (#22304) --- packages/nodes-base/nodes/Form/Form.node.ts | 2 +- .../nodes-base/nodes/Form/FormTrigger.node.ts | 3 +- .../nodes/Form/common.descriptions.ts | 38 +++ .../nodes-base/nodes/Form/test/utils.test.ts | 278 ++++++++++++++++++ packages/nodes-base/nodes/Form/utils/utils.ts | 29 +- .../nodes/Form/v2/FormTriggerV2.node.ts | 2 +- .../tests/ui/16-form-trigger-node.spec.ts | 4 + 7 files changed, 348 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 91a7162bbf2..4103586d328 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -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', diff --git a/packages/nodes-base/nodes/Form/FormTrigger.node.ts b/packages/nodes-base/nodes/Form/FormTrigger.node.ts index 739a5787865..e513a242d75 100644 --- a/packages/nodes-base/nodes/Form/FormTrigger.node.ts +++ b/packages/nodes-base/nodes/Form/FormTrigger.node.ts @@ -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); diff --git a/packages/nodes-base/nodes/Form/common.descriptions.ts b/packages/nodes-base/nodes/Form/common.descriptions.ts index b2bac9fe4ab..d653600e4fd 100644 --- a/packages/nodes-base/nodes/Form/common.descriptions.ts +++ b/packages/nodes-base/nodes/Form/common.descriptions.ts @@ -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 } }], }, }, }, diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index 9f5dcbbe766..8bc7811c824 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -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 = { + 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 = { + 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': '
hi
', 'field-1': '

' }, @@ -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', () => { diff --git a/packages/nodes-base/nodes/Form/utils/utils.ts b/packages/nodes-base/nodes/Form/utils/utils.ts index 6e0f68b684f..3df9d11441f 100644 --- a/packages/nodes-base/nodes/Form/utils/utils.ts +++ b/packages/nodes-base/nodes/Form/utils/utils.ts @@ -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); diff --git a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts index e87f6097197..0fa6f4a747a 100644 --- a/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts +++ b/packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts @@ -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', diff --git a/packages/testing/playwright/tests/ui/16-form-trigger-node.spec.ts b/packages/testing/playwright/tests/ui/16-form-trigger-node.spec.ts index c1092bc5763..fa4843d519d 100644 --- a/packages/testing/playwright/tests/ui/16-form-trigger-node.spec.ts +++ b/packages/testing/playwright/tests/ui/16-form-trigger-node.spec.ts @@ -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);