Skip to content

Commit d4457b2

Browse files
Fix 4676 - Add support for arrays in the LayoutGridField
Fixes #4676 by adding support for indexes into the `LayoutGridField` - Updated `LayoutGridField` to add support for arrays - Updated the `LayoutGridField` to add 100% validation for the new arrays code
1 parent 1d3c624 commit d4457b2

File tree

2 files changed

+196
-14
lines changed

2 files changed

+196
-14
lines changed

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

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
UI_OPTIONS_KEY,
2525
UI_GLOBAL_OPTIONS_KEY,
2626
UiSchema,
27+
ITEMS_KEY,
2728
} from '@rjsf/utils';
2829
import cloneDeep from 'lodash/cloneDeep';
2930
import each from 'lodash/each';
@@ -39,6 +40,7 @@ import isObject from 'lodash/isObject';
3940
import isPlainObject from 'lodash/isPlainObject';
4041
import isString from 'lodash/isString';
4142
import isUndefined from 'lodash/isUndefined';
43+
import last from 'lodash/last';
4244
import set from 'lodash/set';
4345

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

135+
/** Detects if a `str` is made up entirely of numeric characters
136+
*
137+
* @param str - The string to check to see if it as number
138+
* @return - True if the string consists entirely of numeric characters
139+
*/
140+
function isNumeric(str: string) {
141+
return /^-?\d+(\.\d+)?$/.test(str); // Matches integers or decimals, positive or negative
142+
}
143+
133144
/** The `LayoutGridField` will render a schema, uiSchema and formData combination out into a GridTemplate in the shape
134145
* described in the uiSchema. To define the grid to use to render the elements within a field in the schema, provide in
135146
* the uiSchema for that field the object contained under a `ui:layoutGrid` element. E.g. (as a JSON object):
@@ -496,6 +507,47 @@ export default class LayoutGridField<
496507
return schemaUtils.toIdSchema(schema, baseId, formData, baseId, idSeparator);
497508
}
498509

510+
/** Computes the `rawSchema` and `idSchema` for a `schema` and a `potentialIndex`. If the `schema` is of type array,
511+
* has an `ITEMS_KEY` element and `potentialIndex` represents a numeric value, the element at `ITEMS_KEY` is checked
512+
* to see if it is an array. If it is AND the `potentialIndex`th element is available, it is used as the `rawSchema`,
513+
* otherwise the last value of the element is used. If it is not, then the element is used as the `rawSchema`. In
514+
* either case, an `idSchema` is computed for the array index. If the `schema` does not represent an array or the
515+
* `potentialIndex` is not a numeric value, then `rawSchema` is returned as undefined and given `idSchema` is returned
516+
* as is.
517+
*
518+
* @param schema - The schema to generate the idSchema for
519+
* @param idSchema - The IdSchema for the schema
520+
* @param potentialIndex - A string containing a potential index
521+
* @param [idSeparator] - The param to pass into the `toIdSchema` util which will use it to join the `idSchema` paths
522+
* @returns - An object containing the `rawSchema` and `idSchema` of an array item, otherwise an undefined `rawSchema`
523+
*/
524+
static computeArraySchemasIfPresent<T = any, S extends StrictRJSFSchema = RJSFSchema>(
525+
schema: S | undefined,
526+
idSchema: IdSchema<T>,
527+
potentialIndex: string,
528+
idSeparator?: string,
529+
): {
530+
rawSchema?: S;
531+
idSchema: IdSchema<T>;
532+
} {
533+
let rawSchema: S | undefined;
534+
if (isNumeric(potentialIndex) && schema && schema?.type === 'array' && has(schema, ITEMS_KEY)) {
535+
const index = Number(potentialIndex);
536+
const items = schema[ITEMS_KEY];
537+
if (Array.isArray(items)) {
538+
if (index > items.length) {
539+
rawSchema = last(items) as S;
540+
} else {
541+
rawSchema = items[index] as S;
542+
}
543+
} else {
544+
rawSchema = items as S;
545+
}
546+
idSchema = { [ID_KEY]: `${idSchema[ID_KEY]}${idSeparator ?? '_'}${index}` } as IdSchema<T>;
547+
}
548+
return { rawSchema, idSchema };
549+
}
550+
499551
/** Given a `dottedPath` to a field in the `initialSchema`, iterate through each individual path in the schema until
500552
* the leaf path is found and returned (along with whether that leaf path `isRequired`) OR no schema exists for an
501553
* element in the path. If the leaf schema element happens to be a oneOf/anyOf then also return the oneOf/anyOf as
@@ -552,7 +604,9 @@ export default class LayoutGridField<
552604
rawSchema = get(selectedSchema, [PROPERTIES_KEY, part], {}) as S;
553605
idSchema = get(selectedIdSchema, part, {}) as IdSchema<T>;
554606
} else {
555-
rawSchema = {} as S;
607+
const result = LayoutGridField.computeArraySchemasIfPresent<T, S>(schema, idSchema, part, idSeparator);
608+
rawSchema = result.rawSchema ?? ({} as S);
609+
idSchema = result.idSchema;
556610
}
557611
// Now drill into the innerData for the part, returning an empty object by default if it doesn't exist
558612
innerData = get(innerData, part, {}) as T;
@@ -578,11 +632,17 @@ export default class LayoutGridField<
578632
idSchema = mergeObjects(rawIdSchema, idSchema) as IdSchema<T>;
579633
}
580634
isRequired = schema !== undefined && Array.isArray(schema.required) && includes(schema.required, leafPath);
581-
// Now grab the schema from the leafPath of the current schema properties
582-
schema = get(schema, [PROPERTIES_KEY, leafPath]) as S | undefined;
583-
// Resolve any `$ref`s for the current schema
584-
schema = schema ? schemaUtils.retrieveSchema(schema) : schema;
585-
idSchema = get(idSchema, leafPath, {}) as IdSchema<T>;
635+
const result = LayoutGridField.computeArraySchemasIfPresent<T, S>(schema, idSchema, leafPath, idSeparator);
636+
if (result.rawSchema) {
637+
schema = result.rawSchema;
638+
idSchema = result.idSchema;
639+
} else {
640+
// Now grab the schema from the leafPath of the current schema properties
641+
schema = get(schema, [PROPERTIES_KEY, leafPath]) as S | undefined;
642+
// Resolve any `$ref`s for the current schema
643+
schema = schema ? schemaUtils.retrieveSchema(schema) : schema;
644+
idSchema = get(idSchema, leafPath, {}) as IdSchema<T>;
645+
}
586646
isReadonly = getNonNullishValue(schema?.readOnly, isReadonly);
587647
if (schema && (has(schema, ONE_OF_KEY) || has(schema, ANY_OF_KEY))) {
588648
const xxx = has(schema, ONE_OF_KEY) ? ONE_OF_KEY : ANY_OF_KEY;

packages/core/test/LayoutGridField.test.tsx

Lines changed: 130 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,6 @@ const gridFormSchema = {
370370
},
371371
},
372372
} as RJSFSchema;
373-
374373
const gridFormUISchema: UiSchema = {
375374
'ui:field': 'LayoutGridForm',
376375
'ui:layoutGrid': {
@@ -565,6 +564,35 @@ const gridFormUISchema: UiSchema = {
565564
},
566565
};
567566

567+
const arraySchema: RJSFSchema = {
568+
type: 'object',
569+
properties: {
570+
example: {
571+
type: 'array',
572+
title: 'Examples',
573+
items: {
574+
type: 'array',
575+
minItems: 3,
576+
maxItems: 3,
577+
items: [
578+
{
579+
type: 'integer',
580+
},
581+
{
582+
type: 'integer',
583+
},
584+
{
585+
type: 'integer',
586+
},
587+
],
588+
},
589+
},
590+
},
591+
required: ['example'],
592+
};
593+
const outerArraySchema = arraySchema?.properties?.example as RJSFSchema;
594+
const innerArraySchema = outerArraySchema?.items as RJSFSchema;
595+
568596
const ERRORS = ['error'];
569597
const EXTRA_ERROR = new ErrorSchemaBuilder().addErrors(ERRORS).ErrorSchema;
570598
const DEFAULT_ID = 'test-id';
@@ -643,9 +671,11 @@ const simpleOneOfRegistry = getTestRegistry(SIMPLE_ONEOF, REGISTRY_FIELDS, {}, {
643671
const gridFormSchemaRegistry = getTestRegistry(GRID_FORM_SCHEMA, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
644672
const sampleSchemaRegistry = getTestRegistry(SAMPLE_SCHEMA, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
645673
const readonlySchemaRegistry = getTestRegistry(readonlySchema, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
674+
const arraySchemaRegistry = getTestRegistry(arraySchema, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
646675
const GRID_FORM_ID_SCHEMA = gridFormSchemaRegistry.schemaUtils.toIdSchema(GRID_FORM_SCHEMA);
647676
const SAMPLE_SCHEMA_ID_SCHEMA = sampleSchemaRegistry.schemaUtils.toIdSchema(SAMPLE_SCHEMA);
648677
const READONLY_ID_SCHEMA = readonlySchemaRegistry.schemaUtils.toIdSchema(readonlySchema);
678+
const ARRAY_ID_SCHEMA = arraySchemaRegistry.schemaUtils.toIdSchema(arraySchema);
649679

650680
/** Simple mock idSchema generator that will take a dotted path string, and return the path joined by the `idSeparator`
651681
* and appended to `root` (default idPrefix in `toIdSchema`)
@@ -845,6 +875,40 @@ describe('LayoutGridField', () => {
845875
expect(LayoutGridField.getIdSchema(schemaUtils, ID_SCHEMA, {})).toEqual(ID_SCHEMA);
846876
});
847877
});
878+
describe('LayoutGridField.computeArraySchemasIfPresent()', () => {
879+
test('returns undefined rawSchema and given idSchema for non-numeric potentialIndex', () => {
880+
expect(LayoutGridField.computeArraySchemasIfPresent(undefined, ID_SCHEMA, 'string')).toEqual({
881+
rawSchema: undefined,
882+
idSchema: ID_SCHEMA,
883+
});
884+
});
885+
test('returns undefined rawSchema and given idSchema for numeric potentialIndex, no schema', () => {
886+
expect(LayoutGridField.computeArraySchemasIfPresent(undefined, ID_SCHEMA, '0')).toEqual({
887+
rawSchema: undefined,
888+
idSchema: ID_SCHEMA,
889+
});
890+
});
891+
test('returns undefined rawSchema and given idSchema for numeric potentialIndex, non-array schema', () => {
892+
expect(LayoutGridField.computeArraySchemasIfPresent(readonlySchema, ID_SCHEMA, '0')).toEqual({
893+
rawSchema: undefined,
894+
idSchema: ID_SCHEMA,
895+
});
896+
});
897+
test('returns outer array rawSchema and generated idSchema for numeric potentialIndex, array schema', () => {
898+
const idSchema = { [ID_KEY]: `${ID_SCHEMA[ID_KEY]}_0` } as IdSchema;
899+
expect(LayoutGridField.computeArraySchemasIfPresent(outerArraySchema, ID_SCHEMA, '0')).toEqual({
900+
rawSchema: outerArraySchema.items,
901+
idSchema,
902+
});
903+
});
904+
test('returns inner array rawSchema and generated idSchema for numeric potentialIndex, array schema, idSeparator', () => {
905+
const idSchema = { [ID_KEY]: `${ID_SCHEMA[ID_KEY]}.1` } as IdSchema;
906+
expect(LayoutGridField.computeArraySchemasIfPresent(innerArraySchema, ID_SCHEMA, '1', '.')).toEqual({
907+
rawSchema: get(innerArraySchema.items, 1),
908+
idSchema,
909+
});
910+
});
911+
});
848912
describe('LayoutGridField.getSchemaDetailsForField(), blank schema', () => {
849913
beforeAll(() => {
850914
retrieveSchemaSpy = jest.spyOn(registry.schemaUtils, 'retrieveSchema');
@@ -1067,9 +1131,9 @@ describe('LayoutGridField', () => {
10671131
});
10681132
describe('LayoutGridField.getSchemaDetailsForField(), readonlySchema', () => {
10691133
beforeAll(() => {
1070-
retrieveSchemaSpy = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'retrieveSchema');
1071-
toIdSchemaSpy = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'toIdSchema');
1072-
findSelectedOptionInXxxOf = jest.spyOn(gridFormSchemaRegistry.schemaUtils, 'findSelectedOptionInXxxOf');
1134+
retrieveSchemaSpy = jest.spyOn(readonlySchemaRegistry.schemaUtils, 'retrieveSchema');
1135+
toIdSchemaSpy = jest.spyOn(readonlySchemaRegistry.schemaUtils, 'toIdSchema');
1136+
findSelectedOptionInXxxOf = jest.spyOn(readonlySchemaRegistry.schemaUtils, 'findSelectedOptionInXxxOf');
10731137
});
10741138
afterEach(() => {
10751139
findSelectedOptionInXxxOf.mockClear();
@@ -1099,7 +1163,7 @@ describe('LayoutGridField', () => {
10991163
idSchema: testGetIdSchema(path),
11001164
optionsInfo: { options: get(schema, [ONE_OF_KEY]), hasDiscriminator: false },
11011165
});
1102-
expect(retrieveSchemaSpy).not.toHaveBeenCalled();
1166+
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(2);
11031167
expect(toIdSchemaSpy).not.toHaveBeenCalled();
11041168
});
11051169
test('returns schema, isRequired: true, isReadonly: true, options: undefined when selecting readonly field', () => {
@@ -1120,7 +1184,7 @@ describe('LayoutGridField', () => {
11201184
idSchema: testGetIdSchema(path),
11211185
optionsInfo: undefined,
11221186
});
1123-
expect(retrieveSchemaSpy).not.toHaveBeenCalled();
1187+
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(2);
11241188
expect(toIdSchemaSpy).not.toHaveBeenCalled();
11251189
});
11261190
test('returns schema, isRequired: true, isReadonly: true, options: undefined when selecting field on readonly parent', () => {
@@ -1141,7 +1205,7 @@ describe('LayoutGridField', () => {
11411205
idSchema: testGetIdSchema(path),
11421206
optionsInfo: undefined,
11431207
});
1144-
expect(retrieveSchemaSpy).not.toHaveBeenCalled();
1208+
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(3);
11451209
expect(toIdSchemaSpy).not.toHaveBeenCalled();
11461210
});
11471211
test('returns schema, isRequired: true, isReadonly: false, options: undefined when selecting explicitly readonly false field', () => {
@@ -1163,7 +1227,65 @@ describe('LayoutGridField', () => {
11631227
idSchema: testGetIdSchema(path),
11641228
optionsInfo: undefined,
11651229
});
1166-
expect(retrieveSchemaSpy).not.toHaveBeenCalled();
1230+
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(3);
1231+
expect(toIdSchemaSpy).not.toHaveBeenCalled();
1232+
});
1233+
});
1234+
describe('LayoutGridField.getSchemaDetailsForField(), arraySchema', () => {
1235+
beforeAll(() => {
1236+
retrieveSchemaSpy = jest.spyOn(arraySchemaRegistry.schemaUtils, 'retrieveSchema');
1237+
toIdSchemaSpy = jest.spyOn(arraySchemaRegistry.schemaUtils, 'toIdSchema');
1238+
findSelectedOptionInXxxOf = jest.spyOn(arraySchemaRegistry.schemaUtils, 'findSelectedOptionInXxxOf');
1239+
});
1240+
afterEach(() => {
1241+
findSelectedOptionInXxxOf.mockClear();
1242+
retrieveSchemaSpy.mockClear();
1243+
toIdSchemaSpy.mockClear();
1244+
});
1245+
afterAll(() => {
1246+
retrieveSchemaSpy.mockRestore();
1247+
toIdSchemaSpy.mockRestore();
1248+
});
1249+
test('returns schema, isRequired: false, isReadonly: undefined, options when oneOf schema is requested', () => {
1250+
const path = 'example.0';
1251+
const schema = innerArraySchema;
1252+
expect(
1253+
LayoutGridField.getSchemaDetailsForField(
1254+
arraySchemaRegistry.schemaUtils,
1255+
path,
1256+
arraySchema,
1257+
{},
1258+
ARRAY_ID_SCHEMA,
1259+
),
1260+
).toEqual({
1261+
schema,
1262+
isRequired: false,
1263+
isReadonly: undefined,
1264+
idSchema: testGetIdSchema(path),
1265+
optionsInfo: undefined,
1266+
});
1267+
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(2);
1268+
expect(toIdSchemaSpy).not.toHaveBeenCalled();
1269+
});
1270+
test('returns schema, isRequired: true, isReadonly: true, options: undefined when selecting readonly field', () => {
1271+
const path = 'example.0.1';
1272+
const schema = get(innerArraySchema.items, '1');
1273+
expect(
1274+
LayoutGridField.getSchemaDetailsForField(
1275+
arraySchemaRegistry.schemaUtils,
1276+
path,
1277+
arraySchema,
1278+
{},
1279+
ARRAY_ID_SCHEMA,
1280+
),
1281+
).toEqual({
1282+
schema,
1283+
isRequired: false,
1284+
isReadonly: undefined,
1285+
idSchema: testGetIdSchema(path),
1286+
optionsInfo: undefined,
1287+
});
1288+
expect(retrieveSchemaSpy).toHaveBeenCalledTimes(3);
11671289
expect(toIdSchemaSpy).not.toHaveBeenCalled();
11681290
});
11691291
});

0 commit comments

Comments
 (0)