Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: made small changes to utility functions to fix #3997, #4314 and #4322 #4329

Merged
merged 8 commits into from
Oct 21, 2024
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
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({});
});
Comment on lines +24 to +26
Copy link
Contributor

Choose a reason for hiding this comment

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

May want to add a case where null is returned when formData is null


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