Skip to content

Commit 85e8c6f

Browse files
committed
feat: Handle multiple values field(s)
1 parent f41f893 commit 85e8c6f

File tree

7 files changed

+171
-13
lines changed

7 files changed

+171
-13
lines changed

packages/core/src/components/fields/ArrayField.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -597,11 +597,16 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
597597
rawErrors,
598598
name,
599599
} = this.props;
600-
const { widgets, globalUiOptions, schemaUtils } = registry;
600+
601+
const { widgets, globalUiOptions, schemaUtils, globalFormOptions } = registry;
601602
const { widget, title: uiTitle, ...options } = getUiOptions<T[], S, F>(uiSchema, globalUiOptions);
602603
const Widget = getWidget<T[], S, F>(schema, widget, widgets);
603604
const label = uiTitle ?? schema.title ?? name;
604605
const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions);
606+
607+
// For custom widgets with multiple=true, generate a fieldPathId with isMultiValue flag
608+
const multiValueFieldPathId = toFieldPathId('', globalFormOptions, fieldPathId, true);
609+
605610
return (
606611
<Widget
607612
id={fieldPathId.$id}
@@ -624,7 +629,7 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
624629
placeholder={placeholder}
625630
autofocus={autofocus}
626631
rawErrors={rawErrors}
627-
htmlName={fieldPathId.name}
632+
htmlName={multiValueFieldPathId.name}
628633
/>
629634
);
630635
}
@@ -648,13 +653,18 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
648653
rawErrors,
649654
name,
650655
} = this.props;
651-
const { widgets, schemaUtils, globalUiOptions } = registry;
656+
657+
const { widgets, schemaUtils, globalUiOptions, globalFormOptions } = registry;
652658
const itemsSchema = schemaUtils.retrieveSchema(schema.items as S, items);
653659
const enumOptions = optionsList<T[], S, F>(itemsSchema, uiSchema);
654660
const { widget = 'select', title: uiTitle, ...options } = getUiOptions<T[], S, F>(uiSchema, globalUiOptions);
655661
const Widget = getWidget<T[], S, F>(schema, widget, widgets);
656662
const label = uiTitle ?? schema.title ?? name;
657663
const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions);
664+
665+
// For multi-select widgets, generate a fieldPathId with isMultiValue flag
666+
const multiValueFieldPathId = toFieldPathId('', globalFormOptions, fieldPathId, true);
667+
658668
return (
659669
<Widget
660670
id={fieldPathId.$id}
@@ -676,7 +686,7 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
676686
placeholder={placeholder}
677687
autofocus={autofocus}
678688
rawErrors={rawErrors}
679-
htmlName={fieldPathId.name}
689+
htmlName={multiValueFieldPathId.name}
680690
/>
681691
);
682692
}
@@ -699,11 +709,16 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
699709
formData: items = [],
700710
rawErrors,
701711
} = this.props;
702-
const { widgets, globalUiOptions, schemaUtils } = registry;
712+
713+
const { widgets, globalUiOptions, schemaUtils, globalFormOptions } = registry;
703714
const { widget = 'files', title: uiTitle, ...options } = getUiOptions<T[], S, F>(uiSchema, globalUiOptions);
704715
const Widget = getWidget<T[], S, F>(schema, widget, widgets);
705716
const label = uiTitle ?? schema.title ?? name;
706717
const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions);
718+
719+
// For file widgets with multiple=true, generate a fieldPathId with isMultiValue flag
720+
const multiValueFieldPathId = toFieldPathId('', globalFormOptions, fieldPathId, true);
721+
707722
return (
708723
<Widget
709724
options={options}
@@ -724,7 +739,7 @@ class ArrayField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
724739
rawErrors={rawErrors}
725740
label={label}
726741
hideLabel={!displayLabel}
727-
htmlName={fieldPathId.name}
742+
htmlName={multiValueFieldPathId.name}
728743
/>
729744
);
730745
}

packages/utils/src/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,6 @@ import unwrapErrorHandler from './unwrapErrorHandler';
7070
import utcToLocal from './utcToLocal';
7171
import validationDataMerge from './validationDataMerge';
7272
import withIdRefPrefix from './withIdRefPrefix';
73-
import getOptionMatchingSimpleDiscriminator from './getOptionMatchingSimpleDiscriminator';
74-
import getChangedFields from './getChangedFields';
7573
import { bracketNameGenerator, dotNotationNameGenerator } from './nameGenerators';
7674

7775
export * from './types';

