From 912b22a08f6367d03e2f8c72b551afbb8022b077 Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Mon, 6 Oct 2025 21:26:27 +0200 Subject: [PATCH 01/20] feat: Add `htmlName` prop --- packages/core/src/components/Form.tsx | 10 +++++++++- .../core/src/components/fields/ArrayField.tsx | 3 +++ .../core/src/components/fields/BooleanField.tsx | 1 + .../components/fields/LayoutMultiSchemaField.tsx | 1 + .../core/src/components/fields/StringField.tsx | 1 + .../components/templates/BaseInputTemplate.tsx | 3 ++- .../src/components/widgets/CheckboxWidget.tsx | 3 ++- .../src/components/widgets/CheckboxesWidget.tsx | 3 ++- .../core/src/components/widgets/HiddenWidget.tsx | 3 ++- .../core/src/components/widgets/RadioWidget.tsx | 3 ++- .../core/src/components/widgets/RatingWidget.tsx | 3 ++- .../core/src/components/widgets/SelectWidget.tsx | 3 ++- .../src/components/widgets/TextareaWidget.tsx | 3 ++- packages/core/test/SchemaField.test.jsx | 2 -- packages/utils/src/toFieldPathId.ts | 16 +++++++++++++--- packages/utils/src/types.ts | 12 ++++++++++++ 16 files changed, 56 insertions(+), 14 deletions(-) diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index c600f99415..61ca4fce2d 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -43,6 +43,7 @@ import { GlobalFormOptions, ERRORS_KEY, ID_KEY, + NameGeneratorFunction, } from '@rjsf/utils'; import _cloneDeep from 'lodash/cloneDeep'; import _get from 'lodash/get'; @@ -196,6 +197,7 @@ export interface FormProps ); } @@ -675,6 +676,7 @@ class ArrayField ); } @@ -722,6 +724,7 @@ class ArrayField ); } diff --git a/packages/core/src/components/fields/BooleanField.tsx b/packages/core/src/components/fields/BooleanField.tsx index 98570fcebc..6a3ec82165 100644 --- a/packages/core/src/components/fields/BooleanField.tsx +++ b/packages/core/src/components/fields/BooleanField.tsx @@ -116,6 +116,7 @@ function BooleanField ); } diff --git a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx index 3a089fd2b0..383a72fb20 100644 --- a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx +++ b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx @@ -220,6 +220,7 @@ export default function LayoutMultiSchemaField< onFocus={onFocus} value={selectedOption} options={widgetOptions} + htmlName={fieldPathId.name} /> ); diff --git a/packages/core/src/components/fields/StringField.tsx b/packages/core/src/components/fields/StringField.tsx index 4af1b2e967..7c0e0896ff 100644 --- a/packages/core/src/components/fields/StringField.tsx +++ b/packages/core/src/components/fields/StringField.tsx @@ -74,6 +74,7 @@ function StringField ); } diff --git a/packages/core/src/components/templates/BaseInputTemplate.tsx b/packages/core/src/components/templates/BaseInputTemplate.tsx index 24eda9c466..f827a3cdfe 100644 --- a/packages/core/src/components/templates/BaseInputTemplate.tsx +++ b/packages/core/src/components/templates/BaseInputTemplate.tsx @@ -23,6 +23,7 @@ export default function BaseInputTemplate< const { id, name, // remove this from ...rest + htmlName, value, readonly, disabled, @@ -77,7 +78,7 @@ export default function BaseInputTemplate< <> ) { const DescriptionFieldTemplate = getTemplate<'DescriptionFieldTemplate', T, S, F>( 'DescriptionFieldTemplate', @@ -73,7 +74,7 @@ function CheckboxWidget) { const checkboxesValues = Array.isArray(value) ? value : [value]; @@ -62,7 +63,7 @@ function CheckboxesWidget({ id, value, + htmlName, }: WidgetProps) { - return ; + return ; } export default HiddenWidget; diff --git a/packages/core/src/components/widgets/RadioWidget.tsx b/packages/core/src/components/widgets/RadioWidget.tsx index 7873a65ecd..cd227dcd5f 100644 --- a/packages/core/src/components/widgets/RadioWidget.tsx +++ b/packages/core/src/components/widgets/RadioWidget.tsx @@ -26,6 +26,7 @@ function RadioWidget) { const { enumOptions, enumDisabled, inline, emptyValue } = options; @@ -57,7 +58,7 @@ function RadioWidget) { const { stars = 5, shape = 'star' } = options; @@ -117,7 +118,7 @@ export default function RatingWidget< ) { const { enumOptions, enumDisabled, emptyValue: optEmptyVal } = options; const emptyValue = multiple ? [] : ''; @@ -72,7 +73,7 @@ function SelectWidget) { const handleChange = useCallback( ({ target: { value } }: ChangeEvent) => onChange(value === '' ? options.emptyValue : value), @@ -36,7 +37,7 @@ function TextareaWidget { globalFormOptions: { idPrefix: DEFAULT_ID_PREFIX, idSeparator: DEFAULT_ID_SEPARATOR, - experimental_componentUpdateStrategy: undefined, }, }); }); @@ -94,7 +93,6 @@ describe('SchemaField', () => { globalFormOptions: { idPrefix: DEFAULT_ID_PREFIX, idSeparator: DEFAULT_ID_SEPARATOR, - experimental_componentUpdateStrategy: undefined, }, }); }); diff --git a/packages/utils/src/toFieldPathId.ts b/packages/utils/src/toFieldPathId.ts index aed0f84a6a..8d70160f85 100644 --- a/packages/utils/src/toFieldPathId.ts +++ b/packages/utils/src/toFieldPathId.ts @@ -4,12 +4,12 @@ import { FieldPathId, FieldPathList, GlobalFormOptions } from './types'; /** Constructs the `FieldPathId` for `fieldPath`. If `parentPathId` is provided, the `fieldPath` is appended to the end * of the parent path. Then the `ID_KEY` of the resulting `FieldPathId` is constructed from the `idPrefix` and * `idSeparator` contained within the `globalFormOptions`. If `fieldPath` is passed as an empty string, it will simply - * generate the path from the `parentPath` (if provided) and the `idPrefix` and `idSeparator` + * generate the path from the `parentPath` (if provided) and the `idPrefix` and `idSeparator`. If a `nameGenerator` + * is provided in `globalFormOptions`, it will also generate the HTML `name` attribute. * * @param fieldPath - The property name or array index of the current field element * @param globalFormOptions - The `GlobalFormOptions` used to get the `idPrefix` and `idSeparator` * @param [parentPath] - The optional `FieldPathId` or `FieldPathList` of the parent element for this field element - * @returns - The `FieldPathId` for the given `fieldPath` and the optional `parentPathId` */ export default function toFieldPathId( fieldPath: string | number, @@ -20,5 +20,15 @@ export default function toFieldPathId( const childPath = fieldPath === '' ? [] : [fieldPath]; const path = basePath ? basePath.concat(...childPath) : childPath; const id = [globalFormOptions.idPrefix, ...path].join(globalFormOptions.idSeparator); - return { path, [ID_KEY]: id }; + + // Generate name attribute if nameGenerator is provided + let name: string | undefined; + if (globalFormOptions.nameGenerator && path.length > 0) { + // Determine element type based on the last element in the path + const lastPathElement = path[path.length - 1]; + const elementType = typeof lastPathElement === 'number' ? 'array' : 'object'; + name = globalFormOptions.nameGenerator(path, elementType); + } + + return { path, [ID_KEY]: id, ...(name !== undefined && { name }) }; } diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 82be3fe536..6c95c1a670 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -36,6 +36,9 @@ export type FormContextType = GenericObjectType; */ export type TestIdShape = Record; +/** Function to generate HTML name attributes from path segments */ +export type NameGeneratorFunction = (path: FieldPathList, elementType: 'object' | 'array') => string; + /** Experimental feature that specifies the Array `minItems` default form state behavior */ export type Experimental_ArrayMinItems = { @@ -176,6 +179,8 @@ export type FieldPathId = { $id: string; /** The path for a field */ path: FieldPathList; + /** The optional HTML name attribute for a field, generated by nameGenerator if provided */ + name?: string; }; /** Type describing a name used for a field in the `PathSchema` */ @@ -421,6 +426,11 @@ export type GlobalFormOptions = { readonly idSeparator: string; /** The component update strategy used by the Form and its fields for performance optimization */ readonly experimental_componentUpdateStrategy?: 'customDeep' | 'shallow' | 'always'; + /** Optional function to generate custom HTML name attributes for form elements. Receives the field path segments + * and element type (object or array), and returns a custom name string. This allows backends like PHP/Rails + * (`root[tasks][0][title]`) or Django (`root__tasks-0__title`) to receive form data in their expected format. + */ + readonly nameGenerator?: NameGeneratorFunction; }; /** The object containing the registered core, theme and custom fields and widgets as well as the root schema, form @@ -891,6 +901,8 @@ export interface WidgetProps Date: Mon, 6 Oct 2025 22:05:47 +0200 Subject: [PATCH 02/20] feat: Add `idPrefix` arg to `nameGeneratorFunction` --- packages/utils/src/toFieldPathId.ts | 5 +---- packages/utils/src/types.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/utils/src/toFieldPathId.ts b/packages/utils/src/toFieldPathId.ts index 8d70160f85..5ef2b63a38 100644 --- a/packages/utils/src/toFieldPathId.ts +++ b/packages/utils/src/toFieldPathId.ts @@ -24,10 +24,7 @@ export default function toFieldPathId( // Generate name attribute if nameGenerator is provided let name: string | undefined; if (globalFormOptions.nameGenerator && path.length > 0) { - // Determine element type based on the last element in the path - const lastPathElement = path[path.length - 1]; - const elementType = typeof lastPathElement === 'number' ? 'array' : 'object'; - name = globalFormOptions.nameGenerator(path, elementType); + name = globalFormOptions.nameGenerator(path, globalFormOptions.idPrefix); } return { path, [ID_KEY]: id, ...(name !== undefined && { name }) }; diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 6c95c1a670..340b4f57d6 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -37,7 +37,7 @@ export type FormContextType = GenericObjectType; export type TestIdShape = Record; /** Function to generate HTML name attributes from path segments */ -export type NameGeneratorFunction = (path: FieldPathList, elementType: 'object' | 'array') => string; +export type NameGeneratorFunction = (path: FieldPathList, idPrefix: string) => string; /** Experimental feature that specifies the Array `minItems` default form state behavior */ From 06d1e571dad60130031c157839c92b45ef046995 Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Mon, 6 Oct 2025 22:06:17 +0200 Subject: [PATCH 03/20] fix: Wrong prop order --- packages/core/src/components/widgets/SelectWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/components/widgets/SelectWidget.tsx b/packages/core/src/components/widgets/SelectWidget.tsx index ade877239b..d4922b7952 100644 --- a/packages/core/src/components/widgets/SelectWidget.tsx +++ b/packages/core/src/components/widgets/SelectWidget.tsx @@ -73,7 +73,7 @@ function SelectWidget Date: Tue, 7 Oct 2025 21:29:39 +0200 Subject: [PATCH 04/20] feat: Add common name generators --- packages/utils/src/index.ts | 5 ++ packages/utils/src/nameGenerators.ts | 30 ++++++++++ packages/utils/test/nameGenerators.test.ts | 65 ++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 packages/utils/src/nameGenerators.ts create mode 100644 packages/utils/test/nameGenerators.test.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d6f2c4c50f..2bdd5deec2 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -71,6 +71,9 @@ import useDeepCompareMemo from './useDeepCompareMemo'; import utcToLocal from './utcToLocal'; import validationDataMerge from './validationDataMerge'; import withIdRefPrefix from './withIdRefPrefix'; +import getOptionMatchingSimpleDiscriminator from './getOptionMatchingSimpleDiscriminator'; +import getChangedFields from './getChangedFields'; +import { bracketNameGenerator, dotNotationNameGenerator } from './nameGenerators'; export * from './types'; export * from './enums'; @@ -155,6 +158,8 @@ export { utcToLocal, validationDataMerge, withIdRefPrefix, + bracketNameGenerator, + dotNotationNameGenerator, }; export type { ComponentUpdateStrategy } from './shouldRender'; diff --git a/packages/utils/src/nameGenerators.ts b/packages/utils/src/nameGenerators.ts new file mode 100644 index 0000000000..8f641bd89c --- /dev/null +++ b/packages/utils/src/nameGenerators.ts @@ -0,0 +1,30 @@ +import { NameGeneratorFunction, FieldPathList } from './types'; + +/** + * Generates bracketed names + * Example: root[tasks][0][title] + */ +export const bracketNameGenerator: NameGeneratorFunction = (path: FieldPathList, idPrefix: string): string => { + if (!path || path.length === 0) { + return idPrefix; + } + + return path.reduce((acc, pathUnit, index) => { + if (index === 0) { + return `${idPrefix}[${String(pathUnit)}]`; + } + return `${acc}[${String(pathUnit)}]`; + }, ''); +}; + +/** + * Generates dot-notation names + * Example: root.tasks.0.title + */ +export const dotNotationNameGenerator: NameGeneratorFunction = (path: FieldPathList, idPrefix: string): string => { + if (!path || path.length === 0) { + return idPrefix; + } + + return `${idPrefix}.${path.map(String).join('.')}`; +}; diff --git a/packages/utils/test/nameGenerators.test.ts b/packages/utils/test/nameGenerators.test.ts new file mode 100644 index 0000000000..231b7ddab5 --- /dev/null +++ b/packages/utils/test/nameGenerators.test.ts @@ -0,0 +1,65 @@ +import { bracketNameGenerator, dotNotationNameGenerator } from '../src'; + +describe('bracketNameGenerator()', () => { + test('returns "root" for empty path', () => { + expect(bracketNameGenerator([], 'root')).toBe('root'); + }); + + test('generates name for single string segment', () => { + expect(bracketNameGenerator(['firstName'], 'root')).toBe('root[firstName]'); + }); + + test('generates name for single number segment (array index)', () => { + expect(bracketNameGenerator([0], 'root')).toBe('root[0]'); + }); + + test('generates name for nested object path', () => { + expect(bracketNameGenerator(['user', 'address', 'city'], 'root')).toBe('root[user][address][city]'); + }); + + test('generates name for array with object properties', () => { + expect(bracketNameGenerator(['tasks', 0, 'title'], 'root')).toBe('root[tasks][0][title]'); + }); + + test('generates name for nested arrays', () => { + expect(bracketNameGenerator(['matrix', 0, 1], 'root')).toBe('root[matrix][0][1]'); + }); + + test('generates name for complex nested structure', () => { + expect(bracketNameGenerator(['users', 0, 'addresses', 1, 'street'], 'root')).toBe( + 'root[users][0][addresses][1][street]', + ); + }); +}); + +describe('dotNotationNameGenerator()', () => { + test('returns "root" for empty path', () => { + expect(dotNotationNameGenerator([], 'root')).toBe('root'); + }); + + test('generates name for single string segment', () => { + expect(dotNotationNameGenerator(['firstName'], 'root')).toBe('root.firstName'); + }); + + test('generates name for single number segment (array index)', () => { + expect(dotNotationNameGenerator([0], 'root')).toBe('root.0'); + }); + + test('generates name for nested object path', () => { + expect(dotNotationNameGenerator(['user', 'address', 'city'], 'root')).toBe('root.user.address.city'); + }); + + test('generates name for array with object properties', () => { + expect(dotNotationNameGenerator(['tasks', 0, 'title'], 'root')).toBe('root.tasks.0.title'); + }); + + test('generates name for nested arrays', () => { + expect(dotNotationNameGenerator(['matrix', 0, 1], 'root')).toBe('root.matrix.0.1'); + }); + + test('generates name for complex nested structure', () => { + expect(dotNotationNameGenerator(['users', 0, 'addresses', 1, 'street'], 'root')).toBe( + 'root.users.0.addresses.1.street', + ); + }); +}); From 8be278287d821b84bfeabf7ff39d56b9e4bfaabf Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Tue, 14 Oct 2025 21:06:49 +0200 Subject: [PATCH 05/20] feat: Handle multiple values field(s) --- .../core/src/components/fields/ArrayField.tsx | 27 ++++- packages/utils/src/index.ts | 2 - packages/utils/src/nameGenerators.ts | 19 +++- packages/utils/src/toFieldPathId.ts | 5 +- packages/utils/src/types.ts | 2 +- packages/utils/test/nameGenerators.test.ts | 24 ++++ packages/utils/test/toFieldPathId.test.ts | 106 ++++++++++++++++++ 7 files changed, 172 insertions(+), 13 deletions(-) diff --git a/packages/core/src/components/fields/ArrayField.tsx b/packages/core/src/components/fields/ArrayField.tsx index c6e1a78f30..3e31637601 100644 --- a/packages/core/src/components/fields/ArrayField.tsx +++ b/packages/core/src/components/fields/ArrayField.tsx @@ -597,11 +597,16 @@ class ArrayField(uiSchema, globalUiOptions); const Widget = getWidget(schema, widget, widgets); const label = uiTitle ?? schema.title ?? name; const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions); + + // For custom widgets with multiple=true, generate a fieldPathId with isMultiValue flag + const multiValueFieldPathId = toFieldPathId('', globalFormOptions, fieldPathId, true); + return ( ); } @@ -648,13 +653,18 @@ class ArrayField(itemsSchema, uiSchema); const { widget = 'select', title: uiTitle, ...options } = getUiOptions(uiSchema, globalUiOptions); const Widget = getWidget(schema, widget, widgets); const label = uiTitle ?? schema.title ?? name; const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions); + + // For multi-select widgets, generate a fieldPathId with isMultiValue flag + const multiValueFieldPathId = toFieldPathId('', globalFormOptions, fieldPathId, true); + return ( ); } @@ -699,11 +709,16 @@ class ArrayField(uiSchema, globalUiOptions); const Widget = getWidget(schema, widget, widgets); const label = uiTitle ?? schema.title ?? name; const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions); + + // For file widgets with multiple=true, generate a fieldPathId with isMultiValue flag + const multiValueFieldPathId = toFieldPathId('', globalFormOptions, fieldPathId, true); + return ( ); } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 2bdd5deec2..6164c849e2 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -71,8 +71,6 @@ import useDeepCompareMemo from './useDeepCompareMemo'; import utcToLocal from './utcToLocal'; import validationDataMerge from './validationDataMerge'; import withIdRefPrefix from './withIdRefPrefix'; -import getOptionMatchingSimpleDiscriminator from './getOptionMatchingSimpleDiscriminator'; -import getChangedFields from './getChangedFields'; import { bracketNameGenerator, dotNotationNameGenerator } from './nameGenerators'; export * from './types'; diff --git a/packages/utils/src/nameGenerators.ts b/packages/utils/src/nameGenerators.ts index 8f641bd89c..9ab7f2870f 100644 --- a/packages/utils/src/nameGenerators.ts +++ b/packages/utils/src/nameGenerators.ts @@ -3,25 +3,38 @@ import { NameGeneratorFunction, FieldPathList } from './types'; /** * Generates bracketed names * Example: root[tasks][0][title] + * For multi-value fields (checkboxes, multi-select): root[hobbies][] */ -export const bracketNameGenerator: NameGeneratorFunction = (path: FieldPathList, idPrefix: string): string => { +export const bracketNameGenerator: NameGeneratorFunction = ( + path: FieldPathList, + idPrefix: string, + isMultiValue?: boolean, +): string => { if (!path || path.length === 0) { return idPrefix; } - return path.reduce((acc, pathUnit, index) => { + const baseName = path.reduce((acc, pathUnit, index) => { if (index === 0) { return `${idPrefix}[${String(pathUnit)}]`; } return `${acc}[${String(pathUnit)}]`; }, ''); + + // For multi-value fields, append [] to allow multiple values with the same name + return isMultiValue ? `${baseName}[]` : baseName; }; /** * Generates dot-notation names * Example: root.tasks.0.title + * Multi-value fields are handled the same as single-value fields in dot notation */ -export const dotNotationNameGenerator: NameGeneratorFunction = (path: FieldPathList, idPrefix: string): string => { +export const dotNotationNameGenerator: NameGeneratorFunction = ( + path: FieldPathList, + idPrefix: string, + _isMultiValue?: boolean, +): string => { if (!path || path.length === 0) { return idPrefix; } diff --git a/packages/utils/src/toFieldPathId.ts b/packages/utils/src/toFieldPathId.ts index 5ef2b63a38..d7b75c1748 100644 --- a/packages/utils/src/toFieldPathId.ts +++ b/packages/utils/src/toFieldPathId.ts @@ -10,11 +10,14 @@ import { FieldPathId, FieldPathList, GlobalFormOptions } from './types'; * @param fieldPath - The property name or array index of the current field element * @param globalFormOptions - The `GlobalFormOptions` used to get the `idPrefix` and `idSeparator` * @param [parentPath] - The optional `FieldPathId` or `FieldPathList` of the parent element for this field element + * @param [isMultiValue] - Optional flag indicating this field accepts multiple values + * @returns - The `FieldPathId` for the given `fieldPath` and the optional `parentPathId` */ export default function toFieldPathId( fieldPath: string | number, globalFormOptions: GlobalFormOptions, parentPath?: FieldPathId | FieldPathList, + isMultiValue?: boolean, ): FieldPathId { const basePath = Array.isArray(parentPath) ? parentPath : parentPath?.path; const childPath = fieldPath === '' ? [] : [fieldPath]; @@ -24,7 +27,7 @@ export default function toFieldPathId( // Generate name attribute if nameGenerator is provided let name: string | undefined; if (globalFormOptions.nameGenerator && path.length > 0) { - name = globalFormOptions.nameGenerator(path, globalFormOptions.idPrefix); + name = globalFormOptions.nameGenerator(path, globalFormOptions.idPrefix, isMultiValue); } return { path, [ID_KEY]: id, ...(name !== undefined && { name }) }; diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 340b4f57d6..73741281a9 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -37,7 +37,7 @@ export type FormContextType = GenericObjectType; export type TestIdShape = Record; /** Function to generate HTML name attributes from path segments */ -export type NameGeneratorFunction = (path: FieldPathList, idPrefix: string) => string; +export type NameGeneratorFunction = (path: FieldPathList, idPrefix: string, isMultiValue?: boolean) => string; /** Experimental feature that specifies the Array `minItems` default form state behavior */ diff --git a/packages/utils/test/nameGenerators.test.ts b/packages/utils/test/nameGenerators.test.ts index 231b7ddab5..a9456abf0a 100644 --- a/packages/utils/test/nameGenerators.test.ts +++ b/packages/utils/test/nameGenerators.test.ts @@ -30,6 +30,22 @@ describe('bracketNameGenerator()', () => { 'root[users][0][addresses][1][street]', ); }); + + test('appends [] for multi-value fields (checkboxes, multi-select)', () => { + expect(bracketNameGenerator(['hobbies'], 'root', true)).toBe('root[hobbies][]'); + }); + + test('does not append [] when isMultiValue is false', () => { + expect(bracketNameGenerator(['hobbies'], 'root', false)).toBe('root[hobbies]'); + }); + + test('does not append [] when isMultiValue is undefined', () => { + expect(bracketNameGenerator(['hobbies'], 'root')).toBe('root[hobbies]'); + }); + + test('appends [] to nested path when isMultiValue is true', () => { + expect(bracketNameGenerator(['user', 'hobbies'], 'root', true)).toBe('root[user][hobbies][]'); + }); }); describe('dotNotationNameGenerator()', () => { @@ -62,4 +78,12 @@ describe('dotNotationNameGenerator()', () => { 'root.users.0.addresses.1.street', ); }); + + test('isMultiValue flag has no effect in dot notation', () => { + expect(dotNotationNameGenerator(['hobbies'], 'root', true)).toBe('root.hobbies'); + }); + + test('isMultiValue flag false has no effect in dot notation', () => { + expect(dotNotationNameGenerator(['hobbies'], 'root', false)).toBe('root.hobbies'); + }); }); diff --git a/packages/utils/test/toFieldPathId.test.ts b/packages/utils/test/toFieldPathId.test.ts index 37aa611062..6fbbc974c2 100644 --- a/packages/utils/test/toFieldPathId.test.ts +++ b/packages/utils/test/toFieldPathId.test.ts @@ -74,4 +74,110 @@ describe('toFieldPathId()', () => { path: [...parentPath], }); }); + + describe('with nameGenerator', () => { + const phpNameGenerator = (path: (string | number)[], idPrefix: string) => { + if (path.length === 0) { + return idPrefix; + } + const segments = path.map((segment) => `[${segment}]`).join(''); + return `${idPrefix}${segments}`; + }; + + const GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR = { + idPrefix: DEFAULT_ID_PREFIX, + idSeparator: DEFAULT_ID_SEPARATOR, + nameGenerator: phpNameGenerator, + }; + + test('generates name for string fieldPath', () => { + const path = 'firstName'; + const result = toFieldPathId(path, GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR); + expect(result).toEqual({ + [ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}${path}`, + path: [path], + name: 'root[firstName]', + }); + }); + + test('generates name for nested object path', () => { + const parentPath = ['tasks', 0]; + const path = 'title'; + const result = toFieldPathId(path, GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR, parentPath); + expect(result).toEqual({ + [ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}tasks${DEFAULT_ID_SEPARATOR}0${DEFAULT_ID_SEPARATOR}${path}`, + path: [...parentPath, path], + name: 'root[tasks][0][title]', + }); + }); + + test('generates name for array index', () => { + const parentPath = ['listOfStrings']; + const path = 0; + const result = toFieldPathId(path, GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR, parentPath); + expect(result).toEqual({ + [ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}listOfStrings${DEFAULT_ID_SEPARATOR}${path}`, + path: [...parentPath, path], + name: 'root[listOfStrings][0]', + }); + }); + + test('does not generate name for empty path', () => { + const result = toFieldPathId('', GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR); + expect(result).toEqual({ + [ID_KEY]: DEFAULT_ID_PREFIX, + path: [], + }); + }); + + test('generates name with isMultiValue flag for multi-select fields', () => { + const phpMultiValueNameGenerator = (path: (string | number)[], idPrefix: string, isMultiValue?: boolean) => { + if (path.length === 0) { + return idPrefix; + } + const segments = path.map((segment) => `[${segment}]`).join(''); + const baseName = `${idPrefix}${segments}`; + return isMultiValue ? `${baseName}[]` : baseName; + }; + + const GLOBAL_FORM_OPTIONS_WITH_MULTI_VALUE = { + idPrefix: DEFAULT_ID_PREFIX, + idSeparator: DEFAULT_ID_SEPARATOR, + nameGenerator: phpMultiValueNameGenerator, + }; + + const parentPath = ['hobbies']; + const result = toFieldPathId('', GLOBAL_FORM_OPTIONS_WITH_MULTI_VALUE, parentPath, true); + expect(result).toEqual({ + [ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}hobbies`, + path: parentPath, + name: 'root[hobbies][]', + }); + }); + + test('generates name without brackets when isMultiValue is false', () => { + const phpMultiValueNameGenerator = (path: (string | number)[], idPrefix: string, isMultiValue?: boolean) => { + if (path.length === 0) { + return idPrefix; + } + const segments = path.map((segment) => `[${segment}]`).join(''); + const baseName = `${idPrefix}${segments}`; + return isMultiValue ? `${baseName}[]` : baseName; + }; + + const GLOBAL_FORM_OPTIONS_WITH_MULTI_VALUE = { + idPrefix: DEFAULT_ID_PREFIX, + idSeparator: DEFAULT_ID_SEPARATOR, + nameGenerator: phpMultiValueNameGenerator, + }; + + const parentPath = ['firstName']; + const result = toFieldPathId('', GLOBAL_FORM_OPTIONS_WITH_MULTI_VALUE, parentPath, false); + expect(result).toEqual({ + [ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}firstName`, + path: parentPath, + name: 'root[firstName]', + }); + }); + }); }); From 2f949451f4f92b6bb665b11f6389127e47171164 Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Wed, 15 Oct 2025 21:10:12 +0200 Subject: [PATCH 06/20] fix: Use consistent variable name Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com> --- packages/core/src/components/fields/ArrayField.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/components/fields/ArrayField.tsx b/packages/core/src/components/fields/ArrayField.tsx index 3e31637601..41f9e76919 100644 --- a/packages/core/src/components/fields/ArrayField.tsx +++ b/packages/core/src/components/fields/ArrayField.tsx @@ -609,7 +609,7 @@ class ArrayField Date: Wed, 15 Oct 2025 21:44:14 +0200 Subject: [PATCH 07/20] docs: Describe `nameGenerator` prop & add feature in the migration guide --- packages/core/src/components/Form.tsx | 2 + .../docs/docs/api-reference/form-props.md | 41 +++++++++++++++++++ .../migration-guides/v6.x upgrade guide.md | 13 +++++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index 61ca4fce2d..2fe5faa91f 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -197,6 +197,8 @@ export interface FormProps, documen This will render `` instead of `` when rendering `first`. +## nameGenerator + +The `nameGenerator` prop allows you to customize how HTML `name` attributes are generated for form fields. This is essential when submitting form data to backend frameworks that expect specific naming conventions for structured data. + +**Default behavior:** When no `nameGenerator` is provided, the `name` attribute will equal the `id` attribute (e.g., `root_tasks_0_title`). + +RJSF provides two built-in generators: + +**`bracketNameGenerator`** - Generates bracket notation for PHP/Rails (e.g., `root[tasks][0][title]`). Automatically appends `[]` for multi-value fields like checkboxes. + +**`dotNotationNameGenerator`** - Generates dot notation for other frameworks (e.g., `root.tasks.0.title`). + +```tsx +import { Form } from '@rjsf/core'; +import { bracketNameGenerator, RJSFSchema } from '@rjsf/utils'; +import validator from '@rjsf/validator-ajv8'; + +const schema: RJSFSchema = { + type: 'object', + properties: { + firstName: { type: 'string' }, + tasks: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + }, + }, + }, + }, +}; + +render( +
, + document.getElementById('app'), +); +``` + +You can also create a custom generator by implementing the `NameGeneratorFunction` type, which receives `path` (array of path segments), `idPrefix` (typically `'root'`), and optional `isMultiValue` (boolean for multi-value fields). + ## liveOmit If `omitExtraData` and `liveOmit` are both set to true, then extra form data values that are not in any form field will be removed whenever `onChange` is called. Set to `false` by default. diff --git a/packages/docs/docs/migration-guides/v6.x upgrade guide.md b/packages/docs/docs/migration-guides/v6.x upgrade guide.md index 675cc24278..543248e4f2 100644 --- a/packages/docs/docs/migration-guides/v6.x upgrade guide.md +++ b/packages/docs/docs/migration-guides/v6.x upgrade guide.md @@ -430,6 +430,11 @@ export type GlobalFormOptions = { readonly idSeparator?: string; /** The component update strategy used by the Form and its fields for performance optimization */ readonly experimental_componentUpdateStrategy?: 'customDeep' | 'shallow' | 'always'; + /** Optional function to generate custom HTML name attributes for form elements. Receives the field path segments + * and element type (object or array), and returns a custom name string. This allows backends like PHP/Rails + * (`root[tasks][0][title]`) or Django (`root__tasks-0__title`) to receive form data in their expected format. + */ + readonly nameGenerator?: NameGeneratorFunction; }; ``` @@ -781,7 +786,7 @@ Many new or formerly internally private utility functions are available in `@rjs - `optionalControlsId(id: FieldPathId | string, element: 'Add' | 'Msg' | 'Remove')`: Return a consistent `id` for the optional data controls `element` - `shouldRenderOptionalField(registry: Registry, schema: S, required: boolean, uiSchema?: UiSchema): boolean`: Determines if this field should be rendered with the Optional Data Controls UI. - `sortedJSONStringify(object: unknown): string`: Stringifies an `object`, sorts object fields in consistent order before stringifying it. -- `toFieldPathId(fieldPath: string | number, globalFormOptions: GlobalFormOptions, parentPath?: FieldPathId | FieldPathList)`: Constructs the `FieldPathId` for `fieldPath` and the optional `parentPath`, using `globalFormOptions` +- `toFieldPathId(fieldPath: string | number, globalFormOptions: GlobalFormOptions, parentPath?: FieldPathId | FieldPathList, isMultiValue?: boolean)`: Constructs the `FieldPathId` for `fieldPath` and the optional `parentPath`, using `globalFormOptions` - `useDeepCompareMemo(newValue: T): T`: Hook that stores and returns a `T` value. If `newValue` is the same as the stored one, then the stored one is returned, otherwise `newValue` is stored and returned ### New validator-based utility functions @@ -826,3 +831,9 @@ const uiSchema: UiSchema = { This feature is fully backward compatible - existing forms using object-based `uiSchema.items` will continue to work without changes. See the [Dynamic UI Schema Examples](../api-reference/dynamic-ui-schema-examples.md) documentation for comprehensive examples and usage patterns. + +### Custom field `name` generation + +RJSF 6.x adds support for customizing how HTML `name` attributes are generated for form fields via the new [`nameGenerator`](../api-reference/form-props.md#namegenerator) prop. This enables proper form data submission to backend frameworks that expect specific naming conventions like bracket notation (`root[tasks][0][title]`) for PHP/Rails or dot notation (`root.tasks.0.title`) for other frameworks. + +The default behavior is unchanged if the prop is not provided. From 076b3b2f20c27e01988403be5d6f0f52d5f89386 Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Wed, 15 Oct 2025 21:56:20 +0200 Subject: [PATCH 08/20] docs: Add `@rjsf/utils` and `@rjsf/core` changelog entries --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 847ce604be..6f3164599d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ should change the heading of the (upcoming) version to include a major version b - Updated `ObjectField` to refactor the code from a class component to two stateless functional components, replacing the 3 generator-props with the 4 memoized props mentioned in the `@rjsf/utils` changes - Updated `Form` to "memoize" the `fieldPathId` and `registry` into the `FormState`, adding a `toIChangeEvent()` helper to restrict the state returned on the `IChangeEvent` interface callbacks - Updated `FieldTemplate`, `ObjectFieldTemplate` and `WrapIfAdditionalTemplate` to rename the old `additionalProperties` interface props to the new ones +- Added `nameGenerator` prop to `Form` component to enable custom HTML `name` attribute generation for form fields ## @rjsf/daisyui @@ -76,6 +77,13 @@ should change the heading of the (upcoming) version to include a major version b - BREAKING CHANGE: Updated `ObjectFieldTemplateProps` to replace the `onAddClick()` callback generator prop with the `onAddProperty()` callback prop - Added new hook `useDeepCompareMemo()` and its associated tests +## @rjsf/utils + +- Added `NameGeneratorFunction` type and two built-in name generators: `bracketNameGenerator` and `dotNotationNameGenerator` +- Updated `GlobalFormOptions` type to include optional `nameGenerator` field +- Updated `toFieldPathId()` function to support name generation via the `nameGenerator` option in `GlobalFormOptions` +- Added `htmlName` field to `WidgetProps` interface to provide the generated HTML `name` attribute to widgets + ## Dev / docs / playground - Updated the `formTests.tsx` snapshots to add an `anyOf` of all arrays with different item types and removed the disabling of the optional data controls feature for the optional object with oneOfs - Updated the snapshots in all of the themes accordingly @@ -86,6 +94,7 @@ should change the heading of the (upcoming) version to include a major version b - Updated the `custom-templates.md` documentation to reflect the `additionalProperties`-based interface props replacement - Updated the `utility-functions.mf` documenation to add the new `useDeepCompareMemo()` hook - Updated the `v6.x upgrade guide.md` documentation to add the BREAKING CHANGES to the `FieldTemplateProps`, `ObjectFieldTemplateProps` and `WrapIfAdditionalTemplateProps` interface props changes and the `useDeepCompareMemo()` hook +- Added documentation for the `nameGenerator` prop in `form-props.md` and v6.x upgrade guide # 6.0.0-beta.21 @@ -209,7 +218,7 @@ should change the heading of the (upcoming) version to include a major version b - `ObjectField` and `ArrayField` to use `toFieldPathId` instead of `toIdSchema()` to generate the `fieldPathId`s of all its children - Updated the `onChange` handling of fields to make `path` required and either pass it straight through, or use the `fieldPathId.path` instead of using an empty array or appending path information - Updated `Form` to use `toFieldPathId()` to generate `fieldPathId` instead of `idSchema`, always providing the `idPrefix` and `idSeparator` in the `globalFormOptions` and make the `path: FieldPathList` required - - Updated `LayoutGridField` to remove the `IdSchema` related code in favor of `FieldPathId` code + - Updated `LayoutGridField` to remove the `IdSchema` related code in favor of `FieldPathId` code - Also exported the `getTestRegistry()` function from the main `index.ts` to assist developers in creating `registry` object for tests - Updated all of the test to deal with the `idSchema` -> `fieldPathId` changes From 2ae911fd245d6ae3b4388d29851c6b6011eb9f1b Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Fri, 17 Oct 2025 07:41:25 +0200 Subject: [PATCH 09/20] feat: Add `htmlName` to antd theme --- .../src/templates/BaseInputTemplate/index.tsx | 5 +++-- .../antd/src/widgets/CheckboxWidget/index.tsx | 5 +++-- .../src/widgets/CheckboxesWidget/index.tsx | 18 +++++++++++++++--- .../antd/src/widgets/RadioWidget/index.tsx | 5 +++-- .../antd/src/widgets/SelectWidget/index.tsx | 3 ++- .../antd/src/widgets/TextareaWidget/index.tsx | 16 ++++++++++++++-- 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/antd/src/templates/BaseInputTemplate/index.tsx b/packages/antd/src/templates/BaseInputTemplate/index.tsx index b7f0da42c7..872db8fb9e 100644 --- a/packages/antd/src/templates/BaseInputTemplate/index.tsx +++ b/packages/antd/src/templates/BaseInputTemplate/index.tsx @@ -30,6 +30,7 @@ export default function BaseInputTemplate< disabled, registry, id, + htmlName, onBlur, onChange, onChangeOverride, @@ -60,7 +61,7 @@ export default function BaseInputTemplate< (props: WidgetProps) { - const { autofocus, disabled, registry, id, label, hideLabel, onBlur, onChange, onFocus, readonly, value } = props; + const { autofocus, disabled, registry, id, htmlName, label, hideLabel, onBlur, onChange, onFocus, readonly, value } = + props; const { formContext } = registry; const { readonlyAsDisabled = true } = formContext as GenericObjectType; @@ -42,7 +43,7 @@ export default function CheckboxWidget< checked={typeof value === 'undefined' ? false : value} disabled={disabled || (readonlyAsDisabled && readonly)} id={id} - name={id} + name={htmlName || id} onChange={!readonly ? handleChange : undefined} {...extraProps} aria-describedby={ariaDescribedByIds(id)} diff --git a/packages/antd/src/widgets/CheckboxesWidget/index.tsx b/packages/antd/src/widgets/CheckboxesWidget/index.tsx index 66d3b2e51d..6f12145606 100644 --- a/packages/antd/src/widgets/CheckboxesWidget/index.tsx +++ b/packages/antd/src/widgets/CheckboxesWidget/index.tsx @@ -21,7 +21,19 @@ export default function CheckboxesWidget< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any, ->({ autofocus, disabled, registry, id, onBlur, onChange, onFocus, options, readonly, value }: WidgetProps) { +>({ + autofocus, + disabled, + registry, + id, + htmlName, + onBlur, + onChange, + onFocus, + options, + readonly, + value, +}: WidgetProps) { const { formContext } = registry; const { readonlyAsDisabled = true } = formContext as GenericObjectType; @@ -49,7 +61,7 @@ export default function CheckboxesWidget< <> ( ({ disabled, registry, id, onBlur, onChange, onFocus, options, placeholder, readonly, value }: WidgetProps) { +>({ + disabled, + registry, + id, + htmlName, + onBlur, + onChange, + onFocus, + options, + placeholder, + readonly, + value, +}: WidgetProps) { const { formContext } = registry; const { readonlyAsDisabled = true } = formContext as GenericObjectType; @@ -42,7 +54,7 @@ export default function TextareaWidget< Date: Fri, 17 Oct 2025 07:42:06 +0200 Subject: [PATCH 10/20] feat: Add `htmlName` to Chakra UI theme --- packages/chakra-ui/src/BaseInputTemplate/BaseInputTemplate.tsx | 3 ++- packages/chakra-ui/src/CheckboxWidget/CheckboxWidget.tsx | 3 ++- packages/chakra-ui/src/CheckboxesWidget/CheckboxesWidget.tsx | 3 ++- packages/chakra-ui/src/RadioWidget/RadioWidget.tsx | 3 ++- packages/chakra-ui/src/SelectWidget/SelectWidget.tsx | 3 ++- packages/chakra-ui/src/TextareaWidget/TextareaWidget.tsx | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/chakra-ui/src/BaseInputTemplate/BaseInputTemplate.tsx b/packages/chakra-ui/src/BaseInputTemplate/BaseInputTemplate.tsx index bf5c7e3537..2ce6d91489 100644 --- a/packages/chakra-ui/src/BaseInputTemplate/BaseInputTemplate.tsx +++ b/packages/chakra-ui/src/BaseInputTemplate/BaseInputTemplate.tsx @@ -21,6 +21,7 @@ export default function BaseInputTemplate< >(props: BaseInputTemplateProps) { const { id, + htmlName, type, value, label, @@ -60,7 +61,7 @@ export default function BaseInputTemplate< > (props: WidgetProps) { const { id, + htmlName, value, disabled, readonly, @@ -66,7 +67,7 @@ export default function CheckboxWidget< )} (props: WidgetProps) { const { id, + htmlName, disabled, options, value, @@ -70,7 +71,7 @@ export default function CheckboxesWidget< ({ id, + htmlName, options, value, required, @@ -58,7 +59,7 @@ export default function RadioWidget diff --git a/packages/chakra-ui/src/SelectWidget/SelectWidget.tsx b/packages/chakra-ui/src/SelectWidget/SelectWidget.tsx index 9c7e734b11..e66a690e93 100644 --- a/packages/chakra-ui/src/SelectWidget/SelectWidget.tsx +++ b/packages/chakra-ui/src/SelectWidget/SelectWidget.tsx @@ -23,6 +23,7 @@ export default function SelectWidget({ id, + htmlName, placeholder, value, label, @@ -52,7 +53,7 @@ export default function TextareaWidget< >