feat: Checkboxes and Radio Buttons field types (#17934)

Co-authored-by: Your Name <you@example.com>
Co-authored-by: Roman Davydchuk <roman.davydchuk@n8n.io>
This commit is contained in:
Michael Kret
2025-08-11 17:11:22 +03:00
committed by GitHub
parent f69d8efa04
commit fdab0ab116
9 changed files with 800 additions and 40 deletions

View File

@@ -295,9 +295,46 @@
}
input[type='checkbox'] {
appearance: none;
width: var(--checkbox-size);
height: var(--checkbox-size);
border: 1px solid var(--color-input-border);
border-radius: 3px;
cursor: pointer;
position: relative;
}
.multiselect-checkbox:checked {
background: var(--color-focus-border);
border-color: var(--color-focus-border);
}
.multiselect-checkbox:checked::after {
content: '✔';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: var(--font-size-label);
font-weight: bold;
}
.multiselect[data-radio-select] .multiselect-checkbox {
border-radius: 50%;
width: var(--checkbox-size);
height: var(--checkbox-size);
display: flex;
align-items: center;
justify-content: center;
}
.multiselect[data-radio-select] .multiselect-checkbox:checked {
background: white;
border-color: var(--color-focus-border);
border-width: 4px;
border-radius: 50%;
}
.multiselect[data-radio-select] .multiselect-checkbox:checked::after {
content: '';
}
/* required field ----------------------------- */
.form-required {
@@ -454,7 +491,22 @@
{{#if isMultiSelect}}
<div>
<label class='form-label {{inputRequired}}'>{{label}}</label>
<div class='multiselect {{inputRequired}}' id='{{id}}'>
<div
class='multiselect {{inputRequired}}'
id='{{id}}'
{{#if radioSelect}}
data-radio-select='{{radioSelect}}'
{{/if}}
{{#if exactSelectedOptions}}
data-exact-select='{{exactSelectedOptions}}'
{{/if}}
{{#if minSelectedOptions}}
data-min-select='{{minSelectedOptions}}'
{{/if}}
{{#if maxSelectedOptions}}
data-max-select='{{maxSelectedOptions}}'
{{/if}}
>
{{#each multiSelectOptions}}
<div class='multiselect-option'>
<input type='checkbox' class='multiselect-checkbox' id='{{id}}' />
@@ -620,6 +672,15 @@
</section>
</div>
<script>
function updateError(errorElement, action = 'add', message = '') {
if(action === 'add') {
errorElement.textContent = message;
errorElement.classList.add('error-show');
} else {
errorElement.classList.remove('error-show');
}
}
function validateInput(input, errorElement) {
const value = input.value.trim();
const type = input.type;
@@ -628,19 +689,17 @@
return validateEmailInput(value, errorElement);
} else if (type === 'number' && value !== '') {
if (isNaN(value)) {
errorElement.textContent = 'Enter only numbers in this field';
errorElement.classList.add('error-show');
updateError(errorElement, 'add', 'Enter only numbers in this field');
return false;
} else {
errorElement.classList.remove('error-show');
updateError(errorElement, 'remove');
return true;
}
} else if (value === '') {
errorElement.textContent = 'This field is required';
errorElement.classList.add('error-show');
updateError(errorElement, 'add', 'This field is required');
return false;
} else {
errorElement.classList.remove('error-show');
updateError(errorElement, 'remove');
return true;
}
}
@@ -650,12 +709,11 @@
const isValidEmail = regex.test(value);
if (!isValidEmail) {
errorElement.textContent = 'Enter a valid email address in this field';
errorElement.classList.add('error-show');
updateError(errorElement, 'add', 'Enter a valid email address in this field');
return false;
} else {
errorElement.textContent = 'This field is required';
errorElement.classList.remove('error-show');
updateError(errorElement, 'remove');
return true;
}
}
@@ -674,16 +732,41 @@
return selectedValues;
}
function validateMultiselect(input, errorElement) {
const selectedValues = getSelectedValues(input);
function getDataValues(input) {
const radio = input.dataset.radioSelect ? true : false;
const exact = input.dataset.exactSelect ? parseInt(input.dataset.exactSelect, 10) : null;
const maxSelect = input.dataset.maxSelect ? parseInt(input.dataset.maxSelect, 10) : null;
const minSelect = input.dataset.minSelect ? parseInt(input.dataset.minSelect, 10) : null;
return { radio, exact, maxSelect, minSelect };
}
if (!selectedValues.length) {
errorElement.classList.add('error-show');
function validateMultiselect(input, errorElement) {
const values = getSelectedValues(input);
const data = getDataValues(input);
const required = input.classList.contains('form-required');
if (required && !values.length) {
updateError(errorElement, 'add', 'This field is required');
return false;
} else {
errorElement.classList.remove('error-show');
return true;
}
if (data.exact && values.length !== data.exact) {
updateError(errorElement, 'add', `You must select ${data.exact} options`);
return false;
}
if (data.minSelect && values.length < data.minSelect) {
updateError(errorElement, 'add', `You must select at least ${data.minSelect} options`);
return false;
}
if(data.maxSelect && values.length > data.maxSelect) {
updateError(errorElement, 'add', `You can select a maximum of ${data.maxSelect} options`);
return false;
}
updateError(errorElement, 'remove');
return true;
}
const form = document.querySelector('#n8n-form');
@@ -726,6 +809,32 @@
const requiredInputs = document.querySelectorAll('.form-required:not(label)');
const emailInputs = document.querySelectorAll("input[type=email]");
const multiselectInputs = document.querySelectorAll('.multiselect');
multiselectInputs.forEach((input) => {
const data = getDataValues(input);
const errorElement = document.querySelector(`.error-${input.id}`);
if (data.radio) {
const checkboxes = input.querySelectorAll('.multiselect-checkbox');
checkboxes.forEach((checkbox) => {
checkbox.addEventListener('change', (event) => {
if (event.target.checked) {
checkboxes.forEach((cb) => {
if (cb !== event.target) {
cb.checked = false;
}
});
updateError(errorElement, 'remove');
}
});
});
}
input.addEventListener('click', () => {
validateMultiselect(input, errorElement);
});
});
requiredInputs.forEach((input) => {
const errorSelector = `.error-${input.id}`;
@@ -740,7 +849,7 @@
validateInput(input, error);
});
input.addEventListener('input', () => {
error.classList.remove('error-show');
updateError(error, 'remove');
});
}
});
@@ -823,7 +932,7 @@
valid.push(validateEmailInput(value, error));
});
requiredInputs.forEach((input) => {
[...requiredInputs, ...multiselectInputs].forEach((input) => {
const errorSelector = `.error-${input.id}`;
const error = document.querySelector(errorSelector);

View File

@@ -268,7 +268,9 @@ export class Form extends Node {
name: 'form',
icon: 'file:form.svg',
group: ['input'],
version: 1,
// 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],
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'Form',

View File

@@ -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.2,
defaultVersion: 2.3,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
@@ -20,6 +20,7 @@ export class FormTrigger extends VersionedNodeType {
2: new FormTriggerV2(baseDescription),
2.1: new FormTriggerV2(baseDescription),
2.2: new FormTriggerV2(baseDescription),
2.3: new FormTriggerV2(baseDescription),
};
super(nodeVersions, baseDescription);

View File

@@ -77,6 +77,10 @@ export const formFields: INodeProperties = {
default: 'text',
description: 'The type of field to add to the form',
options: [
{
name: 'Checkboxes',
value: 'checkbox',
},
{
name: 'Custom HTML',
value: 'html',
@@ -86,7 +90,7 @@ export const formFields: INodeProperties = {
value: 'date',
},
{
name: 'Dropdown List',
name: 'Dropdown',
value: 'dropdown',
},
{
@@ -109,6 +113,10 @@ export const formFields: INodeProperties = {
name: 'Password',
value: 'password',
},
{
name: 'Radio Buttons',
value: 'radio',
},
{
name: 'Text',
value: 'text',
@@ -141,7 +149,7 @@ export const formFields: INodeProperties = {
default: '',
displayOptions: {
hide: {
fieldType: ['dropdown', 'date', 'file', 'html', 'hiddenField'],
fieldType: ['dropdown', 'date', 'file', 'html', 'hiddenField', 'radio', 'checkbox'],
},
},
},
@@ -171,6 +179,7 @@ export const formFields: INodeProperties = {
},
},
},
{
displayName: 'Field Options',
name: 'fieldOptions',
@@ -203,6 +212,82 @@ export const formFields: INodeProperties = {
},
],
},
{
displayName: 'Checkboxes',
name: 'fieldOptions',
placeholder: 'Add Checkbox',
type: 'fixedCollection',
default: { values: [{ option: '' }] },
required: true,
displayOptions: {
show: {
fieldType: ['checkbox'],
},
},
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Checkbox Label',
name: 'option',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Radio Buttons',
name: 'fieldOptions',
placeholder: 'Add Radio Button',
type: 'fixedCollection',
default: { values: [{ option: '' }] },
required: true,
displayOptions: {
show: {
fieldType: ['radio'],
},
},
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Radio Button Label',
name: 'option',
type: 'string',
default: '',
},
],
},
],
},
{
displayName:
'Multiple Choice is a legacy option, please use Checkboxes or Radio Buttons field type instead',
name: 'multiselectLegacyNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
multiselect: [true],
fieldType: ['dropdown'],
'@version': [{ _cnd: { lt: 2.3 } }],
},
},
},
{
displayName: 'Multiple Choice',
name: 'multiselect',
@@ -213,6 +298,80 @@ export const formFields: INodeProperties = {
displayOptions: {
show: {
fieldType: ['dropdown'],
'@version': [{ _cnd: { lt: 2.3 } }],
},
},
},
{
displayName: 'Limit Selection',
name: 'limitSelection',
type: 'options',
default: 'unlimited',
options: [
{
name: 'Exact Number',
value: 'exact',
},
{
name: 'Range',
value: 'range',
},
{
name: 'Unlimited',
value: 'unlimited',
},
],
displayOptions: {
show: {
fieldType: ['checkbox'],
},
},
},
{
displayName: 'Number of Selections',
name: 'numberOfSelections',
type: 'number',
default: 1,
typeOptions: {
numberPrecision: 0,
minValue: 1,
},
displayOptions: {
show: {
fieldType: ['checkbox'],
limitSelection: ['exact'],
},
},
},
{
displayName: 'Minimum Selections',
name: 'minSelections',
type: 'number',
default: 0,
typeOptions: {
numberPrecision: 0,
minValue: 0,
},
displayOptions: {
show: {
fieldType: ['checkbox'],
limitSelection: ['range'],
},
},
},
{
displayName: 'Maximum Selections',
name: 'maxSelections',
type: 'number',
default: 1,
typeOptions: {
numberPrecision: 0,
minValue: 1,
},
displayOptions: {
show: {
fieldType: ['checkbox'],
limitSelection: ['range'],
},
},
},

View File

@@ -1,20 +1,37 @@
export type FormTriggerInput = {
isSelect?: boolean;
isMultiSelect?: boolean;
isTextarea?: boolean;
isFileInput?: boolean;
isInput?: boolean;
label: string;
defaultValue?: string;
import type { GenericValue } from 'n8n-workflow';
export type FormField = {
id: string;
errorId: string;
type?: 'text' | 'number' | 'date';
label: string;
placeholder?: string;
inputRequired: 'form-required' | '';
type?: 'text' | 'number' | 'date' | 'email';
defaultValue: GenericValue;
isInput?: boolean;
isTextarea?: boolean;
isSelect?: boolean;
selectOptions?: string[];
isMultiSelect?: boolean;
radioSelect?: 'radio';
exactSelectedOptions?: number;
minSelectedOptions?: number;
maxSelectedOptions?: number;
multiSelectOptions?: Array<{ id: string; label: string }>;
isFileInput?: boolean;
acceptFileTypes?: string;
multipleFiles?: 'multiple' | '';
placeholder?: string;
isHtml?: boolean;
html?: string;
isHidden?: boolean;
hiddenName?: string;
hiddenValue?: GenericValue;
};
export type FormTriggerData = {
@@ -26,7 +43,7 @@ export type FormTriggerData = {
formSubmittedText?: string;
redirectUrl?: string;
n8nWebsiteLink: string;
formFields: FormTriggerInput[];
formFields: FormField[];
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;

View File

@@ -608,6 +608,440 @@ describe('FormTrigger, prepareFormData', () => {
});
});
describe('FormTrigger, prepareFormData - Checkbox and Radio Fields', () => {
it('should correctly handle checkbox fields', () => {
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Hobbies',
fieldType: 'checkbox',
requiredField: false,
fieldOptions: {
values: [{ option: 'Reading' }, { option: 'Gaming' }, { option: 'Sports' }],
},
},
];
const query = { Hobbies: 'Reading,Gaming' };
const result = prepareFormData({
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: 'Thank you',
redirectUrl: 'example.com',
formFields,
testRun: false,
query,
});
expect(result.formFields[0].isMultiSelect).toBe(true);
expect(result.formFields[0].multiSelectOptions).toEqual([
{ id: 'option0_field-0', label: 'Reading' },
{ id: 'option1_field-0', label: 'Gaming' },
{ id: 'option2_field-0', label: 'Sports' },
]);
});
it('should correctly handle radio fields', () => {
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Preferred Contact Method',
fieldType: 'radio',
requiredField: true,
fieldOptions: {
values: [{ option: 'Email' }, { option: 'Phone' }, { option: 'Text Message' }],
},
},
];
const query = { 'Preferred Contact Method': 'Email' };
const result = prepareFormData({
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: 'Thank you',
redirectUrl: 'example.com',
formFields,
testRun: false,
query,
});
expect(result.formFields[0].radioSelect).toBe('radio');
expect(result.formFields[0].multiSelectOptions).toEqual([
{ id: 'option0_field-0', label: 'Email' },
{ id: 'option1_field-0', label: 'Phone' },
{ id: 'option2_field-0', label: 'Text Message' },
]);
expect(result.formFields[0].defaultValue).toBe('Email');
});
it('should handle checkbox fields with no default selection', () => {
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Newsletter Subscriptions',
fieldType: 'checkbox',
requiredField: false,
fieldOptions: {
values: [{ option: 'Tech News' }, { option: 'Product Updates' }],
},
},
];
const query = {};
const result = prepareFormData({
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: 'Thank you',
redirectUrl: 'example.com',
formFields,
testRun: false,
query,
});
expect(result.formFields[0].isMultiSelect).toBe(true);
expect(result.formFields[0].defaultValue).toBe('');
expect(result.formFields[0].multiSelectOptions).toEqual([
{ id: 'option0_field-0', label: 'Tech News' },
{ id: 'option1_field-0', label: 'Product Updates' },
]);
});
it('should handle radio fields with no default selection', () => {
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Experience Level',
fieldType: 'radio',
requiredField: false,
fieldOptions: {
values: [{ option: 'Beginner' }, { option: 'Intermediate' }, { option: 'Advanced' }],
},
},
];
const query = {};
const result = prepareFormData({
formTitle: 'Test Form',
formDescription: 'This is a test form',
formSubmittedText: 'Thank you',
redirectUrl: 'example.com',
formFields,
testRun: false,
query,
});
expect(result.formFields[0].radioSelect).toBe('radio');
expect(result.formFields[0].defaultValue).toBe('');
expect(result.formFields[0].multiSelectOptions).toEqual([
{ id: 'option0_field-0', label: 'Beginner' },
{ id: 'option1_field-0', label: 'Intermediate' },
{ id: 'option2_field-0', label: 'Advanced' },
]);
});
it('should handle mixed form with checkbox, radio, and other field types', () => {
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Name',
fieldType: 'text',
requiredField: true,
placeholder: 'Enter your name',
},
{
fieldLabel: 'Skills',
fieldType: 'checkbox',
requiredField: false,
fieldOptions: {
values: [{ option: 'JavaScript' }, { option: 'Python' }, { option: 'Java' }],
},
},
{
fieldLabel: 'Employment Status',
fieldType: 'radio',
requiredField: true,
fieldOptions: {
values: [{ option: 'Full-time' }, { option: 'Part-time' }, { option: 'Freelancer' }],
},
},
];
const query = {
Name: 'John Doe',
Skills: 'JavaScript,Python',
'Employment Status': 'Full-time',
};
const result = prepareFormData({
formTitle: 'Developer Survey',
formDescription: 'Tell us about yourself',
formSubmittedText: 'Thank you for participating',
redirectUrl: 'example.com/thanks',
formFields,
testRun: false,
query,
});
expect(result.formFields[0]).toEqual({
id: 'field-0',
errorId: 'error-field-0',
label: 'Name',
inputRequired: 'form-required',
defaultValue: 'John Doe',
placeholder: 'Enter your name',
isInput: true,
type: 'text',
});
expect(result.formFields[1].isMultiSelect).toBe(true);
expect(result.formFields[1].multiSelectOptions).toEqual([
{ id: 'option0_field-1', label: 'JavaScript' },
{ id: 'option1_field-1', label: 'Python' },
{ id: 'option2_field-1', label: 'Java' },
]);
expect(result.formFields[2].radioSelect).toBe('radio');
expect(result.formFields[2].defaultValue).toBe('Full-time');
expect(result.formFields[2].multiSelectOptions).toEqual([
{ id: 'option0_field-2', label: 'Full-time' },
{ id: 'option1_field-2', label: 'Part-time' },
{ id: 'option2_field-2', label: 'Freelancer' },
]);
});
it('should handle checkbox fields with unique IDs when multiple checkbox fields exist', () => {
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Programming Languages',
fieldType: 'checkbox',
requiredField: false,
fieldOptions: {
values: [{ option: 'JavaScript' }, { option: 'Python' }],
},
},
{
fieldLabel: 'Frameworks',
fieldType: 'checkbox',
requiredField: false,
fieldOptions: {
values: [{ option: 'React' }, { option: 'Vue' }],
},
},
];
const query = {
'Programming Languages': 'JavaScript',
Frameworks: 'React,Vue',
};
const result = prepareFormData({
formTitle: 'Tech Survey',
formDescription: 'Your tech preferences',
formSubmittedText: 'Thanks!',
redirectUrl: 'example.com',
formFields,
testRun: false,
query,
});
expect(result.formFields[0].multiSelectOptions).toEqual([
{ id: 'option0_field-0', label: 'JavaScript' },
{ id: 'option1_field-0', label: 'Python' },
]);
expect(result.formFields[1].multiSelectOptions).toEqual([
{ id: 'option0_field-1', label: 'React' },
{ id: 'option1_field-1', label: 'Vue' },
]);
});
it('should handle radio fields with unique IDs when multiple radio fields exist', () => {
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Experience Level',
fieldType: 'radio',
requiredField: true,
fieldOptions: {
values: [{ option: 'Junior' }, { option: 'Senior' }],
},
},
{
fieldLabel: 'Work Preference',
fieldType: 'radio',
requiredField: true,
fieldOptions: {
values: [{ option: 'Remote' }, { option: 'Office' }],
},
},
];
const query = {
'Experience Level': 'Senior',
'Work Preference': 'Remote',
};
const result = prepareFormData({
formTitle: 'Job Survey',
formDescription: 'Your work preferences',
formSubmittedText: 'Thanks!',
redirectUrl: 'example.com',
formFields,
testRun: false,
query,
});
expect(result.formFields[0].multiSelectOptions).toEqual([
{ id: 'option0_field-0', label: 'Junior' },
{ id: 'option1_field-0', label: 'Senior' },
]);
expect(result.formFields[1].multiSelectOptions).toEqual([
{ id: 'option0_field-1', label: 'Remote' },
{ id: 'option1_field-1', label: 'Office' },
]);
});
});
describe('addFormResponseDataToReturnItem - Checkbox and Radio Fields', () => {
it('should process checkbox field data correctly', () => {
const returnItem: INodeExecutionData = { json: {} };
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Hobbies',
fieldType: 'checkbox',
requiredField: false,
fieldOptions: {
values: [{ option: 'Reading' }, { option: 'Gaming' }],
},
},
];
const bodyData: IDataObject = {
'field-0': '["Reading", "Gaming"]',
};
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
expect(returnItem.json.Hobbies).toEqual(['Reading', 'Gaming']);
});
it('should process radio field data correctly', () => {
const returnItem: INodeExecutionData = { json: {} };
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Preferred Contact',
fieldType: 'radio',
requiredField: true,
fieldOptions: {
values: [{ option: 'Email' }, { option: 'Phone' }],
},
},
];
const bodyData: IDataObject = {
'field-0': '["Email"]',
};
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
expect(returnItem.json['Preferred Contact']).toBe('Email');
});
it('should handle radio field with array value by taking first element', () => {
const returnItem: INodeExecutionData = { json: {} };
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Priority Level',
fieldType: 'radio',
requiredField: true,
fieldOptions: {
values: [{ option: 'High' }, { option: 'Medium' }, { option: 'Low' }],
},
},
];
const bodyData: IDataObject = {
'field-0': '["High", "Medium"]',
};
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
expect(returnItem.json['Priority Level']).toBe('High');
});
it('should handle checkbox field with null value', () => {
const returnItem: INodeExecutionData = { json: {} };
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Optional Features',
fieldType: 'checkbox',
requiredField: false,
fieldOptions: {
values: [{ option: 'Feature A' }, { option: 'Feature B' }],
},
},
];
const bodyData: IDataObject = {};
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
expect(returnItem.json['Optional Features']).toBeNull();
});
it('should handle radio field with null value', () => {
const returnItem: INodeExecutionData = { json: {} };
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Rating',
fieldType: 'radio',
requiredField: false,
fieldOptions: {
values: [{ option: '1 Star' }, { option: '2 Stars' }],
},
},
];
const bodyData: IDataObject = {};
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
expect(returnItem.json.Rating).toBeNull();
});
it('should process mixed form data with checkbox, radio, and other fields', () => {
const returnItem: INodeExecutionData = { json: {} };
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Name',
fieldType: 'text',
requiredField: true,
},
{
fieldLabel: 'Skills',
fieldType: 'checkbox',
requiredField: false,
fieldOptions: {
values: [{ option: 'JavaScript' }, { option: 'Python' }],
},
},
{
fieldLabel: 'Experience',
fieldType: 'radio',
requiredField: true,
fieldOptions: {
values: [{ option: 'Junior' }, { option: 'Senior' }],
},
},
];
const bodyData: IDataObject = {
'field-0': 'John Doe',
'field-1': '["JavaScript", "Python"]',
'field-2': '["Senior"]',
};
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
expect(returnItem.json.Name).toBe('John Doe');
expect(returnItem.json.Skills).toEqual(['JavaScript', 'Python']);
expect(returnItem.json.Experience).toBe('Senior');
});
});
jest.mock('luxon', () => ({
DateTime: {
fromFormat: jest.fn().mockReturnValue({
@@ -1125,6 +1559,22 @@ describe('addFormResponseDataToReturnItem', () => {
expect(returnItem.json['Text Field']).toBe('hello world');
});
it('should parse radio field from JSON', () => {
const formFields: FormFieldsParameter = [{ fieldLabel: 'Radio Field', fieldType: 'radio' }];
const bodyData: IDataObject = { 'field-0': '["option1"]' };
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
expect(returnItem.json['Radio Field']).toEqual('option1');
});
it('should parse checkboxes fields from JSON', () => {
const formFields: FormFieldsParameter = [{ fieldLabel: 'Checkboxes', fieldType: 'checkbox' }];
const bodyData: IDataObject = { 'field-0': '["option1", "option2"]' };
addFormResponseDataToReturnItem(returnItem, formFields, bodyData);
expect(returnItem.json['Checkboxes']).toEqual(['option1', 'option2']);
});
it('should parse multiselect fields from JSON', () => {
const formFields: FormFieldsParameter = [
{ fieldLabel: 'Multi Field', fieldType: 'text', multiselect: true },

View File

@@ -22,7 +22,7 @@ import { getResolvables } from '../../../utils/utilities';
import { WebhookAuthorizationError } from '../../Webhook/error';
import { validateWebhookAuthentication } from '../../Webhook/utils';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
import type { FormTriggerData, FormTriggerInput } from '../interfaces';
import type { FormTriggerData, FormField } from '../interfaces';
export function sanitizeHtml(text: string) {
return sanitize(text, {
@@ -187,7 +187,7 @@ export function prepareFormData({
for (const [index, field] of formFields.entries()) {
const { fieldType, requiredField, multiselect, placeholder } = field;
const input: IDataObject = {
const input: FormField = {
id: `field-${index}`,
errorId: `error-field-${index}`,
label: field.fieldLabel,
@@ -196,13 +196,22 @@ export function prepareFormData({
placeholder,
};
if (multiselect) {
if (multiselect || (fieldType && ['radio', 'checkbox'].includes(fieldType))) {
input.isMultiSelect = true;
input.multiSelectOptions =
field.fieldOptions?.values.map((e, i) => ({
id: `option${i}_${input.id}`,
label: e.option,
})) ?? [];
if (fieldType === 'radio') {
input.radioSelect = 'radio';
} else if (field.limitSelection === 'exact') {
input.exactSelectedOptions = field.numberOfSelections;
} else if (field.limitSelection === 'range') {
input.minSelectedOptions = field.minSelections;
input.maxSelectedOptions = field.maxSelections;
}
} else if (fieldType === 'file') {
input.isFileInput = true;
input.acceptFileTypes = field.acceptFileTypes;
@@ -226,7 +235,7 @@ export function prepareFormData({
input.type = fieldType as 'text' | 'number' | 'date' | 'email';
}
formData.formFields.push(input as FormTriggerInput);
formData.formFields.push(input);
}
return formData;
@@ -305,8 +314,15 @@ export function addFormResponseDataToReturnItem(
if (field.fieldType === 'text') {
value = String(value).trim();
}
if (field.multiselect && typeof value === 'string') {
if (
(field.multiselect || field.fieldType === 'checkbox' || field.fieldType === 'radio') &&
typeof value === 'string'
) {
value = jsonParse(value);
if (field.fieldType === 'radio' && Array.isArray(value)) {
value = value[0];
}
}
if (field.fieldType === 'date' && value && field.formatDate !== '') {
value = DateTime.fromFormat(String(value), 'yyyy-mm-dd').toFormat(field.formatDate as string);

View File

@@ -36,7 +36,9 @@ const descriptionV2: INodeTypeDescription = {
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
version: [2, 2.1, 2.2],
// 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],
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'On form submission',

View File

@@ -2857,6 +2857,10 @@ export type FormFieldsParameter = Array<{
placeholder?: string;
fieldName?: string;
fieldValue?: string;
limitSelection?: 'exact' | 'range' | 'unlimited';
numberOfSelections?: number;
minSelections?: number;
maxSelections?: number;
}>;
export type FieldTypeMap = {