Skip to content

Fix 4676 - Add support for arrays in the LayoutGridField #4724

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ should change the heading of the (upcoming) version to include a major version b
- Updated all of the `XxxxField` components and `Form` to handle the new `path` parameter in `FieldProps.onChange`, making `Form` queue up changes so that they are all processed and no data is lost, fixing [#3367](https://github.com/rjsf-team/react-jsonschema-form/issues/3367)
- Updated a bug in `AltDateWidget` related to the `clear` button not working after the fix for [#3367](https://github.com/rjsf-team/react-jsonschema-form/issues/3367)
- Fixed the missing hook dependencies for the `CheckboxesWidget` so that they work properly
- Added support for array indexes in the `LayoutGridField`, fixing [#4676](https://github.com/rjsf-team/react-jsonschema-form/issues/4676)

## @rjsf/chakra-ui

Expand Down
72 changes: 66 additions & 6 deletions packages/core/src/components/fields/LayoutGridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
UI_OPTIONS_KEY,
UI_GLOBAL_OPTIONS_KEY,
UiSchema,
ITEMS_KEY,
} from '@rjsf/utils';
import cloneDeep from 'lodash/cloneDeep';
import each from 'lodash/each';
Expand All @@ -39,6 +40,7 @@ import isObject from 'lodash/isObject';
import isPlainObject from 'lodash/isPlainObject';
import isString from 'lodash/isString';
import isUndefined from 'lodash/isUndefined';
import last from 'lodash/last';
import set from 'lodash/set';

/** The enumeration of the three different Layout GridTemplate type values
Expand Down Expand Up @@ -130,6 +132,15 @@ function getNonNullishValue<T = unknown>(value?: T, fallback?: T): T | undefined
return value ?? fallback;
}

/** Detects if a `str` is made up entirely of numeric characters
*
* @param str - The string to check to see if it is a numeric index
* @return - True if the string consists entirely of numeric characters
*/
function isNumericIndex(str: string) {
return /^\d+?$/.test(str); // Matches positive integers
}

/** The `LayoutGridField` will render a schema, uiSchema and formData combination out into a GridTemplate in the shape
* described in the uiSchema. To define the grid to use to render the elements within a field in the schema, provide in
* the uiSchema for that field the object contained under a `ui:layoutGrid` element. E.g. (as a JSON object):
Expand Down Expand Up @@ -496,6 +507,47 @@ export default class LayoutGridField<
return schemaUtils.toIdSchema(schema, baseId, formData, baseId, idSeparator);
}

/** Computes the `rawSchema` and `idSchema` for a `schema` and a `potentialIndex`. If the `schema` is of type array,
* has an `ITEMS_KEY` element and `potentialIndex` represents a numeric value, the element at `ITEMS_KEY` is checked
* to see if it is an array. If it is AND the `potentialIndex`th element is available, it is used as the `rawSchema`,
* otherwise the last value of the element is used. If it is not, then the element is used as the `rawSchema`. In
* either case, an `idSchema` is computed for the array index. If the `schema` does not represent an array or the
* `potentialIndex` is not a numeric value, then `rawSchema` is returned as undefined and given `idSchema` is returned
* as is.
*
* @param schema - The schema to generate the idSchema for
* @param idSchema - The IdSchema for the schema
* @param potentialIndex - A string containing a potential index
* @param [idSeparator] - The param to pass into the `toIdSchema` util which will use it to join the `idSchema` paths
* @returns - An object containing the `rawSchema` and `idSchema` of an array item, otherwise an undefined `rawSchema`
*/
static computeArraySchemasIfPresent<T = any, S extends StrictRJSFSchema = RJSFSchema>(
schema: S | undefined,
idSchema: IdSchema<T>,
potentialIndex: string,
idSeparator?: string,
): {
rawSchema?: S;
idSchema: IdSchema<T>;
} {
let rawSchema: S | undefined;
if (isNumericIndex(potentialIndex) && schema && schema?.type === 'array' && has(schema, ITEMS_KEY)) {
const index = Number(potentialIndex);
const items = schema[ITEMS_KEY];
if (Array.isArray(items)) {
if (index > items.length) {
rawSchema = last(items) as S;
} else {
rawSchema = items[index] as S;
}
} else {
rawSchema = items as S;
}
idSchema = { [ID_KEY]: `${idSchema[ID_KEY]}${idSeparator ?? '_'}${index}` } as IdSchema<T>;
}
return { rawSchema, idSchema };
}

/** Given a `dottedPath` to a field in the `initialSchema`, iterate through each individual path in the schema until
* the leaf path is found and returned (along with whether that leaf path `isRequired`) OR no schema exists for an
* element in the path. If the leaf schema element happens to be a oneOf/anyOf then also return the oneOf/anyOf as
Expand Down Expand Up @@ -552,7 +604,9 @@ export default class LayoutGridField<
rawSchema = get(selectedSchema, [PROPERTIES_KEY, part], {}) as S;
idSchema = get(selectedIdSchema, part, {}) as IdSchema<T>;
} else {
rawSchema = {} as S;
const result = LayoutGridField.computeArraySchemasIfPresent<T, S>(schema, idSchema, part, idSeparator);
rawSchema = result.rawSchema ?? ({} as S);
idSchema = result.idSchema;
}
// Now drill into the innerData for the part, returning an empty object by default if it doesn't exist
innerData = get(innerData, part, {}) as T;
Expand All @@ -578,11 +632,17 @@ export default class LayoutGridField<
idSchema = mergeObjects(rawIdSchema, idSchema) as IdSchema<T>;
}
isRequired = schema !== undefined && Array.isArray(schema.required) && includes(schema.required, leafPath);
// Now grab the schema from the leafPath of the current schema properties
schema = get(schema, [PROPERTIES_KEY, leafPath]) as S | undefined;
// Resolve any `$ref`s for the current schema
schema = schema ? schemaUtils.retrieveSchema(schema) : schema;
idSchema = get(idSchema, leafPath, {}) as IdSchema<T>;
const result = LayoutGridField.computeArraySchemasIfPresent<T, S>(schema, idSchema, leafPath, idSeparator);
if (result.rawSchema) {
schema = result.rawSchema;
idSchema = result.idSchema;
} else {
// Now grab the schema from the leafPath of the current schema properties
schema = get(schema, [PROPERTIES_KEY, leafPath]) as S | undefined;
// Resolve any `$ref`s for the current schema
schema = schema ? schemaUtils.retrieveSchema(schema) : schema;
idSchema = get(idSchema, leafPath, {}) as IdSchema<T>;
}
isReadonly = getNonNullishValue(schema?.readOnly, isReadonly);
if (schema && (has(schema, ONE_OF_KEY) || has(schema, ANY_OF_KEY))) {
const xxx = has(schema, ONE_OF_KEY) ? ONE_OF_KEY : ANY_OF_KEY;
Expand Down
138 changes: 130 additions & 8 deletions packages/core/test/LayoutGridField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,6 @@ const gridFormSchema = {
},
},
} as RJSFSchema;

const gridFormUISchema: UiSchema = {
'ui:field': 'LayoutGridForm',
'ui:layoutGrid': {
Expand Down Expand Up @@ -565,6 +564,35 @@ const gridFormUISchema: UiSchema = {
},
};

const arraySchema: RJSFSchema = {
type: 'object',
properties: {
example: {
type: 'array',
title: 'Examples',
items: {
type: 'array',
minItems: 3,
maxItems: 3,
items: [
{
type: 'integer',
},
{
type: 'integer',
},
{
type: 'integer',
},
],
},
},
},
required: ['example'],
};
const outerArraySchema = arraySchema?.properties?.example as RJSFSchema;
const innerArraySchema = outerArraySchema?.items as RJSFSchema;

const ERRORS = ['error'];
const EXTRA_ERROR = new ErrorSchemaBuilder().addErrors(ERRORS).ErrorSchema;
const DEFAULT_ID = 'test-id';
Expand Down Expand Up @@ -643,9 +671,11 @@ const simpleOneOfRegistry = getTestRegistry(SIMPLE_ONEOF, REGISTRY_FIELDS, {}, {
const gridFormSchemaRegistry = getTestRegistry(GRID_FORM_SCHEMA, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
const sampleSchemaRegistry = getTestRegistry(SAMPLE_SCHEMA, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
const readonlySchemaRegistry = getTestRegistry(readonlySchema, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
const arraySchemaRegistry = getTestRegistry(arraySchema, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
const GRID_FORM_ID_SCHEMA = gridFormSchemaRegistry.schemaUtils.toIdSchema(GRID_FORM_SCHEMA);
const SAMPLE_SCHEMA_ID_SCHEMA = sampleSchemaRegistry.schemaUtils.toIdSchema(SAMPLE_SCHEMA);
const READONLY_ID_SCHEMA = readonlySchemaRegistry.schemaUtils.toIdSchema(readonlySchema);
const ARRAY_ID_SCHEMA = arraySchemaRegistry.schemaUtils.toIdSchema(arraySchema);

/** Simple mock idSchema generator that will take a dotted path string, and return the path joined by the `idSeparator`
* and appended to `root` (default idPrefix in `toIdSchema`)
Expand Down Expand Up @@ -845,6 +875,40 @@ describe('LayoutGridField', () => {
expect(LayoutGridField.getIdSchema(schemaUtils, ID_SCHEMA, {})).toEqual(ID_SCHEMA);
});
});
describe('LayoutGridField.computeArraySchemasIfPresent()', () => {
test('returns undefined rawSchema and given idSchema for non-numeric potentialIndex', () => {
expect(LayoutGridField.computeArraySchemasIfPresent(undefined, ID_SCHEMA, 'string')).toEqual({
rawSchema: undefined,
idSchema: ID_SCHEMA,
});
});
test('returns undefined rawSchema and given idSchema for numeric potentialIndex, no schema', () => {
expect(LayoutGridField.computeArraySchemasIfPresent(undefined, ID_SCHEMA, '0')).toEqual({
rawSchema: undefined,
idSchema: ID_SCHEMA,
});
});
test('returns undefined rawSchema and given idSchema for numeric potentialIndex, non-array schema', () => {
expect(LayoutGridField.computeArraySchemasIfPresent(readonlySchema, ID_SCHEMA, '0')).toEqual({
rawSchema: undefined,
idSchema: ID_SCHEMA,
});
});
test('returns outer array rawSchema and generated idSchema for numeric potentialIndex, array schema', () => {
const idSchema = { [ID_KEY]: `${ID_SCHEMA[ID_KEY]}_0` } as IdSchema;
expect(LayoutGridField.computeArraySchemasIfPresent(outerArraySchema, ID_SCHEMA, '0')).toEqual({
rawSchema: outerArraySchema.items,
idSchema,
});
});
test('returns inner array rawSchema and generated idSchema for numeric potentialIndex, array schema, idSeparator', () => {
const idSchema = { [ID_KEY]: `${ID_SCHEMA[ID_KEY]}.1` } as IdSchema;
expect(LayoutGridField.computeArraySchemasIfPresent(innerArraySchema, ID_SCHEMA, '1', '.')).toEqual({
rawSchema: get(innerArraySchema.items, 1),
idSchema,
});
});
});
describe('LayoutGridField.getSchemaDetailsForField(), blank schema', () => {
beforeAll(() => {
retrieveSchemaSpy = jest.spyOn(registry.schemaUtils, 'retrieveSchema');
Expand Down Expand Up @@ -1067,9 +1131,9 @@ describe('LayoutGridField', () => {
});
describe('LayoutGridField.getSchemaDetailsForField(), readonlySchema', () => {
beforeAll(() => {
retrieveSchemaSpy = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'retrieveSchema');
toIdSchemaSpy = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'toIdSchema');
findSelectedOptionInXxxOf = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'findSelectedOptionInXxxOf');
retrieveSchemaSpy = jest.spyOn(readonlySchemaRegistry.schemaUtils, 'retrieveSchema');
toIdSchemaSpy = jest.spyOn(readonlySchemaRegistry.schemaUtils, 'toIdSchema');
findSelectedOptionInXxxOf = jest.spyOn(readonlySchemaRegistry.schemaUtils, 'findSelectedOptionInXxxOf');
});
afterEach(() => {
findSelectedOptionInXxxOf.mockClear();
Expand Down Expand Up @@ -1099,7 +1163,7 @@ describe('LayoutGridField', () => {
idSchema: testGetIdSchema(path),
optionsInfo: { options: get(schema, [ONE_OF_KEY]), hasDiscriminator: false },
});
expect(retrieveSchemaSpy).not.toHaveBeenCalled();
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(2);
expect(toIdSchemaSpy).not.toHaveBeenCalled();
});
test('returns schema, isRequired: true, isReadonly: true, options: undefined when selecting readonly field', () => {
Expand All @@ -1120,7 +1184,7 @@ describe('LayoutGridField', () => {
idSchema: testGetIdSchema(path),
optionsInfo: undefined,
});
expect(retrieveSchemaSpy).not.toHaveBeenCalled();
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(2);
expect(toIdSchemaSpy).not.toHaveBeenCalled();
});
test('returns schema, isRequired: true, isReadonly: true, options: undefined when selecting field on readonly parent', () => {
Expand All @@ -1141,7 +1205,7 @@ describe('LayoutGridField', () => {
idSchema: testGetIdSchema(path),
optionsInfo: undefined,
});
expect(retrieveSchemaSpy).not.toHaveBeenCalled();
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(3);
expect(toIdSchemaSpy).not.toHaveBeenCalled();
});
test('returns schema, isRequired: true, isReadonly: false, options: undefined when selecting explicitly readonly false field', () => {
Expand All @@ -1163,7 +1227,65 @@ describe('LayoutGridField', () => {
idSchema: testGetIdSchema(path),
optionsInfo: undefined,
});
expect(retrieveSchemaSpy).not.toHaveBeenCalled();
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(3);
expect(toIdSchemaSpy).not.toHaveBeenCalled();
});
});
describe('LayoutGridField.getSchemaDetailsForField(), arraySchema', () => {
beforeAll(() => {
retrieveSchemaSpy = jest.spyOn(arraySchemaRegistry.schemaUtils, 'retrieveSchema');
toIdSchemaSpy = jest.spyOn(arraySchemaRegistry.schemaUtils, 'toIdSchema');
findSelectedOptionInXxxOf = jest.spyOn(arraySchemaRegistry.schemaUtils, 'findSelectedOptionInXxxOf');
});
afterEach(() => {
findSelectedOptionInXxxOf.mockClear();
retrieveSchemaSpy.mockClear();
toIdSchemaSpy.mockClear();
});
afterAll(() => {
retrieveSchemaSpy.mockRestore();
toIdSchemaSpy.mockRestore();
});
test('returns schema, isRequired: false, isReadonly: undefined, options undefined when 1d array schema is requested', () => {
const path = 'example.0';
const schema = innerArraySchema;
expect(
LayoutGridField.getSchemaDetailsForField(
arraySchemaRegistry.schemaUtils,
path,
arraySchema,
{},
ARRAY_ID_SCHEMA,
),
).toEqual({
schema,
isRequired: false,
isReadonly: undefined,
idSchema: testGetIdSchema(path),
optionsInfo: undefined,
});
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(2);
expect(toIdSchemaSpy).not.toHaveBeenCalled();
});
test('returns schema, isRequired: false, isReadonly: undefined, options: undefined when 2d array schema is requested', () => {
const path = 'example.0.1';
const schema = get(innerArraySchema.items, '1');
expect(
LayoutGridField.getSchemaDetailsForField(
arraySchemaRegistry.schemaUtils,
path,
arraySchema,
{},
ARRAY_ID_SCHEMA,
),
).toEqual({
schema,
isRequired: false,
isReadonly: undefined,
idSchema: testGetIdSchema(path),
optionsInfo: undefined,
});
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(3);
expect(toIdSchemaSpy).not.toHaveBeenCalled();
});
});
Expand Down