Skip to content

Commit

Permalink
fix: fixed several issue in oneOf/anyOf functions
Browse files Browse the repository at this point in the history
Fixes rjsf-team#2944, rjsf-team#3236, rjsf-team#2978 and possibly others
- In `@rjsf/utils`, added new `getClosestMatchingOption()`, `getFirstMatchingOption()` and `sanitizeDataForNewSchema()` schema-based utility functions
  - Deprecated `getMatchingOption()` and updated all calls to it in other utility functions to use `getFirstMatchingOption()`
  - Added 100% unit tests for all new functions, renaming the old `getMatchingOptionsTest.ts` file to `getFirstMatchingOptionsTest.ts`
  - Updated `createSchemaUtils()` and it's associated type to add the three new functions
- In `@rjsf/validator-ajv6` and `@rjsf/validator-ajv8`, updated the `schema.tests.ts` to add the new tests for the new schema-based utility functions
- In `@rjsf/core`, updated the `MultiSchemaField` to use the new `getClosestMatchingOption()` and `sanitizeDataForNewSchema()` utility functions
  - Also updated the render to properly pass props to the widget and the schema field
- In `@rjsf/playground`, updated `onFormDataEdited()` to only change the formData in the state if the `JSON.stringify()` of the old and new values are different
  - Also updated the `npm start` command to add the `--force` option to avoid issues where changes made to other packages weren't getting picked up due to `vite` caching
- Updated the `utility-functions.md` file to document the new schema-based functions and to fix up incorrect strike-through caused by the unescaped `<S>` generic
- Updated the `5.x upgrade guide.md` file to document the new utility functions and the deprecation of `getMatchingOption()`
  • Loading branch information
heath-freenome committed Jan 20, 2023
1 parent a3cf692 commit d826c1a
Show file tree
Hide file tree
Showing 25 changed files with 1,748 additions and 109 deletions.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ it according to semantic versioning. For example, if your PR adds a breaking cha
should change the heading of the (upcoming) version to include a major version bump.
-->
# 5.0.0-beta-18

