diff --git a/packages/core/src/components/fields/LayoutGridField.tsx b/packages/core/src/components/fields/LayoutGridField.tsx index a09ba68704..a450b5fa29 100644 --- a/packages/core/src/components/fields/LayoutGridField.tsx +++ b/packages/core/src/components/fields/LayoutGridField.tsx @@ -6,6 +6,7 @@ import { FormContextType, GenericObjectType, getDiscriminatorFieldFromSchema, + getSchemaType, getTemplate, getTestIds, getUiOptions, @@ -141,6 +142,21 @@ function isNumericIndex(str: string) { return /^\d+?$/.test(str); // Matches positive integers } +/** Detects whether the given `type` indicates the schema is an object or array + * + * @param [type] - The potential type of the schema + * @returns - true if the type indicates it is an array or object, otherwise false + */ +function isObjectOrArrayType(type?: string | string[]) { + let realType: string | undefined; + if (Array.isArray(type)) { + realType = type.length === 1 ? type[0] : undefined; + } else { + realType = type; + } + return realType ? ['object', 'array'].includes(realType) : false; +} + /** 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): @@ -730,12 +746,17 @@ export default class LayoutGridField< /** Generates an `onChange` handler for the field associated with the `dottedPath`. This handler will clone and update * the `formData` with the new `value` and the `errorSchema` if an `errSchema` is provided. After updating those two - * elements, they will then be passed on to the `onChange` handler of the `LayoutFieldGrid`. + * elements, they will then be passed on to the `onChange` handler of the `LayoutFieldGrid`. This handler is also + * given the `schemaType` and uses it to determine whether the inbound path on the `onChange` should be appended to + * the `dottedPath` that has been split on the `.` character. When the type is an 'object' or 'array', then the + * inbound path will be the index of the array item or name of the object's field. * * @param dottedPath - The dotted-path to the field for which to generate the onChange handler - * @returns - The `onChange` handling function for the `dottedPath` field + * @param schemaType - The optional type of the schema for the field + * @returns - The `onChange` handling function for the `dottedPath` field of the `schemaType` type */ - onFieldChange = (dottedPath: string) => { + onFieldChange = (dottedPath: string, schemaType?: string | string[]) => { + const appendPath = isObjectOrArrayType(schemaType); return (value: T | undefined, path?: (number | string)[], errSchema?: ErrorSchema, id?: string) => { const { onChange, errorSchema } = this.props; let newErrorSchema = errorSchema; @@ -743,7 +764,11 @@ export default class LayoutGridField< newErrorSchema = cloneDeep(errorSchema); set(newErrorSchema, dottedPath, errSchema); } - onChange(value, dottedPath.split('.'), newErrorSchema, id); + let actualPath: (number | string)[] = dottedPath.split('.'); + // When the `schemaType` is an object or array, then the path will contain the index of the array or the name of + // object's field, so append it to the path. + actualPath = Array.isArray(path) && appendPath ? actualPath.concat(...path) : actualPath; + onChange(value, actualPath, newErrorSchema, id); }; }; @@ -954,7 +979,7 @@ export default class LayoutGridField< errorSchema={get(errorSchema, name)} idSchema={fieldIdSchema} formData={get(formData, name)} - onChange={this.onFieldChange(name)} + onChange={this.onFieldChange(name, getSchemaType(schema))} onBlur={onBlur} onFocus={onFocus} options={optionsInfo?.options} diff --git a/packages/core/test/LayoutGridField.test.tsx b/packages/core/test/LayoutGridField.test.tsx index 670dcd59b5..f02da4b299 100644 --- a/packages/core/test/LayoutGridField.test.tsx +++ b/packages/core/test/LayoutGridField.test.tsx @@ -23,7 +23,7 @@ import { import validator from '@rjsf/validator-ajv8'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { get, has, omit, pick } from 'lodash'; +import { get, has, isEmpty, omit, pick } from 'lodash'; import LayoutGridField, { GridType, @@ -593,6 +593,44 @@ const arraySchema: RJSFSchema = { const outerArraySchema = arraySchema?.properties?.example as RJSFSchema; const innerArraySchema = outerArraySchema?.items as RJSFSchema; +const nestedSchema: RJSFSchema = { + type: 'object', + properties: { + listOfStrings: { + type: 'array', + title: 'A list of strings', + items: { + type: 'string', + default: 'bazinga', + }, + }, + mapOfStrings: { + type: 'object', + title: 'A map of strings', + additionalProperties: { + type: 'string', + default: 'bazinga', + }, + }, + }, +}; + +const nestedUiSchema: UiSchema = { + 'ui:field': 'LayoutGridField', + 'ui:layoutGrid': { + 'ui:row': { + children: [ + { + 'ui:columns': { + span: 6, + children: ['listOfStrings', 'mapOfStrings'], + }, + }, + ], + }, + }, +}; + const ERRORS = ['error']; const EXTRA_ERROR = new ErrorSchemaBuilder().addErrors(ERRORS).ErrorSchema; const DEFAULT_ID = 'test-id'; @@ -672,6 +710,7 @@ const gridFormSchemaRegistry = getTestRegistry(GRID_FORM_SCHEMA, REGISTRY_FIELDS 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 nestedSchemaRegistry = getTestRegistry(nestedSchema, 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); @@ -713,6 +752,10 @@ function getExpectedPropsForField( required = result?.required?.includes(name) || false; return schema1; }, props.schema); + // Null out nested properties that can show up when additionalProperties is specified + if (!isEmpty(schema?.properties)) { + schema.properties = {}; + } // Get the readonly options from the schema, if any const readonly = get(schema, 'readOnly'); // Get the options from the schema's oneOf, if any @@ -1501,6 +1544,60 @@ describe('LayoutGridField', () => { await userEvent.tab(); expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo'); }); + test('renderField via name explicit layoutGridSchema, nested array', async () => { + const fieldName = 'listOfStrings'; + const props = getProps({ + schema: nestedSchema, + uiSchema: nestedUiSchema, + layoutGridSchema: fieldName, + idSeparator: '.', + registry: nestedSchemaRegistry, + }); + const fieldId = get(props.idSchema, [fieldName, ID_KEY]); + render(); + // Renders a field + const field = screen.getByTestId(LayoutGridField.TEST_IDS.field); + expect(field).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, fieldName))); + // Test onChange, onFocus, onBlur + const input = within(field).getByRole('textbox'); + // Click on the input to cause the focus + await userEvent.click(input); + expect(props.onFocus).toHaveBeenCalledWith(fieldId, ''); + // Type to trigger the onChange + await userEvent.type(input, 'foo'); + // Due to the selection of schema type = `array` the path is appended to the fieldName, duplicating it + expect(props.onChange).toHaveBeenCalledWith('foo', [fieldName, fieldName], props.errorSchema, fieldId); + // Tab out of the input field to cause the blur + await userEvent.tab(); + expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo'); + }); + test('renderField via name explicit layoutGridSchema, nested object', async () => { + const fieldName = 'mapOfStrings'; + const props = getProps({ + schema: nestedSchema, + uiSchema: nestedUiSchema, + layoutGridSchema: fieldName, + idSeparator: '.', + registry: nestedSchemaRegistry, + }); + const fieldId = get(props.idSchema, [fieldName, ID_KEY]); + render(); + // Renders a field + const field = screen.getByTestId(LayoutGridField.TEST_IDS.field); + expect(field).toHaveTextContent(stringifyProps(getExpectedPropsForField(props, fieldName))); + // Test onChange, onFocus, onBlur + const input = within(field).getByRole('textbox'); + // Click on the input to cause the focus + await userEvent.click(input); + expect(props.onFocus).toHaveBeenCalledWith(fieldId, ''); + // Type to trigger the onChange + await userEvent.type(input, 'foo'); + // Due to the selection of schema type = `object` the path is appended to the fieldName, duplicating it + expect(props.onChange).toHaveBeenCalledWith('foo', [fieldName, fieldName], props.errorSchema, fieldId); + // Tab out of the input field to cause the blur + await userEvent.tab(); + expect(props.onBlur).toHaveBeenCalledWith(fieldId, 'foo'); + }); test('renderField via object explicit layoutGridSchema, otherProps', () => { const fieldName = 'employment'; const globalUiOptions = { propToApplyToAllFields: 'foobar' }; diff --git a/packages/core/test/testData/getTestRegistry.tsx b/packages/core/test/testData/getTestRegistry.tsx index e96dbd7215..fc5f912589 100644 --- a/packages/core/test/testData/getTestRegistry.tsx +++ b/packages/core/test/testData/getTestRegistry.tsx @@ -11,6 +11,7 @@ export default function getTestRegistry( templates: Partial = {}, widgets: Registry['widgets'] = {}, formContext: Registry['formContext'] = {}, + globalFormOptions: Registry['globalFormOptions'] = {}, ): Registry { const defaults = getDefaultRegistry(); const schemaUtils = createSchemaUtils(validator, rootSchema); @@ -22,6 +23,6 @@ export default function getTestRegistry( rootSchema, schemaUtils, translateString: englishStringTranslator, - globalFormOptions: {}, + globalFormOptions, }; }