Skip to content
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
35 changes: 30 additions & 5 deletions packages/core/src/components/fields/LayoutGridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
FormContextType,
GenericObjectType,
getDiscriminatorFieldFromSchema,
getSchemaType,
getTemplate,
getTestIds,
getUiOptions,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -730,20 +746,29 @@ 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<T>, id?: string) => {
const { onChange, errorSchema } = this.props;
let newErrorSchema = errorSchema;
if (errSchema && errorSchema) {
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);
};
};

Expand Down Expand Up @@ -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<S>(schema))}
onBlur={onBlur}
onFocus={onFocus}
options={optionsInfo?.options}
Expand Down
99 changes: 98 additions & 1 deletion packages/core/test/LayoutGridField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(<LayoutGridField {...props} />);
// 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(<LayoutGridField {...props} />);
// 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' };
Expand Down
3 changes: 2 additions & 1 deletion packages/core/test/testData/getTestRegistry.tsx
Copy link
Member Author

@heath-freenome heath-freenome Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nickgros I've been considering exporting this function from the main index.ts of @rjsf/core so that people can use it as a starting point. Thoughts?? Of course it would mean moving it to be a sibling of getDefaultRegistry()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's actually useful to export, it should be fine.

Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default function getTestRegistry(
templates: Partial<Registry['templates']> = {},
widgets: Registry['widgets'] = {},
formContext: Registry['formContext'] = {},
globalFormOptions: Registry['globalFormOptions'] = {},
): Registry {
const defaults = getDefaultRegistry();
const schemaUtils = createSchemaUtils(validator, rootSchema);
Expand All @@ -22,6 +23,6 @@ export default function getTestRegistry(
rootSchema,
schemaUtils,
translateString: englishStringTranslator,
globalFormOptions: {},
globalFormOptions,
};
}