# @rjsf/core
- Updated `MultiSchemaField` to utilize the new `getClosestMatchingOption()` and `sanitizeDataForNewSchema()` functions, fixing [#2944](https://github.com/rjsf-team/react-jsonschema-form/issues/2944), [#3236](https://github.com/rjsf-team/react-jsonschema-form/issues/3236), [#2978](https://github.com/rjsf-team/react-jsonschema-form/issues/2978), and probably others

# @rjsf/utils
- Added new `getClosestMatchingOption()`, `getFirstMatchingOption()` and `sanitizeDataForNewSchema()` schema-based utility functions
- Deprecated `getMatchingOption()` and updated all calls to it in other utility functions to use `getFirstMatchingOption()`

## Dev / docs / playground
- Updated the playground to `onFormDataEdited()` to only change the formData in the state if the `JSON.stringify()` of the old and new values are different, partially fixing [#3236](https://github.com/rjsf-team/react-jsonschema-form/issues/3236)
- Updated the playground `npm start` command to always use the `--force` option to avoid issues where changes made to other packages weren't getting picked up due to `vite` caching
- Updated the documentation for `utility-functions` and the `5.x upgrade guide` to add the new utility functions and to document the deprecation of `getMatchingOption()`

# 5.0.0-beta-17

## @rjsf/antd
Expand Down
8 changes: 7 additions & 1 deletion docs/5.x upgrade guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Unfortunately, there is required work pending to properly support React 18, so u
There are four new packages added in RJSF version 5:

- `@rjsf/utils`: All of the [utility functions](https://react-jsonschema-form.readthedocs.io/en/stable/api-reference/utiltity-functions) previously imported from `@rjsf/core/utils` as well as the Typescript types for RJSF version 5.
- The following new utility functions were added: `createSchemaUtils()`, `getInputProps()`, `mergeValidationData()` and `processSelectValue()`
- The following new utility functions were added: `ariaDescribedByIds()`, `createSchemaUtils()`, `descriptionId()`, `enumOptionsDeselectValue()`, `enumOptionsSelectValue()`, `errorId()`, `examplesId()`, `getClosestMatchingOption()`, `getFirstMatchingOption()`, `getInputProps()`, `helpId()`, `mergeValidationData()`, `optionId()`, `processSelectValue()`, `sanitizeDataForNewSchema()` and `titleId()`
- `@rjsf/validator-ajv6`: The [ajv](https://github.com/ajv-validator/ajv)-v6-based validator refactored out of `@rjsf/[email protected]`, that implements the `ValidatorType` interface defined in `@rjsf/utils`.
- `@rjsf/validator-ajv8`: The [ajv](https://github.com/ajv-validator/ajv)-v8-based validator that is an upgrade of the `@rjsf/validator-ajv6`, that implements the `ValidatorType` interface defined in `@rjsf/utils`. See the ajv 6 to 8 [migration guide](https://ajv.js.org/v6-to-v8-migration.html) for more information.
- `@rjsf/mui`: Previously `@rjsf/material-ui/v5`, now provided as its own theme.
Expand Down Expand Up @@ -231,6 +231,7 @@ render((
In version 5, all the utility functions that were previously accessed via `import { utils } from '@rjsf/core';` are now available via `import utils from '@rjsf/utils';`.
Because of the decoupling of validation from `@rjsf/core` there is a breaking change for all the [validator-based utility functions](https://react-jsonschema-form.readthedocs.io/en/stable/api-reference/utiltity-functions#validator-based-utility-functions), since they now require an additional `ValidatorType` parameter.
More over, one previously exported function `resolveSchema()` is no longer exposed in the `@rjsf/utils`, so use `retrieveSchema()` instead.
Finally, one previously exported function `getMatchingOption()` has been deprecated in favor of `getFirstMatchingOption()`.

If you have built custom fields or widgets that utilized any of these breaking-change functions, don't worry, there is a quick and easy solution for you.
The `registry` has a breaking-change which removes the previously deprecated `definitions` property while adding the new `schemaUtils` property.
Expand Down Expand Up @@ -259,8 +260,10 @@ import { RJSFSchema, WidgetProps, getUiOptions } from '@rjsf/utils';
function YourWidget(props: WidgetProps) {
const { registry, uiSchema } = props;
const { schemaUtils } = registry;
// const matchingOption = getMatchingOption({}, options, rootSchema); <- version 4
// const isMultiSelect = isMultiSelect(schema, rootSchema); <- version 4
// const newSchema = resolveSchema(schema, formData, rootSchema); <- version 4
const matchingOption = schemaUtils.getFirstMatchingOption({}, options);
const isMultiSelect = schemaUtils.isMultiSelect(schema);
const newSchema: RJSFSchema = schemaUtils.retrieveSchema(schema, formData);
const options = getUiOptions(uiSchema);
Expand Down Expand Up @@ -399,6 +402,9 @@ From v5, the child fields will correctly use the parent id when generating its o

#### Deprecations added in v5

##### getMatchingOption()
The utility function `getMatchingOption()` was deprecated in favor of the more aptly named `getFirstMatchingOption()` which has the exact same implementation.

##### Non-standard `enumNames` property

`enumNames` is a non-standard JSON Schema field that was deprecated in version 5.
Expand Down
70 changes: 62 additions & 8 deletions docs/api-reference/utility-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,22 +99,22 @@ Return a consistent `id` for the field description element.
Removes the `value` from the currently `selected` list of values.

#### Parameters
- value: EnumOptionsType<S>["value"] - The value that should be selected
- selected: EnumOptionsType<S>["value"][] - The current list of selected values
- value: EnumOptionsType\<S>["value"] - The value that should be selected
- selected: EnumOptionsType\<S>["value"][] - The current list of selected values

#### Returns
- EnumOptionsType<S>["value"][]: The updated `selected` list with the `value` removed from it
- EnumOptionsType\<S>["value"][]: The xupdated `selected` list with the `value` removed from it

### enumOptionsSelectValue\<S extends StrictRJSFSchema = RJSFSchema>()
Add the `value` to the list of `selected` values in the proper order as defined by `allEnumOptions`.

#### Parameters
- value: EnumOptionsType<S>["value"] - The value that should be selected
- selected: EnumOptionsType<S>["value"][] - The current list of selected values
- allEnumOptions: EnumOptionsType<S>[] - The list of all the known enumOptions
- value: EnumOptionsType\<S>["value"] - The value that should be selected
- selected: EnumOptionsType\<S>["value"][] - The current list of selected values
- allEnumOptions: EnumOptionsType\<S>[] - The list of all the known enumOptions

#### Returns
- EnumOptionsType<S>["value"][]: The updated list of selected enum values with `value` added to it in the proper location
- EnumOptionsType\<S>["value"][]: The updated list of selected enum values with `value` added to it in the proper location

### errorId<T = any>()
Return a consistent `id` for the field error element.
Expand Down Expand Up @@ -344,7 +344,7 @@ Return a consistent `id` for the `option`s of a `Radio` or `Checkboxes` widget

#### Parameters
- id: string - The id of the parent component for the option
- option: EnumOptionsType<S> - The option for which the id is desired
- option: EnumOptionsType\<S> - The option for which the id is desired

#### Returns
- string: An id for the option based on the parent `id`
Expand Down Expand Up @@ -517,8 +517,46 @@ Determines whether the combination of `schema` and `uiSchema` properties indicat
#### Returns
- boolean: True if the label should be displayed or false if it should not

### getClosestMatchingOption<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>()
Determines which of the given `options` provided most closely matches the `formData`. Using
`getFirstMatchingOption()` to match two schemas that differ only by the readOnly, default or const value of a field
based on the `formData` and returns 0 when there is no match. Rather than passing in all the `options` at once to
this utility, instead an array of valid option indexes is created by iterating over the list of options, call
`getFirstMatchingOptions` with a list of one junk option and one good option, seeing if the good option is considered
matched.

Once the list of valid indexes is created, if there is only one valid index, just return it. Otherwise, if there are
no valid indexes, then fill the valid indexes array with the indexes of all the options. Next, the index of the
option with the highest score is determined by iterating over the list of valid options, calling
`calculateIndexScore()` on each, comparing it against the current best score, and returning the index of the one that
eventually has the best score.

#### Parameters
- validator: ValidatorType<T, S, F> - An implementation of the `ValidatorType` interface that will be used when necessary
- rootSchema: S - The root schema, used to primarily to look up `$ref`s
- formData: T - The current formData, if any, used to figure out a match
- options: S[] - The list of options to find a matching options from
- [selectedOption=-1]: number - The index of the currently selected option, defaulted to -1 if not specified

#### Returns
- number: The index of the option that is the closest match to the `formData` or the `selectedOption` if no match

### getFirstMatchingOption<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>()
Given the `formData` and list of `options`, attempts to find the index of the first option that matches the data.
Always returns the first option if there is nothing that matches.

#### Parameters
- validator: ValidatorType<T, S, F> - An implementation of the `ValidatorType` interface that will be used when necessary
- formData: T | undefined - The current formData, if any, used to figure out a match
- options: S[] - The list of options to find a matching options from
- rootSchema: S - The root schema, used to primarily to look up `$ref`s

#### Returns
- number: The index of the first matched option or 0 if none is available

### getMatchingOption<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>()
Given the `formData` and list of `options`, attempts to find the index of the option that best matches the data.
Deprecated, use `getFirstMatchingOption()` instead.

#### Parameters
- validator: ValidatorType<T, S, F> - An implementation of the `ValidatorType` interface that will be used when necessary
Expand Down Expand Up @@ -589,6 +627,22 @@ potentially recursive resolution.
#### Returns
- RJSFSchema: The schema having its conditions, additional properties, references and dependencies resolved

### sanitizeDataForNewSchema<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>()
Sanitize the `data` associated with the `oldSchema` so it is considered appropriate for the `newSchema`.
If the new schema does not contain any properties, then `undefined` is returned to clear all the form data.
Due to the nature of schemas, this sanitization happens recursively for nested objects of data.
Also, any properties in the old schema that are non-existent in the new schema are set to `undefined`.

#### Parameters
- validator: ValidatorType<T, S, F> - An implementation of the `ValidatorType` interface that will be used when necessary
- rootSchema: S - The root JSON schema of the entire form
- [newSchema]: S - The new schema for which the data is being sanitized
- [oldSchema]: S - The old schema from which the data originated
- [data={}]: any - The form data associated with the schema, defaulting to an empty object when undefined

#### Returns
- T: The new form data, with all the fields uniquely associated with the old schema set to `undefined`. Will return `undefined` if the new schema is not an object containing properties.

### toIdSchema<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>()
Generates an `IdSchema` object for the `schema`, recursively

Expand Down
129 changes: 55 additions & 74 deletions packages/core/src/components/fields/MultiSchemaField.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import React, { Component } from "react";
import get from "lodash/get";
import isEmpty from "lodash/isEmpty";
import omit from "lodash/omit";
import {
getUiOptions,
getWidget,
guessType,
deepEquals,
FieldProps,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
ERRORS_KEY,
} from "@rjsf/utils";
import has from "lodash/has";
import unset from "lodash/unset";

/** Type used for the state of the `AnyOfField` component */
type AnyOfFieldState = {
Expand Down Expand Up @@ -83,8 +84,12 @@ class AnyOfField<
getMatchingOption(selectedOption: number, formData: T, options: S[]) {
const { schemaUtils } = this.props.registry;

const option = schemaUtils.getMatchingOption(formData, options);
if (option !== 0) {
const option = schemaUtils.getClosestMatchingOption(
formData,
options,
selectedOption
);
if (option > 0) {
return option;
}
// If the form data matches none of the options, use the currently selected
Expand All @@ -98,53 +103,40 @@ class AnyOfField<
*
* @param option -
*/
onOptionChange = (option: any) => {
const selectedOption = parseInt(option, 10);
onOptionChange = (option?: string) => {
const { selectedOption } = this.state;
const { formData, onChange, options, registry } = this.props;
const { schemaUtils } = registry;
const newOption = schemaUtils.retrieveSchema(
options[selectedOption],
const intOption = option !== undefined ? parseInt(option, 10) : -1;
if (intOption === selectedOption) {
return;
}
const newOption =
intOption >= 0
? schemaUtils.retrieveSchema(options[intOption], formData)
: undefined;
const oldOption =
selectedOption >= 0
? schemaUtils.retrieveSchema(options[selectedOption], formData)
: undefined;

let newFormData = schemaUtils.sanitizeDataForNewSchema(
newOption,
oldOption,
formData
);

// If the new option is of type object and the current data is an object,
// discard properties added using the old option.
let newFormData: T | undefined = undefined;
if (
guessType(formData) === "object" &&
(newOption.type === "object" || newOption.properties)
) {
newFormData = Object.assign({}, formData);

const optionsToDiscard = options.slice();
optionsToDiscard.splice(selectedOption, 1);

// Discard any data added using other options
for (const option of optionsToDiscard) {
if (option.properties) {
for (const key in option.properties) {
if (has(newFormData, key)) {
unset(newFormData, key);
}
}
}
}
}
// Call getDefaultFormState to make sure defaults are populated on change. Pass "excludeObjectChildren"
// so that only the root objects themselves are created without adding undefined children properties
onChange(
schemaUtils.getDefaultFormState(
options[selectedOption],
if (newFormData && newOption) {
// Call getDefaultFormState to make sure defaults are populated on change. Pass "excludeObjectChildren"
// so that only the root objects themselves are created without adding undefined children properties
newFormData = schemaUtils.getDefaultFormState(
newOption,
newFormData,
"excludeObjectChildren"
) as T,
undefined,
this.getFieldId()
);
) as T;
}
onChange(newFormData, undefined, this.getFieldId());

this.setState({
selectedOption: parseInt(option, 10),
});
this.setState({ selectedOption: intOption });
};

getFieldId() {
Expand All @@ -158,19 +150,11 @@ class AnyOfField<
*/
render() {
const {
name,
baseType,
disabled = false,
readonly = false,
hideError = false,
errorSchema = {},
formData,
formContext,
idPrefix,
idSeparator,
idSchema,
onBlur,
onChange,
onFocus,
options,
registry,
Expand All @@ -180,8 +164,16 @@ class AnyOfField<
const { widgets, fields } = registry;
const { SchemaField: _SchemaField } = fields;
const { selectedOption } = this.state;
const { widget = "select", ...uiOptions } = getUiOptions<T, S, F>(uiSchema);
const {
widget = "select",
placeholder,
autofocus,
autocomplete,
...uiOptions
} = getUiOptions<T, S, F>(uiSchema);
const Widget = getWidget<T, S, F>({ type: "number" }, widget, widgets);
const rawErrors = get(errorSchema, ERRORS_KEY, []);
const fieldErrorSchema = omit(errorSchema, [ERRORS_KEY]);

const option = options[selectedOption] || null;
let optionSchema;
Expand All @@ -208,33 +200,22 @@ class AnyOfField<
onChange={this.onOptionChange}
onBlur={onBlur}
onFocus={onFocus}
disabled={disabled || isEmpty(enumOptions)}
multiple={false}
rawErrors={rawErrors}
errorSchema={fieldErrorSchema}
value={selectedOption}
options={{ enumOptions }}
options={{ enumOptions, ...uiOptions }}
registry={registry}
formContext={formContext}
{...uiOptions}
placeholder={placeholder}
autocomplete={autocomplete}
autofocus={autofocus}
label=""
/>
</div>
{option !== null && (
<_SchemaField
name={name}
schema={optionSchema}
uiSchema={uiSchema}
errorSchema={errorSchema}
idSchema={idSchema}
idPrefix={idPrefix}
idSeparator={idSeparator}
formData={formData}
formContext={formContext}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
registry={registry}
disabled={disabled}
readonly={readonly}
hideError={hideError}
/>
<_SchemaField {...this.props} schema={optionSchema} />
)}
</div>
);
Expand Down
27 changes: 27 additions & 0 deletions packages/core/test/anyOf_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ describe("anyOf", () => {
expect(node.querySelector("select").id).eql("root__anyof_select");
});

it("should render a root select element with default value", () => {
const formData = { foo: "b" };
const schema = {
type: "object",
anyOf: [
{
title: "foo1",
properties: {
foo: { type: "string", enum: ["a", "b"], default: "a" },
},
},
{
title: "foo2",
properties: {
foo: { type: "string", enum: ["a", "b"], default: "b" },
},
},
],
};

const { node } = createFormComponent({
schema,
formData,
});
expect(node.querySelector("select").value).eql("1");
});

it("should assign a default value and set defaults on option change", () => {
const { node, onChange } = createFormComponent({
schema: {
Expand Down
Loading

0 comments on commit d826c1a

Please sign in to comment.