Skip to content

Commit

Permalink
Fix: made small changes to utility functions to fix #3997, #4314 and #…
Browse files Browse the repository at this point in the history
…4322 (#4329)

* Fix: made small changes to utility functions to fix #3997 and #4322
Fixes #3997 and #4322
- In `@rjsf/utils`, made the following changes:
  - Updated `mergeDefaultsWithFormData()` to not overwrite a default when the formData has an undefined value
  - Updated `getClosestMatchingOption()` to improve the scoring function so that an object container that matches a key gets an extra point
- In `@rjsf/core`, updated `MultiSchemaField` to call `onChange` after setting the new option in state rather than before
- Updated the `CHANGELOG.md` accordingly

* - In order to avoid regressions, added a new `mergeDefaultsWithFormData` prop to the `Experimental_DefaultFormStateBehavior`
- Updated `mergeDefaultsWithFormData()` to add new optional `defaultSupercedesUndefined` that when true uses the defaults rather than `undefined` formData
- Updated `getDefaultFormState()` to pass true to `mergeDefaultsWithFormData` for `defaultSupercedesUndefined` when `mergeDefaultsIntoFormData` has the value `useDefaultIfFormDataUndefined`
- Updated the documentation for the new capabilities
- Updated the playground to add controls for the new `mergeDefaultsIntoFormData` option
  - moved the `Show Error List` component over one column, making it inline radio buttons rather than a select

* - Improved documentation a teeny bit

* - More doc improvements

* - Responded to reviewer feedback

* - Fixed broken links

* - Added section header for wrapping BaseInputTemplate

* - Updated CHANGELOG.md
  • Loading branch information
heath-freenome authored Oct 21, 2024
1 parent f6c5bf7 commit 27e6956
Show file tree
Hide file tree
Showing 14 changed files with 158 additions and 21 deletions.
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/components/fields/MultiSchemaField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,10 @@ class AnyOfField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
// so that only the root objects themselves are created without adding undefined children properties
newFormData = schemaUtils.getDefaultFormState(newOption, newFormData, 'excludeObjectChildren') as T;
}
onChange(newFormData, undefined, this.getFieldId());

this.setState({ selectedOption: intOption });
this.setState({ selectedOption: intOption }, () => {
onChange(newFormData, undefined, this.getFieldId());
});
};

getFieldId() {
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/docs/advanced-customization/custom-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand Down
11 changes: 11 additions & 0 deletions packages/docs/docs/api-reference/form-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions packages/docs/docs/api-reference/utility-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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&lt;S&gt; - See `Form` documentation for the [experimental_customMergeAllOf](./form-props.md#experimental_customMergeAllOf) prop

#### Returns

Expand Down
39 changes: 33 additions & 6 deletions packages/playground/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -282,6 +308,7 @@ export default function Header({
formData={liveSettings}
validator={localValidator}
onChange={handleSetLiveSettings}
uiSchema={liveSettingsBooleanUiSchema}
>
<div />
</Form>
Expand Down
20 changes: 16 additions & 4 deletions packages/utils/src/mergeDefaultsWithFormData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = any>(
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<any>(defaultsArray[idx], value, mergeExtraArrayDefaults);
return mergeDefaultsWithFormData<any>(
defaultsArray[idx],
value,
mergeExtraArrayDefaults,
defaultSupercedesUndefined
);
}
return value;
});
Expand All @@ -44,10 +52,14 @@ export default function mergeDefaultsWithFormData<T = any>(
acc[key as keyof T] = mergeDefaultsWithFormData<T>(
defaults ? get(defaults, key) : {},
get(formData, key),
mergeExtraArrayDefaults
mergeExtraArrayDefaults,
defaultSupercedesUndefined
);
return acc;
}, acc);
}
if (defaultSupercedesUndefined && formData === undefined) {
return defaults;
}
return formData;
}
8 changes: 6 additions & 2 deletions packages/utils/src/schema/getClosestMatchingOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function calculateIndexScore<T = any, S extends StrictRJSFSchema = RJSFSc
validator: ValidatorType<T, S, F>,
rootSchema: S,
schema?: S,
formData: any = {}
formData?: any
): number {
let totalScore = 0;
if (schema) {
Expand Down Expand Up @@ -83,7 +83,11 @@ export function calculateIndexScore<T = any, S extends StrictRJSFSchema = RJSFSc
);
}
if (value.type === 'object') {
return score + calculateIndexScore<T, S, F>(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<T, S, F>(validator, rootSchema, value as S, formValue);
}
if (value.type === guessType(formValue)) {
// If the types match, then we bump the score by one
Expand Down
8 changes: 5 additions & 3 deletions packages/utils/src/schema/getDefaultFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(defaults as T, formData, mergeExtraDefaults);
return mergeDefaultsWithFormData<T>(defaults as T, formData, mergeExtraDefaults, defaultSupercedesUndefined);
}
if (Array.isArray(formData)) {
return mergeDefaultsWithFormData<T[]>(defaults as T[], formData, mergeExtraDefaults);
return mergeDefaultsWithFormData<T[]>(defaults as T[], formData, mergeExtraDefaults, defaultSupercedesUndefined);
}
return formData;
}
12 changes: 11 additions & 1 deletion packages/utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/utils/test/mergeDefaultsWithFormData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/test/schema/getClosestMatchingOptionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions packages/utils/test/schema/getDefaultFormStateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit 27e6956

Please sign in to comment.