diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index e2966e0ae0..27cfc6045a 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -43,6 +43,7 @@ import { DEFAULT_ID_SEPARATOR, DEFAULT_ID_PREFIX, GlobalFormOptions, + NameGeneratorFunction, } from '@rjsf/utils'; import _cloneDeep from 'lodash/cloneDeep'; import _forEach from 'lodash/forEach'; @@ -198,6 +199,7 @@ export interface FormProps ); } @@ -668,6 +669,7 @@ class ArrayField ); } @@ -716,6 +718,7 @@ class ArrayField ); } diff --git a/packages/core/src/components/fields/BooleanField.tsx b/packages/core/src/components/fields/BooleanField.tsx index cb448804ee..c33a66dda7 100644 --- a/packages/core/src/components/fields/BooleanField.tsx +++ b/packages/core/src/components/fields/BooleanField.tsx @@ -117,6 +117,7 @@ function BooleanField ); } diff --git a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx index b38ebf2bb1..deb603e526 100644 --- a/packages/core/src/components/fields/LayoutMultiSchemaField.tsx +++ b/packages/core/src/components/fields/LayoutMultiSchemaField.tsx @@ -222,6 +222,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 4cf952ce68..fa7f119f15 100644 --- a/packages/core/src/components/fields/StringField.tsx +++ b/packages/core/src/components/fields/StringField.tsx @@ -75,6 +75,7 @@ function StringField ); } diff --git a/packages/core/src/components/templates/BaseInputTemplate.tsx b/packages/core/src/components/templates/BaseInputTemplate.tsx index 2d4a4ac4ac..e15c720b3d 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, @@ -78,7 +79,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/index.ts b/packages/utils/src/index.ts index 832c29c82b..305425aabd 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -66,6 +66,7 @@ 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'; @@ -145,6 +146,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/src/toFieldPathId.ts b/packages/utils/src/toFieldPathId.ts index aed0f84a6a..5ef2b63a38 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,12 @@ 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) { + 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 ce368b5bcb..89b426652d 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -35,6 +35,9 @@ 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; + /** Experimental feature that specifies the Array `minItems` default form state behavior */ export type Experimental_ArrayMinItems = { @@ -175,6 +178,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` */ @@ -414,6 +419,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 @@ -850,6 +860,8 @@ export interface WidgetProps { + 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', + ); + }); +});