packages/utils/src/nameGenerators.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,38 @@ import { NameGeneratorFunction, FieldPathList } from './types';
33
/**
44
* Generates bracketed names
55
* Example: root[tasks][0][title]
6+
* For multi-value fields (checkboxes, multi-select): root[hobbies][]
67
*/
7-
export const bracketNameGenerator: NameGeneratorFunction = (path: FieldPathList, idPrefix: string): string => {
8+
export const bracketNameGenerator: NameGeneratorFunction = (
9+
path: FieldPathList,
10+
idPrefix: string,
11+
isMultiValue?: boolean,
12+
): string => {
813
if (!path || path.length === 0) {
914
return idPrefix;
1015
}
1116

12-
return path.reduce<string>((acc, pathUnit, index) => {
17+
const baseName = path.reduce<string>((acc, pathUnit, index) => {
1318
if (index === 0) {
1419
return `${idPrefix}[${String(pathUnit)}]`;
1520
}
1621
return `${acc}[${String(pathUnit)}]`;
1722
}, '');
23+
24+
// For multi-value fields, append [] to allow multiple values with the same name
25+
return isMultiValue ? `${baseName}[]` : baseName;
1826
};
1927

2028
/**
2129
* Generates dot-notation names
2230
* Example: root.tasks.0.title
31+
* Multi-value fields are handled the same as single-value fields in dot notation
2332
*/
24-
export const dotNotationNameGenerator: NameGeneratorFunction = (path: FieldPathList, idPrefix: string): string => {
33+
export const dotNotationNameGenerator: NameGeneratorFunction = (
34+
path: FieldPathList,
35+
idPrefix: string,
36+
_isMultiValue?: boolean,
37+
): string => {
2538
if (!path || path.length === 0) {
2639
return idPrefix;
2740
}

packages/utils/src/toFieldPathId.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import { FieldPathId, FieldPathList, GlobalFormOptions } from './types';
1010
* @param fieldPath - The property name or array index of the current field element
1111
* @param globalFormOptions - The `GlobalFormOptions` used to get the `idPrefix` and `idSeparator`
1212
* @param [parentPath] - The optional `FieldPathId` or `FieldPathList` of the parent element for this field element
13+
* @param [isMultiValue] - Optional flag indicating this field accepts multiple values
1314
*/
1415
export default function toFieldPathId(
1516
fieldPath: string | number,
1617
globalFormOptions: GlobalFormOptions,
1718
parentPath?: FieldPathId | FieldPathList,
19+
isMultiValue?: boolean,
1820
): FieldPathId {
1921
const basePath = Array.isArray(parentPath) ? parentPath : parentPath?.path;
2022
const childPath = fieldPath === '' ? [] : [fieldPath];
@@ -24,7 +26,7 @@ export default function toFieldPathId(
2426
// Generate name attribute if nameGenerator is provided
2527
let name: string | undefined;
2628
if (globalFormOptions.nameGenerator && path.length > 0) {
27-
name = globalFormOptions.nameGenerator(path, globalFormOptions.idPrefix);
29+
name = globalFormOptions.nameGenerator(path, globalFormOptions.idPrefix, isMultiValue);
2830
}
2931

3032
return { path, [ID_KEY]: id, ...(name !== undefined && { name }) };

packages/utils/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export type FormContextType = GenericObjectType;
3636
export type TestIdShape = Record<string, string>;
3737

3838
/** Function to generate HTML name attributes from path segments */
39-
export type NameGeneratorFunction = (path: FieldPathList, idPrefix: string) => string;
39+
export type NameGeneratorFunction = (path: FieldPathList, idPrefix: string, isMultiValue?: boolean) => string;
4040

4141
/** Experimental feature that specifies the Array `minItems` default form state behavior
4242
*/

packages/utils/test/nameGenerators.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,22 @@ describe('bracketNameGenerator()', () => {
3030
'root[users][0][addresses][1][street]',
3131
);
3232
});
33+
34+
test('appends [] for multi-value fields (checkboxes, multi-select)', () => {
35+
expect(bracketNameGenerator(['hobbies'], 'root', true)).toBe('root[hobbies][]');
36+
});
37+
38+
test('does not append [] when isMultiValue is false', () => {
39+
expect(bracketNameGenerator(['hobbies'], 'root', false)).toBe('root[hobbies]');
40+
});
41+
42+
test('does not append [] when isMultiValue is undefined', () => {
43+
expect(bracketNameGenerator(['hobbies'], 'root')).toBe('root[hobbies]');
44+
});
45+
46+
test('appends [] to nested path when isMultiValue is true', () => {
47+
expect(bracketNameGenerator(['user', 'hobbies'], 'root', true)).toBe('root[user][hobbies][]');
48+
});
3349
});
3450

3551
describe('dotNotationNameGenerator()', () => {
@@ -62,4 +78,12 @@ describe('dotNotationNameGenerator()', () => {
6278
'root.users.0.addresses.1.street',
6379
);
6480
});
81+
82+
test('isMultiValue flag has no effect in dot notation', () => {
83+
expect(dotNotationNameGenerator(['hobbies'], 'root', true)).toBe('root.hobbies');
84+
});
85+
86+
test('isMultiValue flag false has no effect in dot notation', () => {
87+
expect(dotNotationNameGenerator(['hobbies'], 'root', false)).toBe('root.hobbies');
88+
});
6589
});

packages/utils/test/toFieldPathId.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,110 @@ describe('toFieldPathId()', () => {
7474
path: [...parentPath],
7575
});
7676
});
77+
78+
describe('with nameGenerator', () => {
79+
const phpNameGenerator = (path: (string | number)[], idPrefix: string) => {
80+
if (path.length === 0) {
81+
return idPrefix;
82+
}
83+
const segments = path.map((segment) => `[${segment}]`).join('');
84+
return `${idPrefix}${segments}`;
85+
};
86+
87+
const GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR = {
88+
idPrefix: DEFAULT_ID_PREFIX,
89+
idSeparator: DEFAULT_ID_SEPARATOR,
90+
nameGenerator: phpNameGenerator,
91+
};
92+
93+
test('generates name for string fieldPath', () => {
94+
const path = 'firstName';
95+
const result = toFieldPathId(path, GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR);
96+
expect(result).toEqual({
97+
[ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}${path}`,
98+
path: [path],
99+
name: 'root[firstName]',
100+
});
101+
});
102+
103+
test('generates name for nested object path', () => {
104+
const parentPath = ['tasks', 0];
105+
const path = 'title';
106+
const result = toFieldPathId(path, GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR, parentPath);
107+
expect(result).toEqual({
108+
[ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}tasks${DEFAULT_ID_SEPARATOR}0${DEFAULT_ID_SEPARATOR}${path}`,
109+
path: [...parentPath, path],
110+
name: 'root[tasks][0][title]',
111+
});
112+
});
113+
114+
test('generates name for array index', () => {
115+
const parentPath = ['listOfStrings'];
116+
const path = 0;
117+
const result = toFieldPathId(path, GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR, parentPath);
118+
expect(result).toEqual({
119+
[ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}listOfStrings${DEFAULT_ID_SEPARATOR}${path}`,
120+
path: [...parentPath, path],
121+
name: 'root[listOfStrings][0]',
122+
});
123+
});
124+
125+
test('does not generate name for empty path', () => {
126+
const result = toFieldPathId('', GLOBAL_FORM_OPTIONS_WITH_NAME_GENERATOR);
127+
expect(result).toEqual({
128+
[ID_KEY]: DEFAULT_ID_PREFIX,
129+
path: [],
130+
});
131+
});
132+
133+
test('generates name with isMultiValue flag for multi-select fields', () => {
134+
const phpMultiValueNameGenerator = (path: (string | number)[], idPrefix: string, isMultiValue?: boolean) => {
135+
if (path.length === 0) {
136+
return idPrefix;
137+
}
138+
const segments = path.map((segment) => `[${segment}]`).join('');
139+
const baseName = `${idPrefix}${segments}`;
140+
return isMultiValue ? `${baseName}[]` : baseName;
141+
};
142+
143+
const GLOBAL_FORM_OPTIONS_WITH_MULTI_VALUE = {
144+
idPrefix: DEFAULT_ID_PREFIX,
145+
idSeparator: DEFAULT_ID_SEPARATOR,
146+
nameGenerator: phpMultiValueNameGenerator,
147+
};
148+
149+
const parentPath = ['hobbies'];
150+
const result = toFieldPathId('', GLOBAL_FORM_OPTIONS_WITH_MULTI_VALUE, parentPath, true);
151+
expect(result).toEqual({
152+
[ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}hobbies`,
153+
path: parentPath,
154+
name: 'root[hobbies][]',
155+
});
156+
});
157+
158+
test('generates name without brackets when isMultiValue is false', () => {
159+
const phpMultiValueNameGenerator = (path: (string | number)[], idPrefix: string, isMultiValue?: boolean) => {
160+
if (path.length === 0) {
161+
return idPrefix;
162+
}
163+
const segments = path.map((segment) => `[${segment}]`).join('');
164+
const baseName = `${idPrefix}${segments}`;
165+
return isMultiValue ? `${baseName}[]` : baseName;
166+
};
167+
168+
const GLOBAL_FORM_OPTIONS_WITH_MULTI_VALUE = {
169+
idPrefix: DEFAULT_ID_PREFIX,
170+
idSeparator: DEFAULT_ID_SEPARATOR,
171+
nameGenerator: phpMultiValueNameGenerator,
172+
};
173+
174+
const parentPath = ['firstName'];
175+
const result = toFieldPathId('', GLOBAL_FORM_OPTIONS_WITH_MULTI_VALUE, parentPath, false);
176+
expect(result).toEqual({
177+
[ID_KEY]: `${DEFAULT_ID_PREFIX}${DEFAULT_ID_SEPARATOR}firstName`,
178+
path: parentPath,
179+
name: 'root[firstName]',
180+
});
181+
});
182+
});
77183
});

0 commit comments

Comments
 (0)