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',
+ );
+ });
+});