From d9955097defad167137c91249f146c3aba0e5c09 Mon Sep 17 00:00:00 2001 From: Heath Chiavettone Date: Thu, 6 Nov 2025 09:52:36 -0800 Subject: [PATCH] Feature: expanded FallbackField for object and array Feature improvement for `FallbackField` to support object and array types - In `@rjsf/utils` removed `ADDITIONAL_PROPERTY_KEY_REMOVE` in favor of moving it to a local `core` `constants.ts` file - In `@rjsf/core` added `src/components/constants.ts` and refactored `ADDITIONAL_PROPERTY_KEY_REMOVE` and `IS_RESET` into it - Updated `Form` to get `ADDITIONAL_PROPERTY_KEY_REMOVE` and `IS_RESET` from the constants file - Updated `ObjectField` to get `ADDITIONAL_PROPERTY_KEY_REMOVE` from the constants file - Updated `FallbackField` to add `object` support (via additional properties) and `array` support - Updated `ArrayField` to handle missing `items` differently when the fallback field feature is on so that clicking the add button presents the fallback field UI - Updated the `CHANGELOG.md` accordingly --- CHANGELOG.md | 6 +- packages/core/src/components/Form.tsx | 5 +- packages/core/src/components/constants.ts | 5 ++ .../core/src/components/fields/ArrayField.tsx | 74 +++++++++++-------- .../src/components/fields/FallbackField.tsx | 28 ++++--- .../src/components/fields/ObjectField.tsx | 3 +- packages/core/test/Form.test.jsx | 63 ++++++++++++++++ packages/utils/src/constants.ts | 1 - 8 files changed, 137 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/components/constants.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 121d0d73fb..0def773548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,10 @@ should change the heading of the (upcoming) version to include a major version b ## @rjsf/core - Fixed duplicate label and description rendering in `CheckboxWidget` by conditionally rendering them based on widget type - - Updated `CheckboxWidget` to handle label and description rendering consistently - - Modified `FieldTemplate` to skip label and description rendering for checkbox widgets, fixing ([#4742](https://github.com/rjsf-team/react-jsonschema-form/issues/4742)) + - Updated `CheckboxWidget` to handle label and description rendering consistently + - Modified `FieldTemplate` to skip label and description rendering for checkbox widgets, fixing ([#4742](https://github.com/rjsf-team/react-jsonschema-form/issues/4742)) - Updated `ObjectField` to change the removal of an additional property to defer the work to the `processPendingChange()` handler in `Form`, fixing [#4850](https://github.com/rjsf-team/react-jsonschema-form/issues/4850) +- Updated `FallbackField` to support `object` and `array` types, and improved `ArrayField` so that it handles missing items properly with the fallback field ## @rjsf/fluentui-rc @@ -66,7 +67,6 @@ should change the heading of the (upcoming) version to include a major version b ## @rjsf/utils - Updated `getDefaultFormState()` to not save an undefined field value into an object when the type is `null` and `excludeObjectChildren` is provided, fixing [#4821](https://github.com/rjsf-team/react-jsonschema-form/issues/4821) -- Added new `ADDITIONAL_PROPERTY_KEY_REMOVE` constant ## Dev / docs / playground diff --git a/packages/core/src/components/Form.tsx b/packages/core/src/components/Form.tsx index 4824190d2f..c501f3a11c 100644 --- a/packages/core/src/components/Form.tsx +++ b/packages/core/src/components/Form.tsx @@ -1,6 +1,5 @@ import { Component, ElementType, FormEvent, ReactNode, Ref, RefObject, createRef } from 'react'; import { - ADDITIONAL_PROPERTY_KEY_REMOVE, createSchemaUtils, CustomValidator, deepEquals, @@ -55,9 +54,7 @@ import _toPath from 'lodash/toPath'; import _unset from 'lodash/unset'; import getDefaultRegistry from '../getDefaultRegistry'; - -/** Internal only symbol used by the `reset()` function to indicate that a reset operation is happening */ -const IS_RESET = Symbol('reset'); +import { ADDITIONAL_PROPERTY_KEY_REMOVE, IS_RESET } from './constants'; /** The properties that are passed to the `Form` */ export interface FormProps { diff --git a/packages/core/src/components/constants.ts b/packages/core/src/components/constants.ts new file mode 100644 index 0000000000..4ec912f2af --- /dev/null +++ b/packages/core/src/components/constants.ts @@ -0,0 +1,5 @@ +/** Internal only symbol used by `Form` & `ObjectField` to mark an additional property element to be removed */ +export const ADDITIONAL_PROPERTY_KEY_REMOVE = Symbol('remove-this-key'); + +/** Internal only symbol used by the `reset()` function to indicate that a reset operation is happening */ +export const IS_RESET = Symbol('reset'); diff --git a/packages/core/src/components/fields/ArrayField.tsx b/packages/core/src/components/fields/ArrayField.tsx index 0f6dfe7780..b2aa245a0b 100644 --- a/packages/core/src/components/fields/ArrayField.tsx +++ b/packages/core/src/components/fields/ArrayField.tsx @@ -153,9 +153,12 @@ function getNewFormDataRow, schema: S, ): T { - const { schemaUtils } = registry; + const { schemaUtils, globalFormOptions } = registry; let itemSchema = schema.items as S; - if (isFixedItems(schema) && allowAdditionalItems(schema)) { + if (globalFormOptions.useFallbackUiForUnsupportedType && !itemSchema) { + // If we don't have itemSchema and useFallbackUiForUnsupportedType is on, use an empty schema + itemSchema = {} as S; + } else if (isFixedItems(schema) && allowAdditionalItems(schema)) { itemSchema = schema.additionalItems as S; } // Cast this as a T to work around schema utils being for T[] caused by the FieldProps call on the class @@ -840,7 +843,7 @@ export default function ArrayField, ) { const { schema, uiSchema, errorSchema, fieldPathId, registry, formData, onChange } = props; - const { schemaUtils, translateString } = registry; + const { globalFormOptions, schemaUtils, translateString } = registry; const { keyedFormData, updateKeyedFormData } = useKeyedFormData(formData); // All the children will use childFieldPathId if present in the props, falling back to the fieldPathId const childFieldPathId = props.childFieldPathId ?? fieldPathId; @@ -1027,24 +1030,14 @@ export default function ArrayField(uiSchema); - const UnsupportedFieldTemplate = getTemplate<'UnsupportedFieldTemplate', T[], S, F>( - 'UnsupportedFieldTemplate', - registry, - uiOptions, - ); - - return ( - - ); - } - const arrayProps = { + const arrayAsMultiProps: ArrayAsFieldProps = { + ...props, + formData, + fieldPathId: childFieldPathId, + onSelectChange: onSelectChange, + }; + const arrayProps: InternalArrayFieldProps = { + ...props, handleAddItem, handleCopyItem, handleRemoveItem, @@ -1052,18 +1045,41 @@ export default function ArrayField(uiSchema); + const UnsupportedFieldTemplate = getTemplate<'UnsupportedFieldTemplate', T[], S, F>( + 'UnsupportedFieldTemplate', + registry, + uiOptions, + ); + + return ( + + ); + } + // Add an items schema with type as undefined so it triggers FallbackField later on + const fallbackSchema = { ...schema, [ITEMS_KEY]: { type: undefined } }; + arrayAsMultiProps.schema = fallbackSchema; + arrayProps.schema = fallbackSchema; + } + if (schemaUtils.isMultiSelect(arrayAsMultiProps.schema)) { // If array has enum or uniqueItems set to true, call renderMultiSelect() to render the default multiselect widget or a custom widget, if specified. - return {...props} fieldPathId={childFieldPathId} onSelectChange={onSelectChange} />; + return {...arrayAsMultiProps} />; } if (isCustomWidget(uiSchema)) { - return {...props} fieldPathId={childFieldPathId} onSelectChange={onSelectChange} />; + return {...arrayAsMultiProps} />; } - if (isFixedItems(schema)) { - return {...props} {...arrayProps} />; + if (isFixedItems(arrayAsMultiProps.schema)) { + return {...arrayProps} />; } - if (schemaUtils.isFilesArray(schema, uiSchema)) { - return ; + if (schemaUtils.isFilesArray(arrayAsMultiProps.schema, uiSchema)) { + return {...arrayAsMultiProps} />; } - return {...props} {...arrayProps} />; + return {...arrayProps} />; } diff --git a/packages/core/src/components/fields/FallbackField.tsx b/packages/core/src/components/fields/FallbackField.tsx index 0c883c27eb..7ed2339ba4 100644 --- a/packages/core/src/components/fields/FallbackField.tsx +++ b/packages/core/src/components/fields/FallbackField.tsx @@ -21,7 +21,7 @@ import { JSONSchema7TypeName } from 'json-schema'; function getFallbackTypeSelectionSchema(title: string): RJSFSchema { return { type: 'string', - enum: ['string', 'number', 'boolean'], + enum: ['string', 'number', 'boolean', 'object', 'array'], default: 'string', title: title, }; @@ -36,6 +36,9 @@ function getTypeOfFormData(formData: any): JSONSchema7TypeName { if (dataType === 'string' || dataType === 'number' || dataType === 'boolean') { return dataType; } + if (dataType === 'object') { + return Array.isArray(formData) ? 'array' : 'object'; + } // Treat everything else as a string return 'string'; } @@ -106,20 +109,14 @@ export default function FallbackField< }; if (!globalFormOptions.useFallbackUiForUnsupportedType) { + const { reason = translateString(TranslatableString.UnknownFieldType, [String(schema.type)]) } = props; const UnsupportedFieldTemplate = getTemplate<'UnsupportedFieldTemplate', T, S, F>( 'UnsupportedFieldTemplate', registry, uiOptions, ); - return ( - - ); + return ; } const FallbackFieldTemplate = getTemplate<'FallbackFieldTemplate', T, S, F>( @@ -151,7 +148,18 @@ export default function FallbackField< required={required} /> } - schemaField={} + schemaField={ + + } /> ); } diff --git a/packages/core/src/components/fields/ObjectField.tsx b/packages/core/src/components/fields/ObjectField.tsx index fa58f825bf..df36b2b710 100644 --- a/packages/core/src/components/fields/ObjectField.tsx +++ b/packages/core/src/components/fields/ObjectField.tsx @@ -1,6 +1,5 @@ import { FocusEvent, useCallback, useState } from 'react'; import { - ADDITIONAL_PROPERTY_KEY_REMOVE, ADDITIONAL_PROPERTY_FLAG, ANY_OF_KEY, getTemplate, @@ -30,6 +29,8 @@ import has from 'lodash/has'; import isObject from 'lodash/isObject'; import set from 'lodash/set'; +import { ADDITIONAL_PROPERTY_KEY_REMOVE } from '../constants'; + /** Returns a flag indicating whether the `name` field is required in the object schema * * @param schema - The schema to check diff --git a/packages/core/test/Form.test.jsx b/packages/core/test/Form.test.jsx index c5db5dbc78..ea3d5ebe5a 100644 --- a/packages/core/test/Form.test.jsx +++ b/packages/core/test/Form.test.jsx @@ -145,6 +145,69 @@ describeRepeated('Form common', (createFormComponent) => { }, 'root_unknownProperty', ); + + // Change the fallback type to 'boolean' + fireEvent.change(node.querySelector('select'), { target: { value: 2 } }); + optionValue = node.querySelector('select').value; + optionText = node.querySelector(`select option[value="${optionValue}"]`).textContent; + expect(optionText).to.equal('boolean'); + expect(node.querySelector('input[type=checkbox]')).to.exist; + expect(node.querySelector('input[type=checkbox]').checked).to.equal(true); + // Verify formData was casted to number + sinon.assert.calledWithMatch( + onChange.lastCall, + { + formData: { + unknownProperty: true, + }, + schema, + }, + 'root_unknownProperty', + ); + + // Change the fallback type to 'object' + fireEvent.change(node.querySelector('select'), { target: { value: 3 } }); + optionValue = node.querySelector('select').value; + optionText = node.querySelector(`select option[value="${optionValue}"]`).textContent; + expect(optionText).to.equal('object'); + let addButton = node.querySelector('.rjsf-object-property-expand button'); + expect(addButton).to.exist; + // click the add button + act(() => addButton.click()); + + // Verify formData was casted to number + sinon.assert.calledWithMatch( + onChange.lastCall, + { + formData: { + unknownProperty: { newKey: 'New Value' }, + }, + schema, + }, + 'root_unknownProperty', + ); + + // Change the fallback type to 'array' + fireEvent.change(node.querySelector('select'), { target: { value: 4 } }); + optionValue = node.querySelector('select').value; + optionText = node.querySelector(`select option[value="${optionValue}"]`).textContent; + expect(optionText).to.equal('array'); + addButton = node.querySelector('.rjsf-array-item-add button'); + expect(addButton).to.exist; + // click the add button + act(() => addButton.click()); + + // Verify formData was casted to number + sinon.assert.calledWithMatch( + onChange.lastCall, + { + formData: { + unknownProperty: [undefined], + }, + schema, + }, + 'root_unknownProperty', + ); }); }); diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts index 795bf7de7c..8ecd2a67a5 100644 --- a/packages/utils/src/constants.ts +++ b/packages/utils/src/constants.ts @@ -4,7 +4,6 @@ * utility. */ export const ADDITIONAL_PROPERTY_FLAG = '__additional_property'; -export const ADDITIONAL_PROPERTY_KEY_REMOVE = Symbol('remove-this-key'); export const ADDITIONAL_PROPERTIES_KEY = 'additionalProperties'; export const ALL_OF_KEY = 'allOf'; export const ANY_OF_KEY = 'anyOf';