diff --git a/CHANGELOG.md b/CHANGELOG.md index c23540c7d7..0735abc61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,26 @@ should change the heading of the (upcoming) version to include a major version b # 5.22.0 +## @rjsf/core + +- Updated `MultiSchemaField` to call the `onChange` handler after setting the new option, fixing [#3997](https://github.com/rjsf-team/react-jsonschema-form/issues/3977) and [#4314](https://github.com/rjsf-team/react-jsonschema-form/issues/4314) + ## @rjsf/utils +- Added `experimental_customMergeAllOf` option to `retrieveSchema()` and `getDefaultFormState()` to allow custom merging of `allOf` schemas - Made fields with const property pre-filled and readonly, fixing [#2600](https://github.com/rjsf-team/react-jsonschema-form/issues/2600) -- Added `experimental_customMergeAllOf` option to `retrieveSchema` to allow custom merging of `allOf` schemas +- Added `mergeDefaultsIntoFormData` option to `Experimental_DefaultFormStateBehavior` type to control how to handle merging of defaults +- Updated `mergeDefaultsWithFormData()` to add new optional `defaultSupercedesUndefined` that when true uses the defaults rather than `undefined` formData, fixing [#4322](https://github.com/rjsf-team/react-jsonschema-form/issues/4322) +- Updated `getDefaultFormState()` to pass true to `mergeDefaultsWithFormData` for `defaultSupercedesUndefined` when `mergeDefaultsIntoFormData` has the value `useDefaultIfFormDataUndefined`, fixing [#4322](https://github.com/rjsf-team/react-jsonschema-form/issues/4322) +- Updated `getClosestMatchingOption()` to improve the scoring of sub-property objects that are provided over ones that aren't, fixing [#3997](https://github.com/rjsf-team/react-jsonschema-form/issues/3977) and [#4314](https://github.com/rjsf-team/react-jsonschema-form/issues/4314) + +## Dev / docs / playground + +- Updated the `form-props.md` to add documentation for the new `experimental_customMergeAllOf` props and the `experimental_defaultFormStateBehavior.mergeDefaultsIntoFormData` option +- Updated the `utility-functions.md` to add documentation for the new optional `defaultSupercedesUndefined` parameter and the two missing optional fields on `getDefaultFormState()` +- Updated the `custom-templates.md` to add a section header for wrapping `BaseInputTemplate` +- Updated the playground to add controls for the new `mergeDefaultsIntoFormData` option + - In the process, moved the `Show Error List` component over one column, making it inline radio buttons rather than a select # 5.21.2 diff --git a/packages/core/package.json b/packages/core/package.json index 3044356faf..7ce88986e2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -14,7 +14,7 @@ "precommit": "lint-staged", "publish-to-npm": "npm run build && npm publish", "test": "jest", - "test:debug": "node --inspect-brk node_modules/.bin/jest", + "test:debug": "node --inspect-brk ../../node_modules/.bin/jest", "test:update": "jest --u", "test:watch": "jest --watch", "test-coverage": "jest --coverage" diff --git a/packages/core/src/components/fields/MultiSchemaField.tsx b/packages/core/src/components/fields/MultiSchemaField.tsx index c58b080424..7808f3289c 100644 --- a/packages/core/src/components/fields/MultiSchemaField.tsx +++ b/packages/core/src/components/fields/MultiSchemaField.tsx @@ -128,9 +128,10 @@ class AnyOfField { + onChange(newFormData, undefined, this.getFieldId()); + }); }; getFieldId() { diff --git a/packages/docs/docs/advanced-customization/custom-templates.md b/packages/docs/docs/advanced-customization/custom-templates.md index cc0393fd82..6491efbb5c 100644 --- a/packages/docs/docs/advanced-customization/custom-templates.md +++ b/packages/docs/docs/advanced-customization/custom-templates.md @@ -362,6 +362,8 @@ render( ); ``` +### Wrapping BaseInputTemplate to customize it + Sometimes you just need to pass some additional properties to the existing `BaseInputTemplate`. The way to do this varies based upon whether you are using `core` or some other theme (such as `mui`): diff --git a/packages/docs/docs/api-reference/form-props.md b/packages/docs/docs/api-reference/form-props.md index a94910d78e..7cda5b464b 100644 --- a/packages/docs/docs/api-reference/form-props.md +++ b/packages/docs/docs/api-reference/form-props.md @@ -251,6 +251,17 @@ render( ); ``` +### mergeDefaultsIntoFormData + +Optional enumerated flag controlling how the defaults are merged into the form data when dealing with undefined values, defaulting to `useFormDataIfPresent`. + +NOTE: If there is a default for a field and the `formData` is unspecified, the default ALWAYS merges. + +| Flag Value | Description | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `useFormDataIfPresent` | Legacy behavior - Do not merge defaults if there is a value for a field in `formData` even if that value is explicitly set to `undefined` | +| `useDefaultIfFormDataUndefined` | If the value of a field within the `formData` is `undefined`, then use the default value instead | + ## experimental_customMergeAllOf The `experimental_customMergeAllOf` function allows you to provide a custom implementation for merging `allOf` schemas. This can be particularly useful in scenarios where the default [json-schema-merge-allof](https://github.com/mokkabonna/json-schema-merge-allof) library becomes a performance bottleneck, especially with large and complex schemas or doesn't satisfy your needs. diff --git a/packages/docs/docs/api-reference/utility-functions.md b/packages/docs/docs/api-reference/utility-functions.md index 79935921bb..a6b0455234 100644 --- a/packages/docs/docs/api-reference/utility-functions.md +++ b/packages/docs/docs/api-reference/utility-functions.md @@ -575,6 +575,7 @@ When merging defaults and form data, we want to merge in this specific way: - [defaults]: T | undefined - The defaults to merge - [formData]: T | undefined - The form data into which the defaults will be merged - [mergeExtraArrayDefaults=false]: boolean - If true, any additional default array entries are appended onto the formData +- [defaultSupercedesUndefined=false]: boolean - If true, an explicit undefined value will be overwritten by the default value #### Returns @@ -897,6 +898,8 @@ Returns the superset of `formData` that includes the given set updated to includ - [formData]: T | undefined - The current formData, if any, onto which to provide any missing defaults - [rootSchema]: S | undefined - The root schema, used to primarily to look up `$ref`s - [includeUndefinedValues=false]: boolean | "excludeObjectChildren" - Optional flag, if true, cause undefined values to be added as defaults. If "excludeObjectChildren", cause undefined values for this object and pass `includeUndefinedValues` as false when computing defaults for any nested object properties. +- [experimental_defaultFormStateBehavior]: Experimental_DefaultFormStateBehavior - See `Form` documentation for the [experimental_defaultFormStateBehavior](./form-props.md#experimental_defaultFormStateBehavior) prop +- [experimental_customMergeAllOf]: Experimental_CustomMergeAllOf<S> - See `Form` documentation for the [experimental_customMergeAllOf](./form-props.md#experimental_customMergeAllOf) prop #### Returns diff --git a/packages/playground/src/components/Header.tsx b/packages/playground/src/components/Header.tsx index 75979dfde7..a2c77deac3 100644 --- a/packages/playground/src/components/Header.tsx +++ b/packages/playground/src/components/Header.tsx @@ -63,18 +63,18 @@ const liveSettingsBooleanSchema: RJSFSchema = { noValidate: { type: 'boolean', title: 'Disable validation' }, noHtml5Validate: { type: 'boolean', title: 'Disable HTML 5 validation' }, focusOnFirstError: { type: 'boolean', title: 'Focus on 1st Error' }, - }, -}; - -const liveSettingsSelectSchema: RJSFSchema = { - type: 'object', - properties: { showErrorList: { type: 'string', default: 'top', title: 'Show Error List', enum: [false, 'top', 'bottom'], }, + }, +}; + +const liveSettingsSelectSchema: RJSFSchema = { + type: 'object', + properties: { experimental_defaultFormStateBehavior: { title: 'Default Form State Behavior (Experimental)', type: 'object', @@ -157,11 +157,37 @@ const liveSettingsSelectSchema: RJSFSchema = { }, ], }, + mergeDefaultsIntoFormData: { + type: 'string', + title: 'Merge defaults into formData', + default: 'useFormDataIfPresent', + oneOf: [ + { + type: 'string', + title: 'Use undefined field value if present', + enum: ['useFormDataIfPresent'], + }, + { + type: 'string', + title: 'Use default for undefined field value', + enum: ['useDefaultIfFormDataUndefined'], + }, + ], + }, }, }, }, }; +const liveSettingsBooleanUiSchema: UiSchema = { + showErrorList: { + 'ui:widget': 'radio', + 'ui:options': { + inline: true, + }, + }, +}; + const liveSettingsSelectUiSchema: UiSchema = { experimental_defaultFormStateBehavior: { 'ui:options': { @@ -282,6 +308,7 @@ export default function Header({ formData={liveSettings} validator={localValidator} onChange={handleSetLiveSettings} + uiSchema={liveSettingsBooleanUiSchema} >
diff --git a/packages/utils/src/mergeDefaultsWithFormData.ts b/packages/utils/src/mergeDefaultsWithFormData.ts index e4ace80eff..5ffe657725 100644 --- a/packages/utils/src/mergeDefaultsWithFormData.ts +++ b/packages/utils/src/mergeDefaultsWithFormData.ts @@ -12,23 +12,31 @@ import { GenericObjectType } from '../src'; * are deeply merged; additional entries from the defaults are ignored unless `mergeExtraArrayDefaults` is true, in * which case the extras are appended onto the end of the form data * - when the array is not set in form data, the default is copied over - * - scalars are overwritten/set by form data + * - scalars are overwritten/set by form data unless undefined and there is a default AND `defaultSupercedesUndefined` + * is true * * @param [defaults] - The defaults to merge * @param [formData] - The form data into which the defaults will be merged * @param [mergeExtraArrayDefaults=false] - If true, any additional default array entries are appended onto the formData + * @param [defaultSupercedesUndefined=false] - If true, an explicit undefined value will be overwritten by the default value * @returns - The resulting merged form data with defaults */ export default function mergeDefaultsWithFormData( defaults?: T, formData?: T, - mergeExtraArrayDefaults = false + mergeExtraArrayDefaults = false, + defaultSupercedesUndefined = false ): T | undefined { if (Array.isArray(formData)) { const defaultsArray = Array.isArray(defaults) ? defaults : []; const mapped = formData.map((value, idx) => { if (defaultsArray[idx]) { - return mergeDefaultsWithFormData(defaultsArray[idx], value, mergeExtraArrayDefaults); + return mergeDefaultsWithFormData( + defaultsArray[idx], + value, + mergeExtraArrayDefaults, + defaultSupercedesUndefined + ); } return value; }); @@ -44,10 +52,14 @@ export default function mergeDefaultsWithFormData( acc[key as keyof T] = mergeDefaultsWithFormData( defaults ? get(defaults, key) : {}, get(formData, key), - mergeExtraArrayDefaults + mergeExtraArrayDefaults, + defaultSupercedesUndefined ); return acc; }, acc); } + if (defaultSupercedesUndefined && formData === undefined) { + return defaults; + } return formData; } diff --git a/packages/utils/src/schema/getClosestMatchingOption.ts b/packages/utils/src/schema/getClosestMatchingOption.ts index c81ce42c70..400ebab75e 100644 --- a/packages/utils/src/schema/getClosestMatchingOption.ts +++ b/packages/utils/src/schema/getClosestMatchingOption.ts @@ -51,7 +51,7 @@ export function calculateIndexScore, rootSchema: S, schema?: S, - formData: any = {} + formData?: any ): number { let totalScore = 0; if (schema) { @@ -83,7 +83,11 @@ export function calculateIndexScore(validator, rootSchema, value as S, formValue || {}); + if (isObject(formValue)) { + // If the structure is matching then give it a little boost in score + score += 1; + } + return score + calculateIndexScore(validator, rootSchema, value as S, formValue); } if (value.type === guessType(formValue)) { // If the types match, then we bump the score by one diff --git a/packages/utils/src/schema/getDefaultFormState.ts b/packages/utils/src/schema/getDefaultFormState.ts index f51d41d04a..5217664b8f 100644 --- a/packages/utils/src/schema/getDefaultFormState.ts +++ b/packages/utils/src/schema/getDefaultFormState.ts @@ -586,12 +586,14 @@ export default function getDefaultFormState< // No form data? Use schema defaults. return defaults; } - const { mergeExtraDefaults } = experimental_defaultFormStateBehavior?.arrayMinItems || {}; + const { mergeDefaultsIntoFormData, arrayMinItems = {} } = experimental_defaultFormStateBehavior || {}; + const { mergeExtraDefaults } = arrayMinItems; + const defaultSupercedesUndefined = mergeDefaultsIntoFormData === 'useDefaultIfFormDataUndefined'; if (isObject(formData)) { - return mergeDefaultsWithFormData(defaults as T, formData, mergeExtraDefaults); + return mergeDefaultsWithFormData(defaults as T, formData, mergeExtraDefaults, defaultSupercedesUndefined); } if (Array.isArray(formData)) { - return mergeDefaultsWithFormData(defaults as T[], formData, mergeExtraDefaults); + return mergeDefaultsWithFormData(defaults as T[], formData, mergeExtraDefaults, defaultSupercedesUndefined); } return formData; } diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index ed1ca7aa69..0b6c400504 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -66,7 +66,8 @@ export type Experimental_ArrayMinItems = { /** Experimental features to specify different default form state behaviors. Currently, this affects the * handling of optional array fields where `minItems` is set and handling of setting defaults based on the - * value of `emptyObjectFields`. + * value of `emptyObjectFields`. It also affects how `allOf` fields are handled and how to handle merging defaults into + * the formData in relation to explicit `undefined` values via `mergeDefaultsIntoFormData`. */ export type Experimental_DefaultFormStateBehavior = { /** Optional object, that controls how the default form state for arrays with `minItems` is handled. When not provided @@ -86,6 +87,15 @@ export type Experimental_DefaultFormStateBehavior = { * Optional flag to compute the default form state using allOf and if/then/else schemas. Defaults to `skipDefaults'. */ allOf?: 'populateDefaults' | 'skipDefaults'; + /** Optional enumerated flag controlling how the defaults are merged into the form data when dealing with undefined + * values, defaulting to `useFormDataIfPresent`. + * NOTE: If there is a default for a field and the `formData` is unspecified, the default ALWAYS merges. + * - `useFormDataIfPresent`: Legacy behavior - Do not merge defaults if there is a value for a field in `formData`, + * even if that value is explicitly set to `undefined` + * - `useDefaultIfFormDataUndefined`: - If the value of a field within the `formData` is `undefined`, then use the + * default value instead + */ + mergeDefaultsIntoFormData?: 'useFormDataIfPresent' | 'useDefaultIfFormDataUndefined'; }; /** Optional function that allows for custom merging of `allOf` schemas diff --git a/packages/utils/test/mergeDefaultsWithFormData.test.ts b/packages/utils/test/mergeDefaultsWithFormData.test.ts index 919b94ef83..8ccf2dcb64 100644 --- a/packages/utils/test/mergeDefaultsWithFormData.test.ts +++ b/packages/utils/test/mergeDefaultsWithFormData.test.ts @@ -17,6 +17,22 @@ describe('mergeDefaultsWithFormData()', () => { expect(mergeDefaultsWithFormData(undefined, [2])).toEqual([2]); }); + it('should return formData when formData is undefined', () => { + expect(mergeDefaultsWithFormData({}, undefined)).toEqual(undefined); + }); + + it('should return default when formData is undefined and defaultSupercedesUndefined true', () => { + expect(mergeDefaultsWithFormData({}, undefined, undefined, true)).toEqual({}); + }); + + it('should return default when formData is null and defaultSupercedesUndefined true', () => { + expect(mergeDefaultsWithFormData({}, null, undefined, true)).toBeNull(); + }); + + it('should return undefined when formData is undefined', () => { + expect(mergeDefaultsWithFormData(undefined, undefined)).toBeUndefined(); + }); + it('should merge two one-level deep objects', () => { expect(mergeDefaultsWithFormData({ a: 1 }, { b: 2 })).toEqual({ a: 1, diff --git a/packages/utils/test/schema/getClosestMatchingOptionTest.ts b/packages/utils/test/schema/getClosestMatchingOptionTest.ts index 5809ea0875..da16f32573 100644 --- a/packages/utils/test/schema/getClosestMatchingOptionTest.ts +++ b/packages/utils/test/schema/getClosestMatchingOptionTest.ts @@ -49,7 +49,7 @@ export default function getClosestMatchingOptionTest(testValidator: TestValidato expect(calculateIndexScore(testValidator, oneOfSchema, firstOption, ONE_OF_SCHEMA_DATA)).toEqual(1); }); it('returns 8 for second option in oneOf schema', () => { - expect(calculateIndexScore(testValidator, oneOfSchema, secondOption, ONE_OF_SCHEMA_DATA)).toEqual(8); + expect(calculateIndexScore(testValidator, oneOfSchema, secondOption, ONE_OF_SCHEMA_DATA)).toEqual(9); }); it('returns 1 for a schema that has a type matching the formData type', () => { expect(calculateIndexScore(testValidator, oneOfSchema, { type: 'boolean' }, true)).toEqual(1); diff --git a/packages/utils/test/schema/getDefaultFormStateTest.ts b/packages/utils/test/schema/getDefaultFormStateTest.ts index f5cd467e5a..ebbdffb7ec 100644 --- a/packages/utils/test/schema/getDefaultFormStateTest.ts +++ b/packages/utils/test/schema/getDefaultFormStateTest.ts @@ -3743,6 +3743,39 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType expect(getDefaultFormState(testValidator, schema, formData)).toEqual(result); }); }); + describe('object with defaults and undefined in formData, testing mergeDefaultsIntoFormData', () => { + let schema: RJSFSchema; + let defaultedFormData: any; + beforeAll(() => { + schema = { + type: 'object', + properties: { + field: { + type: 'string', + default: 'foo', + }, + }, + required: ['field'], + }; + defaultedFormData = { field: 'foo' }; + }); + it('returns field value of default when formData is empty', () => { + const formData = {}; + expect(getDefaultFormState(testValidator, schema, formData)).toEqual(defaultedFormData); + }); + it('returns field value of undefined when formData has undefined for field', () => { + const formData = { field: undefined }; + expect(getDefaultFormState(testValidator, schema, formData)).toEqual(formData); + }); + it('returns field value of default when formData has undefined for field and `useDefaultIfFormDataUndefined`', () => { + const formData = { field: undefined }; + expect( + getDefaultFormState(testValidator, schema, formData, undefined, undefined, { + mergeDefaultsIntoFormData: 'useDefaultIfFormDataUndefined', + }) + ).toEqual(defaultedFormData); + }); + }); it('should return undefined defaults for a required array property with minItems', () => { const schema: RJSFSchema = { type: 'object',