Skip to content

Commit ba61a8a

Browse files
Fix 4765 by supporting objects and arrays as elements in a grid (#4780)
* Fix 4765 by supporting objects and arrays as elements in a grid Fixed #4765 by supporting objects and arrays as elements in a grid, specifically fixing the `onFieldChange()` to support them - Updated `LayoutGridField` to update the `onFieldChange()` method to take an additional parameter, `schemaType` - If `schemaType` is `object` or `array`, then the `actualPath` returned to the parent `onChange` will append the `path` from the `onChange()` to the `dottedPath` - Updated the tests to verify this new behavior - Updated `getTestRegistry()` to support passing in `globalFormOptions` instead of just setting an empty object * Update packages/core/test/LayoutGridField.test.tsx * Update packages/core/test/LayoutGridField.test.tsx
1 parent 8351671 commit ba61a8a

File tree

3 files changed

+130
-7
lines changed

3 files changed

+130
-7
lines changed

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

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
FormContextType,
77
GenericObjectType,
88
getDiscriminatorFieldFromSchema,
9+
getSchemaType,
910
getTemplate,
1011
getTestIds,
1112
getUiOptions,
@@ -141,6 +142,21 @@ function isNumericIndex(str: string) {
141142
return /^\d+?$/.test(str); // Matches positive integers
142143
}
143144

145+
/** Detects whether the given `type` indicates the schema is an object or array
146+
*
147+
* @param [type] - The potential type of the schema
148+
* @returns - true if the type indicates it is an array or object, otherwise false
149+
*/
150+
function isObjectOrArrayType(type?: string | string[]) {
151+
let realType: string | undefined;
152+
if (Array.isArray(type)) {
153+
realType = type.length === 1 ? type[0] : undefined;
154+
} else {
155+
realType = type;
156+
}
157+
return realType ? ['object', 'array'].includes(realType) : false;
158+
}
159+
144160
/** The `LayoutGridField` will render a schema, uiSchema and formData combination out into a GridTemplate in the shape
145161
* described in the uiSchema. To define the grid to use to render the elements within a field in the schema, provide in
146162
* the uiSchema for that field the object contained under a `ui:layoutGrid` element. E.g. (as a JSON object):
@@ -730,20 +746,29 @@ export default class LayoutGridField<
730746

731747
/** Generates an `onChange` handler for the field associated with the `dottedPath`. This handler will clone and update
732748
* the `formData` with the new `value` and the `errorSchema` if an `errSchema` is provided. After updating those two
733-
* elements, they will then be passed on to the `onChange` handler of the `LayoutFieldGrid`.
749+
* elements, they will then be passed on to the `onChange` handler of the `LayoutFieldGrid`. This handler is also
750+
* given the `schemaType` and uses it to determine whether the inbound path on the `onChange` should be appended to
751+
* the `dottedPath` that has been split on the `.` character. When the type is an 'object' or 'array', then the
752+
* inbound path will be the index of the array item or name of the object's field.
734753
*
735754
* @param dottedPath - The dotted-path to the field for which to generate the onChange handler
736-
* @returns - The `onChange` handling function for the `dottedPath` field
755+
* @param schemaType - The optional type of the schema for the field
756+
* @returns - The `onChange` handling function for the `dottedPath` field of the `schemaType` type
737757
*/
738-
onFieldChange = (dottedPath: string) => {
758+
onFieldChange = (dottedPath: string, schemaType?: string | string[]) => {
759+
const appendPath = isObjectOrArrayType(schemaType);
739760
return (value: T | undefined, path?: (number | string)[], errSchema?: ErrorSchema<T>, id?: string) => {
740761
const { onChange, errorSchema } = this.props;
741762
let newErrorSchema = errorSchema;
742763
if (errSchema && errorSchema) {
743764
newErrorSchema = cloneDeep(errorSchema);
744765
set(newErrorSchema, dottedPath, errSchema);
745766
}
746-
onChange(value, dottedPath.split('.'), newErrorSchema, id);
767+
let actualPath: (number | string)[] = dottedPath.split('.');
768+
// When the `schemaType` is an object or array, then the path will contain the index of the array or the name of
769+
// object's field, so append it to the path.
770+
actualPath = Array.isArray(path) && appendPath ? actualPath.concat(...path) : actualPath;
771+
onChange(value, actualPath, newErrorSchema, id);
747772
};
748773
};
749774

@@ -954,7 +979,7 @@ export default class LayoutGridField<
954979
errorSchema={get(errorSchema, name)}
955980
idSchema={fieldIdSchema}
956981
formData={get(formData, name)}
957-
onChange={this.onFieldChange(name)}
982+
onChange={this.onFieldChange(name, getSchemaType<S>(schema))}
958983
onBlur={onBlur}
959984
onFocus={onFocus}
960985
options={optionsInfo?.options}

packages/core/test/LayoutGridField.test.tsx

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
import validator from '@rjsf/validator-ajv8';
2424
import { render, screen, within } from '@testing-library/react';
2525
import userEvent from '@testing-library/user-event';
26-
import { get, has, omit, pick } from 'lodash';
26+
import { get, has, isEmpty, omit, pick } from 'lodash';
2727

2828
import LayoutGridField, {
2929
GridType,
@@ -593,6 +593,44 @@ const arraySchema: RJSFSchema = {
593593
const outerArraySchema = arraySchema?.properties?.example as RJSFSchema;
594594
const innerArraySchema = outerArraySchema?.items as RJSFSchema;
595595

596+
const nestedSchema: RJSFSchema = {
597+
type: 'object',
598+
properties: {
599+
listOfStrings: {
600+
type: 'array',
601+
title: 'A list of strings',
602+
items: {
603+
type: 'string',
604+
default: 'bazinga',
605+
},
606+
},
607+
mapOfStrings: {
608+
type: 'object',
609+
title: 'A map of strings',
610+
additionalProperties: {
611+
type: 'string',
612+
default: 'bazinga',
613+
},
614+
},
615+
},
616+
};
617+
618+
const nestedUiSchema: UiSchema = {
619+
'ui:field': 'LayoutGridField',
620+
'ui:layoutGrid': {
621+
'ui:row': {
622+
children: [
623+
{
624+
'ui:columns': {
625+
span: 6,
626+
children: ['listOfStrings', 'mapOfStrings'],
627+
},
628+
},
629+
],
630+
},
631+
},
632+
};
633+
596634
const ERRORS = ['error'];
597635
const EXTRA_ERROR = new ErrorSchemaBuilder().addErrors(ERRORS).ErrorSchema;
598636
const DEFAULT_ID = 'test-id';
@@ -672,6 +710,7 @@ const gridFormSchemaRegistry = getTestRegistry(GRID_FORM_SCHEMA, REGISTRY_FIELDS
672710
const sampleSchemaRegistry = getTestRegistry(SAMPLE_SCHEMA, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
673711
const readonlySchemaRegistry = getTestRegistry(readonlySchema, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
674712
const arraySchemaRegistry = getTestRegistry(arraySchema, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
713+
const nestedSchemaRegistry = getTestRegistry(nestedSchema, REGISTRY_FIELDS, {}, {}, REGISTRY_FORM_CONTEXT);
675714
const GRID_FORM_ID_SCHEMA = gridFormSchemaRegistry.schemaUtils.toIdSchema(GRID_FORM_SCHEMA);
676715
const SAMPLE_SCHEMA_ID_SCHEMA = sampleSchemaRegistry.schemaUtils.toIdSchema(SAMPLE_SCHEMA);
677716
const READONLY_ID_SCHEMA = readonlySchemaRegistry.schemaUtils.toIdSchema(readonlySchema);
@@ -713,6 +752,10 @@ function getExpectedPropsForField(
713752
required = result?.required?.includes(name) || false;
714753
return schema1;
715754
}, props.schema);
755+
// Null out nested properties that can show up when additionalProperties is specified
756+
if (!isEmpty(schema?.properties)) {
757+
schema.properties = {};
758+
}
716759
// Get the readonly options from the schema, if any
717760
const readonly = get(schema, 'readOnly');
718761
// Get the options from the schema's oneOf, if any
@@ -1501,6 +1544,60 @@ describe('LayoutGridField', () => {
15011544
await userEvent.tab();
15021545
expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo');
15031546
});
1547+
test('renderField via name explicit layoutGridSchema, nested array', async () => {
1548+
const fieldName = 'listOfStrings';
1549+
const props = getProps({
1550+
schema: nestedSchema,
1551+
uiSchema: nestedUiSchema,
1552+
layoutGridSchema: fieldName,
1553+
idSeparator: '.',
1554+
registry: nestedSchemaRegistry,
1555+
});
1556+
const fieldId = get(props.idSchema, [fieldName, ID_KEY]);
1557+
render(<LayoutGridField {...props} />);
1558+
// Renders a field
1559+
const field = screen.getByTestId(LayoutGridField.TEST_IDS.field);
1560+
expect(field).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, fieldName)));
1561+
// Test onChange, onFocus, onBlur
1562+
const input = within(field).getByRole('textbox');
1563+
// Click on the input to cause the focus
1564+
await userEvent.click(input);
1565+
expect(props.onFocus).toHaveBeenCalledWith(fieldId, '');
1566+
// Type to trigger the onChange
1567+
await userEvent.type(input, 'foo');
1568+
// Due to the selection of schema type = `array` the path is appended to the fieldName, duplicating it
1569+
expect(props.onChange).toHaveBeenCalledWith('foo', [fieldName, fieldName], props.errorSchema, fieldId);
1570+
// Tab out of the input field to cause the blur
1571+
await userEvent.tab();
1572+
expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo');
1573+
});
1574+
test('renderField via name explicit layoutGridSchema, nested object', async () => {
1575+
const fieldName = 'mapOfStrings';
1576+
const props = getProps({
1577+
schema: nestedSchema,
1578+
uiSchema: nestedUiSchema,
1579+
layoutGridSchema: fieldName,
1580+
idSeparator: '.',
1581+
registry: nestedSchemaRegistry,
1582+
});
1583+
const fieldId = get(props.idSchema, [fieldName, ID_KEY]);
1584+
render(<LayoutGridField {...props} />);
1585+
// Renders a field
1586+
const field = screen.getByTestId(LayoutGridField.TEST_IDS.field);
1587+
expect(field).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, fieldName)));
1588+
// Test onChange, onFocus, onBlur
1589+
const input = within(field).getByRole('textbox');
1590+
// Click on the input to cause the focus
1591+
await userEvent.click(input);
1592+
expect(props.onFocus).toHaveBeenCalledWith(fieldId, '');
1593+
// Type to trigger the onChange
1594+
await userEvent.type(input, 'foo');
1595+
// Due to the selection of schema type = `object` the path is appended to the fieldName, duplicating it
1596+
expect(props.onChange).toHaveBeenCalledWith('foo', [fieldName, fieldName], props.errorSchema, fieldId);
1597+
// Tab out of the input field to cause the blur
1598+
await userEvent.tab();
1599+
expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo');
1600+
});
15041601
test('renderField via object explicit layoutGridSchema, otherProps', () => {
15051602
const fieldName = 'employment';
15061603
const globalUiOptions = { propToApplyToAllFields: 'foobar' };

packages/core/test/testData/getTestRegistry.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default function getTestRegistry(
1111
templates: Partial<Registry['templates']> = {},
1212
widgets: Registry['widgets'] = {},
1313
formContext: Registry['formContext'] = {},
14+
globalFormOptions: Registry['globalFormOptions'] = {},
1415
): Registry {
1516
const defaults = getDefaultRegistry();
1617
const schemaUtils = createSchemaUtils(validator, rootSchema);
@@ -22,6 +23,6 @@ export default function getTestRegistry(
2223
rootSchema,
2324
schemaUtils,
2425
translateString: englishStringTranslator,
25-
globalFormOptions: {},
26+
globalFormOptions,
2627
};
2728
}

0 commit comments

Comments
 (0)