diff --git a/CHANGELOG.md b/CHANGELOG.md index f39707c5b0..33caa31ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,38 @@ 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 the following issues: + - [#3236](https://github.com/rjsf-team/react-jsonschema-form/issues/3236) + - [#2978](https://github.com/rjsf-team/react-jsonschema-form/issues/2978) + - [#2944](https://github.com/rjsf-team/react-jsonschema-form/issues/2944) + - [#2202](https://github.com/rjsf-team/react-jsonschema-form/issues/2202) + - [#2183](https://github.com/rjsf-team/react-jsonschema-form/issues/2183) + - [#2086](https://github.com/rjsf-team/react-jsonschema-form/issues/2086) + - [#2069](https://github.com/rjsf-team/react-jsonschema-form/issues/2069) + - [#1661](https://github.com/rjsf-team/react-jsonschema-form/issues/1661) + - And probably others +- Updated `ObjectField` to deal with `additionalProperties` with `oneOf`/`anyOf`, fixing [#2538](https://github.com/rjsf-team/react-jsonschema-form/issues/2538) + +## @rjsf/material-ui +- Fix shrinking of `SelectWidget` label only if value is not empty, fixing [#3369](https://github.com/rjsf-team/react-jsonschema-form/issues/3369) + +## @rjsf/mui +- Fix shrinking of `SelectWidget` label only if value is not empty, fixing [#3369](https://github.com/rjsf-team/react-jsonschema-form/issues/3369) + +## @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()` +- Updated `stubExistingAdditionalProperties()` to deal with `additionalProperties` with `oneOf`/`anyOf`, fixing [#2538](https://github.com/rjsf-team/react-jsonschema-form/issues/2538) +- Updated `getSchemaType()` to grab the type of the first element of a `oneOf`/`anyOf`, fixing [#1654](https://github.com/rjsf-team/react-jsonschema-form/issues/1654) + +## 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 diff --git a/docs/5.x upgrade guide.md b/docs/5.x upgrade guide.md index 0d5766a59a..244326d75e 100644 --- a/docs/5.x upgrade guide.md +++ b/docs/5.x upgrade guide.md @@ -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/core@4.x`, 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. @@ -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, the 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. @@ -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); @@ -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. diff --git a/docs/api-reference/utility-functions.md b/docs/api-reference/utility-functions.md index 4dfda587f3..1ed58d387f 100644 --- a/docs/api-reference/utility-functions.md +++ b/docs/api-reference/utility-functions.md @@ -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["value"] - The value that should be selected -- selected: EnumOptionsType["value"][] - The current list of selected values +- value: EnumOptionsType\["value"] - The value that should be selected +- selected: EnumOptionsType\["value"][] - The current list of selected values #### Returns -- EnumOptionsType["value"][]: The updated `selected` list with the `value` removed from it +- EnumOptionsType\["value"][]: The updated `selected` list with the `value` removed from it ### enumOptionsSelectValue\() Add the `value` to the list of `selected` values in the proper order as defined by `allEnumOptions`. #### Parameters -- value: EnumOptionsType["value"] - The value that should be selected -- selected: EnumOptionsType["value"][] - The current list of selected values -- allEnumOptions: EnumOptionsType[] - The list of all the known enumOptions +- value: EnumOptionsType\["value"] - The value that should be selected +- selected: EnumOptionsType\["value"][] - The current list of selected values +- allEnumOptions: EnumOptionsType\[] - The list of all the known enumOptions #### Returns -- EnumOptionsType["value"][]: The updated list of selected enum values with `value` added to it in the proper location +- EnumOptionsType\["value"][]: The updated list of selected enum values with `value` added to it in the proper location ### errorId() Return a consistent `id` for the field error element. @@ -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 - The option for which the id is desired +- option: EnumOptionsType\ - The option for which the id is desired #### Returns - string: An id for the option based on the parent `id` @@ -517,8 +517,38 @@ 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() +Determines which of the given `options` provided most closely matches the `formData`. +Returns the index of the option that is valid and is the closest match, or 0 if there is no match. + +The closest match is determined using the number of matching properties, and more heavily favors options with matching readOnly, default, or const values. + +#### Parameters +- validator: ValidatorType - 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 | undefined - 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() +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 - 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() 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 - An implementation of the `ValidatorType` interface that will be used when necessary @@ -589,6 +619,22 @@ potentially recursive resolution. #### Returns - RJSFSchema: The schema having its conditions, additional properties, references and dependencies resolved +### sanitizeDataForNewSchema() +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 - 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() Generates an `IdSchema` object for the `schema`, recursively diff --git a/packages/core/src/components/fields/MultiSchemaField.tsx b/packages/core/src/components/fields/MultiSchemaField.tsx index cb7c6b85c1..d1e9ea9330 100644 --- a/packages/core/src/components/fields/MultiSchemaField.tsx +++ b/packages/core/src/components/fields/MultiSchemaField.tsx @@ -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 = { @@ -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 @@ -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() { @@ -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, @@ -180,8 +164,16 @@ class AnyOfField< const { widgets, fields } = registry; const { SchemaField: _SchemaField } = fields; const { selectedOption } = this.state; - const { widget = "select", ...uiOptions } = getUiOptions(uiSchema); + const { + widget = "select", + placeholder, + autofocus, + autocomplete, + ...uiOptions + } = getUiOptions(uiSchema); const Widget = getWidget({ type: "number" }, widget, widgets); + const rawErrors = get(errorSchema, ERRORS_KEY, []); + const fieldErrorSchema = omit(errorSchema, [ERRORS_KEY]); const option = options[selectedOption] || null; let optionSchema; @@ -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="" /> {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} /> )} ); diff --git a/packages/core/src/components/fields/ObjectField.tsx b/packages/core/src/components/fields/ObjectField.tsx index ffb16f577f..3885a96096 100644 --- a/packages/core/src/components/fields/ObjectField.tsx +++ b/packages/core/src/components/fields/ObjectField.tsx @@ -13,6 +13,8 @@ import { ADDITIONAL_PROPERTY_FLAG, PROPERTIES_KEY, REF_KEY, + ANY_OF_KEY, + ONE_OF_KEY, } from "@rjsf/utils"; import get from "lodash/get"; import has from "lodash/has"; @@ -203,13 +205,17 @@ class ObjectField< let type: RJSFSchema["type"] = undefined; if (isObject(schema.additionalProperties)) { type = schema.additionalProperties.type; - if (REF_KEY in schema.additionalProperties) { + let apSchema = schema.additionalProperties; + if (REF_KEY in apSchema) { const { schemaUtils } = registry; - const refSchema = schemaUtils.retrieveSchema( - { $ref: schema.additionalProperties[REF_KEY] } as S, + apSchema = schemaUtils.retrieveSchema( + { $ref: apSchema[REF_KEY] } as S, formData ); - type = refSchema.type; + type = apSchema.type; + } + if (!type && (ANY_OF_KEY in apSchema || ONE_OF_KEY in apSchema)) { + type = "object"; } } diff --git a/packages/core/src/withTheme.tsx b/packages/core/src/withTheme.tsx index d6367de70a..900e6b052a 100644 --- a/packages/core/src/withTheme.tsx +++ b/packages/core/src/withTheme.tsx @@ -28,10 +28,10 @@ export default function withTheme< { fields, widgets, templates, ...directProps }: FormProps, ref: ForwardedRef> ) => { - fields = { ...themeProps.fields, ...fields }; - widgets = { ...themeProps.widgets, ...widgets }; + fields = { ...themeProps?.fields, ...fields }; + widgets = { ...themeProps?.widgets, ...widgets }; templates = { - ...themeProps.templates, + ...themeProps?.templates, ...templates, ButtonTemplates: { ...themeProps?.templates?.ButtonTemplates, diff --git a/packages/core/test/anyOf_test.js b/packages/core/test/anyOf_test.js index eb11c49efa..baaad3cac7 100644 --- a/packages/core/test/anyOf_test.js +++ b/packages/core/test/anyOf_test.js @@ -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: { @@ -812,6 +839,71 @@ describe("anyOf", () => { expect(options[1].firstChild.nodeValue).eql("Person"); }); + it("should select anyOf in additionalProperties with anyOf", () => { + const schema = { + type: "object", + properties: { + testProperty: { + description: "Any key name, fixed set of possible values", + type: "object", + minProperties: 1, + additionalProperties: { + anyOf: [ + { + title: "my choice 1", + type: "object", + properties: { + prop1: { + description: "prop1 description", + type: "string", + }, + }, + required: ["prop1"], + additionalProperties: false, + }, + { + title: "my choice 2", + type: "object", + properties: { + prop2: { + description: "prop2 description", + type: "string", + }, + }, + required: ["prop2"], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ["testProperty"], + }; + + const { node, onChange } = createFormComponent({ + schema, + formData: { testProperty: { newKey: { prop2: "foo" } } }, + }); + + const $select = node.querySelector( + "select#root_testProperty_newKey__anyof_select" + ); + + expect($select.value).eql("1"); + + Simulate.change($select, { + target: { value: $select.options[0].value }, + }); + + expect($select.value).eql("0"); + + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { + testProperty: { newKey: { prop1: undefined, prop2: undefined } }, + }, + }); + }); + describe("Arrays", () => { it("should correctly render form inputs for anyOf inside array items", () => { const schema = { diff --git a/packages/core/test/oneOf_test.js b/packages/core/test/oneOf_test.js index 92df08f3c7..578897e0c3 100644 --- a/packages/core/test/oneOf_test.js +++ b/packages/core/test/oneOf_test.js @@ -659,12 +659,77 @@ describe("oneOf", () => { sinon.assert.calledWithMatch( onChange.lastCall, { - formData: { ipsum: {} }, + formData: { ipsum: {}, lorem: undefined }, }, "root__oneof_select" ); }); + it("should select oneOf in additionalProperties with oneOf", () => { + const schema = { + type: "object", + properties: { + testProperty: { + description: "Any key name, fixed set of possible values", + type: "object", + minProperties: 1, + additionalProperties: { + oneOf: [ + { + title: "my choice 1", + type: "object", + properties: { + prop1: { + description: "prop1 description", + type: "string", + }, + }, + required: ["prop1"], + additionalProperties: false, + }, + { + title: "my choice 2", + type: "object", + properties: { + prop2: { + description: "prop2 description", + type: "string", + }, + }, + required: ["prop2"], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ["testProperty"], + }; + + const { node, onChange } = createFormComponent({ + schema, + formData: { testProperty: { newKey: { prop2: "foo" } } }, + }); + + const $select = node.querySelector( + "select#root_testProperty_newKey__oneof_select" + ); + + expect($select.value).eql("1"); + + Simulate.change($select, { + target: { value: $select.options[0].value }, + }); + + expect($select.value).eql("0"); + + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { + testProperty: { newKey: { prop1: undefined, prop2: undefined } }, + }, + }); + }); + describe("Arrays", () => { it("should correctly render mixed types for oneOf inside array items", () => { const schema = { @@ -909,6 +974,139 @@ describe("oneOf", () => { expect(transformerId.value).eql("to_absolute"); }); + it("should infer the value of an array with nested oneOfs properly", () => { + // From https://github.com/rjsf-team/react-jsonschema-form/issues/2944 + const schema = { + type: "array", + items: { + oneOf: [ + { + properties: { + lorem: { + type: "string", + }, + }, + required: ["lorem"], + }, + { + properties: { + ipsum: { + oneOf: [ + { + properties: { + day: { + type: "string", + }, + }, + }, + { + properties: { + night: { + type: "string", + }, + }, + }, + ], + }, + }, + required: ["ipsum"], + }, + ], + }, + }; + const { node } = createFormComponent({ + schema, + formData: [{ ipsum: { night: "nicht" } }], + }); + const outerOneOf = node.querySelector("select#root_0__oneof_select"); + expect(outerOneOf.value).eql("1"); + const innerOneOf = node.querySelector("select#root__oneof_select"); + expect(innerOneOf.value).eql("1"); + }); + it("should update formData to remove unnecessary data when one of option changes", () => { + const schema = { + title: "UFO Sightings", + type: "object", + required: ["craftTypes"], + properties: { + craftTypes: { + type: "array", + minItems: 1, + uniqueItems: true, + title: "Type of UFO", + items: { + oneOf: [ + { + title: "Cigar Shaped", + type: "object", + required: ["daysOfYear"], + properties: { + name: { + type: "string", + title: "What do you call it?", + }, + daysOfYear: { + type: "array", + minItems: 1, + uniqueItems: true, + title: "What days of the year did you see it?", + items: { + type: "number", + title: "Day", + }, + }, + }, + }, + { + title: "Round", + type: "object", + required: ["keywords"], + properties: { + title: { + type: "string", + title: "What should we call it?", + }, + keywords: { + type: "array", + minItems: 1, + uniqueItems: true, + title: "List of keywords related to the sighting", + items: { + type: "string", + title: "Keyword", + }, + }, + }, + }, + ], + }, + }, + }, + }; + const { node, onChange } = createFormComponent({ + schema, + }); + + // Added an empty array initially + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { craftTypes: [{ daysOfYear: [undefined] }] }, + }); + + const select = node.querySelector("select#root_craftTypes_0__oneof_select"); + + Simulate.change(select, { + target: { value: select.options[1].value }, + }); + + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { + craftTypes: [ + { keywords: [undefined], title: undefined, daysOfYear: undefined }, + ], + }, + }); + }); + describe("Custom Field", function () { const schema = { anyOf: [ diff --git a/packages/playground/package.json b/packages/playground/package.json index 1c0ac49a1a..c1df410fb9 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -15,7 +15,7 @@ "precommit": "lint-staged", "publish-to-gh-pages": "npm run build && gh-pages --dist build/", "publish-to-npm": "npm run build && npm publish", - "start": "vite", + "start": "vite --force", "preview": "vite preview" }, "lint-staged": { diff --git a/packages/playground/src/app.jsx b/packages/playground/src/app.jsx index b23209530b..486685b32c 100644 --- a/packages/playground/src/app.jsx +++ b/packages/playground/src/app.jsx @@ -5,6 +5,7 @@ import "react-app-polyfill/ie11"; import Form, { withTheme } from "@rjsf/core"; import { shouldRender } from "@rjsf/utils"; import localValidator from "@rjsf/validator-ajv8"; +import isEqualWith from "lodash/isEqualWith"; import DemoFrame from "./DemoFrame"; import ErrorBoundary from "./ErrorBoundary"; @@ -413,7 +414,16 @@ class Playground extends Component { onUISchemaEdited = (uiSchema) => this.setState({ uiSchema, shareURL: null }); - onFormDataEdited = (formData) => this.setState({ formData, shareURL: null }); + onFormDataEdited = (formData) => { + if (!isEqualWith(formData, this.state.formData, (newValue, oldValue) => { + // Since this is coming from the editor which uses JSON.stringify to trim undefined values compare the values + // using JSON.stringify to see if the trimmed formData is the same as the untrimmed state + // Sometimes passing the trimmed value back into the Form causes the defaults to be improperly assigned + return JSON.stringify(oldValue) === JSON.stringify(newValue); + })) { + this.setState({ formData, shareURL: null }); + } + }; onExtraErrorsEdited = (extraErrors) => this.setState({ extraErrors, shareURL: null }); @@ -529,7 +539,7 @@ class Playground extends Component { theme={theme} select={this.onThemeSelected} /> - {themes[theme].subthemes && ( + {themes[theme] && themes[theme].subthemes && ( ( + this.validator, + this.rootSchema, + formData, + options, + selectedOption + ); + } + + /** 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. + * + * @param formData - The current formData, if any, used to figure out a match + * @param options - The list of options to find a matching options from + * @returns - The firstindex of the matched option or 0 if none is available + */ + getFirstMatchingOption(formData: T | undefined, options: S[]): number { + return getFirstMatchingOption( + this.validator, + formData, + options, + this.rootSchema + ); + } + /** Given the `formData` and list of `options`, attempts to find the index of the option that best matches the data. + * Deprecated, use `getFirstMatchingOption()` instead. * * @param formData - The current formData, if any, onto which to provide any missing defaults * @param options - The list of options to find a matching options from * @returns - The index of the matched option or 0 if none is available + * @deprecated */ getMatchingOption(formData: T, options: S[]) { return getMatchingOption( @@ -201,6 +247,27 @@ class SchemaUtils< ); } + /** 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 schemas that are non-existent in the new schema are set to `undefined`. + * + * @param [newSchema] - The new schema for which the data is being sanitized + * @param [oldSchema] - The old schema from which the data originated + * @param [data={}] - The form data associated with the schema, defaulting to an empty object when undefined + * @returns - 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. + */ + sanitizeDataForNewSchema(newSchema?: S, oldSchema?: S, data?: any): T { + return sanitizeDataForNewSchema( + this.validator, + this.rootSchema, + newSchema, + oldSchema, + data + ); + } + /** Generates an `IdSchema` object for the `schema`, recursively * * @param schema - The schema for which the display label flag is desired diff --git a/packages/utils/src/getSchemaType.ts b/packages/utils/src/getSchemaType.ts index dcd5557c3c..7b1611cebc 100644 --- a/packages/utils/src/getSchemaType.ts +++ b/packages/utils/src/getSchemaType.ts @@ -29,6 +29,14 @@ export default function getSchemaType( return "object"; } + if (!type && Array.isArray(schema.oneOf) && schema.oneOf.length) { + return getSchemaType(schema.oneOf[0] as S); + } + + if (!type && Array.isArray(schema.anyOf) && schema.anyOf.length) { + return getSchemaType(schema.anyOf[0] as S); + } + if (Array.isArray(type) && type.length === 2 && type.includes("null")) { type = type.find((type) => type !== "null"); } diff --git a/packages/utils/src/schema/getClosestMatchingOption.ts b/packages/utils/src/schema/getClosestMatchingOption.ts new file mode 100644 index 0000000000..a6c7fa983d --- /dev/null +++ b/packages/utils/src/schema/getClosestMatchingOption.ts @@ -0,0 +1,222 @@ +import get from "lodash/get"; +import has from "lodash/has"; +import isObject from "lodash/isObject"; +import isString from "lodash/isString"; +import reduce from "lodash/reduce"; +import times from "lodash/times"; + +import getFirstMatchingOption from "./getFirstMatchingOption"; +import retrieveSchema from "./retrieveSchema"; +import { ONE_OF_KEY, REF_KEY } from "../constants"; +import guessType from "../guessType"; +import { + FormContextType, + RJSFSchema, + StrictRJSFSchema, + ValidatorType, +} from "../types"; + +/** A junk option used to determine when the getFirstMatchingOption call really matches an option rather than returning + * the first item + */ +export const JUNK_OPTION: StrictRJSFSchema = { + type: "object", + properties: { + __not_really_there__: { + type: "number", + }, + }, +}; + +/** Recursive function that calculates the score of a `formData` against the given `schema`. The computation is fairly + * simple. Initially the total score is 0. When `schema.properties` object exists, then all the `key/value` pairs within + * the object are processed as follows after obtaining the formValue from `formData` using the `key`: + * - If the `value` contains a `$ref`, `calculateIndexScore()` is called recursively with the formValue and the new + * schema that is the result of the ref in the schema being resolved and that sub-schema's resulting score is added to + * the total. + * - If the `value` contains a `oneOf` and there is a formValue, then score based on the index returned from calling + * `getClosestMatchingOption()` of that oneOf. + * - If the type of the `value` is 'object', `calculateIndexScore()` is called recursively with the formValue and the + * `value` itself as the sub-schema, and the score is added to the total. + * - If the type of the `value` matches the guessed-type of the `formValue`, the score is incremented by 1, UNLESS the + * value has a `default` or `const`. In those case, if the `default` or `const` and the `formValue` match, the score + * is incremented by another 1 otherwise it is decremented by 1. + * + * @param validator - An implementation of the `ValidatorType` interface that will be used when necessary + * @param rootSchema - The root JSON schema of the entire form + * @param schema - The schema for which the score is being calculated + * @param formData - The form data associated with the schema, used to calculate the score + * @returns - The score a schema against the formData + */ +export function calculateIndexScore< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + validator: ValidatorType, + rootSchema: S, + schema?: S, + formData: any = {} +): number { + let totalScore = 0; + if (schema) { + if (isObject(schema.properties)) { + totalScore += reduce( + schema.properties, + (score, value, key) => { + const formValue = get(formData, key); + if (typeof value === "boolean") { + return score; + } + if (has(value, REF_KEY)) { + const newSchema = retrieveSchema( + validator, + value as S, + rootSchema, + formValue + ); + return ( + score + + calculateIndexScore( + validator, + rootSchema, + newSchema, + formValue || {} + ) + ); + } + if (has(value, ONE_OF_KEY) && formValue) { + return ( + score + + getClosestMatchingOption( + validator, + rootSchema, + formValue, + get(value, ONE_OF_KEY) as S[] + ) + ); + } + if (value.type === "object") { + 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 + let newScore = score + 1; + if (value.default) { + // If the schema contains a readonly default value score the value that matches the default higher and + // any non-matching value lower + newScore += formValue === value.default ? 1 : -1; + } else if (value.const) { + // If the schema contains a const value score the value that matches the default higher and + // any non-matching value lower + newScore += formValue === value.const ? 1 : -1; + } + // TODO eventually, deal with enums/arrays + return newScore; + } + return score; + }, + 0 + ); + } else if (isString(schema.type) && schema.type === guessType(formData)) { + totalScore += 1; + } + } + return totalScore; +} + +/** 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. + * + * @param validator - An implementation of the `ValidatorType` interface that will be used when necessary + * @param rootSchema - The root JSON schema of the entire form + * @param formData - The form data associated with the schema + * @param options - The list of options that can be selected from + * @param [selectedOption=-1] - The index of the currently selected option, defaulted to -1 if not specified + * @returns - The index of the option that is the closest match to the `formData` or the `selectedOption` if no match + */ +export default function getClosestMatchingOption< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + validator: ValidatorType, + rootSchema: S, + formData: T | undefined, + options: S[], + selectedOption = -1 +): number { + // Reduce the array of options down to a list of the indexes that are considered matching options + const allValidIndexes = options.reduce( + (validList: number[], option, index: number) => { + const testOptions: S[] = [JUNK_OPTION as S, option]; + const match = getFirstMatchingOption( + validator, + formData, + testOptions, + rootSchema + ); + // The match is the real option, so add its index to list of valid indexes + if (match === 1) { + validList.push(index); + } + return validList; + }, + [] + ); + + // There is only one valid index, so return it! + if (allValidIndexes.length === 1) { + return allValidIndexes[0]; + } + if (!allValidIndexes.length) { + // No indexes were valid, so we'll score all the options, add all the indexes + times(options.length, (i) => allValidIndexes.push(i)); + } + type BestType = { bestIndex: number; bestScore: number }; + // Score all the options in the list of valid indexes and return the index with the best score + const { bestIndex }: BestType = allValidIndexes.reduce( + (scoreData: BestType, index: number) => { + const { bestScore } = scoreData; + let option = options[index]; + if (has(option, REF_KEY)) { + option = retrieveSchema( + validator, + option, + rootSchema, + formData + ); + } + const score = calculateIndexScore( + validator, + rootSchema, + option, + formData + ); + if (score > bestScore) { + return { bestIndex: index, bestScore: score }; + } + return scoreData; + }, + { bestIndex: selectedOption, bestScore: 0 } + ); + return bestIndex; +} diff --git a/packages/utils/src/schema/getDefaultFormState.ts b/packages/utils/src/schema/getDefaultFormState.ts index 8be1438827..4cc677b62c 100644 --- a/packages/utils/src/schema/getDefaultFormState.ts +++ b/packages/utils/src/schema/getDefaultFormState.ts @@ -10,7 +10,7 @@ import { REF_KEY, } from "../constants"; import findSchemaDefinition from "../findSchemaDefinition"; -import getMatchingOption from "./getMatchingOption"; +import getClosestMatchingOption from "./getClosestMatchingOption"; import getSchemaType from "../getSchemaType"; import isObject from "../isObject"; import isFixedItems from "../isFixedItems"; @@ -156,20 +156,22 @@ export function computeDefaults< ) as T[]; } else if (ONE_OF_KEY in schema) { schema = schema.oneOf![ - getMatchingOption( + getClosestMatchingOption( validator, + rootSchema, isEmpty(formData) ? undefined : formData, schema.oneOf as S[], - rootSchema + 0 ) ] as S; } else if (ANY_OF_KEY in schema) { schema = schema.anyOf![ - getMatchingOption( + getClosestMatchingOption( validator, + rootSchema, isEmpty(formData) ? undefined : formData, schema.anyOf as S[], - rootSchema + 0 ) ] as S; } diff --git a/packages/utils/src/schema/getFirstMatchingOption.ts b/packages/utils/src/schema/getFirstMatchingOption.ts new file mode 100644 index 0000000000..f01b11d372 --- /dev/null +++ b/packages/utils/src/schema/getFirstMatchingOption.ts @@ -0,0 +1,29 @@ +import getMatchingOption from "./getMatchingOption"; +import { + FormContextType, + RJSFSchema, + StrictRJSFSchema, + ValidatorType, +} from "../types"; + +/** 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. + * + * @param validator - An implementation of the `ValidatorType` interface that will be used when necessary + * @param formData - The current formData, if any, used to figure out a match + * @param options - The list of options to find a matching options from + * @param rootSchema - The root schema, used to primarily to look up `$ref`s + * @returns - The index of the first matched option or 0 if none is available + */ +export default function getFirstMatchingOption< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + validator: ValidatorType, + formData: T | undefined, + options: S[], + rootSchema: S +): number { + return getMatchingOption(validator, formData, options, rootSchema); +} diff --git a/packages/utils/src/schema/getMatchingOption.ts b/packages/utils/src/schema/getMatchingOption.ts index 78a2e6806a..274cbb385d 100644 --- a/packages/utils/src/schema/getMatchingOption.ts +++ b/packages/utils/src/schema/getMatchingOption.ts @@ -6,12 +6,14 @@ import { } from "../types"; /** Given the `formData` and list of `options`, attempts to find the index of the option that best matches the data. + * Deprecated, use `getFirstMatchingOption()` instead. * * @param validator - An implementation of the `ValidatorType` interface that will be used when necessary * @param formData - The current formData, if any, used to figure out a match * @param options - The list of options to find a matching options from * @param rootSchema - The root schema, used to primarily to look up `$ref`s * @returns - The index of the matched option or 0 if none is available + * @deprecated */ export default function getMatchingOption< T = any, diff --git a/packages/utils/src/schema/index.ts b/packages/utils/src/schema/index.ts index 479018aafc..9e2ac00182 100644 --- a/packages/utils/src/schema/index.ts +++ b/packages/utils/src/schema/index.ts @@ -1,23 +1,29 @@ import getDefaultFormState from "./getDefaultFormState"; import getDisplayLabel from "./getDisplayLabel"; +import getClosestMatchingOption from "./getClosestMatchingOption"; +import getFirstMatchingOption from "./getFirstMatchingOption"; import getMatchingOption from "./getMatchingOption"; import isFilesArray from "./isFilesArray"; import isMultiSelect from "./isMultiSelect"; import isSelect from "./isSelect"; import mergeValidationData from "./mergeValidationData"; import retrieveSchema from "./retrieveSchema"; +import sanitizeDataForNewSchema from "./sanitizeDataForNewSchema"; import toIdSchema from "./toIdSchema"; import toPathSchema from "./toPathSchema"; export { getDefaultFormState, getDisplayLabel, + getClosestMatchingOption, + getFirstMatchingOption, getMatchingOption, isFilesArray, isMultiSelect, isSelect, mergeValidationData, retrieveSchema, + sanitizeDataForNewSchema, toIdSchema, toPathSchema, }; diff --git a/packages/utils/src/schema/retrieveSchema.ts b/packages/utils/src/schema/retrieveSchema.ts index bbfd8650d1..2868636603 100644 --- a/packages/utils/src/schema/retrieveSchema.ts +++ b/packages/utils/src/schema/retrieveSchema.ts @@ -6,7 +6,9 @@ import { ADDITIONAL_PROPERTIES_KEY, ADDITIONAL_PROPERTY_FLAG, ALL_OF_KEY, + ANY_OF_KEY, DEPENDENCIES_KEY, + ONE_OF_KEY, REF_KEY, } from "../constants"; import findSchemaDefinition, { @@ -22,7 +24,7 @@ import { StrictRJSFSchema, ValidatorType, } from "../types"; -import getMatchingOption from "./getMatchingOption"; +import getFirstMatchingOption from "./getFirstMatchingOption"; /** Resolves a conditional block (if/else/then) by removing the condition and merging the appropriate conditional branch * with the rest of the schema @@ -205,6 +207,14 @@ export function stubExistingAdditionalProperties< ); } else if ("type" in schema.additionalProperties!) { additionalProperties = { ...schema.additionalProperties }; + } else if ( + ANY_OF_KEY in schema.additionalProperties! || + ONE_OF_KEY in schema.additionalProperties! + ) { + additionalProperties = { + type: "object", + ...schema.additionalProperties, + }; } else { additionalProperties = { type: guessType(get(formData, [key])) }; } @@ -310,7 +320,7 @@ export function resolveDependencies< let resolvedSchema: S = remainingSchema as S; if (Array.isArray(resolvedSchema.oneOf)) { resolvedSchema = resolvedSchema.oneOf[ - getMatchingOption( + getFirstMatchingOption( validator, formData, resolvedSchema.oneOf as S[], @@ -319,7 +329,7 @@ export function resolveDependencies< ] as S; } else if (Array.isArray(resolvedSchema.anyOf)) { resolvedSchema = resolvedSchema.anyOf[ - getMatchingOption( + getFirstMatchingOption( validator, formData, resolvedSchema.anyOf as S[], diff --git a/packages/utils/src/schema/sanitizeDataForNewSchema.ts b/packages/utils/src/schema/sanitizeDataForNewSchema.ts new file mode 100644 index 0000000000..5c9c292faf --- /dev/null +++ b/packages/utils/src/schema/sanitizeDataForNewSchema.ts @@ -0,0 +1,243 @@ +import get from "lodash/get"; +import has from "lodash/has"; + +import { + FormContextType, + GenericObjectType, + RJSFSchema, + StrictRJSFSchema, + ValidatorType, +} from "../types"; +import { PROPERTIES_KEY, REF_KEY } from "../constants"; +import retrieveSchema from "./retrieveSchema"; + +const NO_VALUE = Symbol("no Value"); + +/** 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`. The data sanitization process has the following flow: + * + * - If the new schema is an object that contains a `properties` object then: + * - Create a `removeOldSchemaData` object, setting each key in the `oldSchema.properties` having `data` to undefined + * - Create an empty `nestedData` object for use in the key filtering below: + * - Iterate over each key in the `newSchema.properties` as follows: + * - Get the `formValue` of the key from the `data` + * - Get the `oldKeySchema` and `newKeyedSchema` for the key, defaulting to `{}` when it doesn't exist + * - Retrieve the schema for any refs within each `oldKeySchema` and/or `newKeySchema` + * - Get the types of the old and new keyed schemas and if the old doesn't exist or the old & new are the same then: + * - If `removeOldSchemaData` has an entry for the key, delete it since the new schema has the same property + * - If type of the key in the new schema is `object`: + * - Store the value from the recursive `sanitizeDataForNewSchema` call in `nestedData[key]` + * - Otherwise, check for default or const values: + * - Get the old and new `default` values from the schema and check: + * - If the new `default` value does not match the form value: + * - If the old `default` value DOES match the form value, then: + * - Replace `removeOldSchemaData[key]` with the new `default` + * - Otherwise, if the new schema is `readOnly` then replace `removeOldSchemaData[key]` with undefined + * - Get the old and new `const` values from the schema and check: + * - If the new `const` value does not match the form value: + * - If the old `const` value DOES match the form value, then: + * - Replace `removeOldSchemaData[key]` with the new `const` + * - Otherwise, replace `removeOldSchemaData[key]` with undefined + * - Once all keys have been processed, return an object built as follows: + * - `{ ...removeOldSchemaData, ...nestedData, ...pick(data, keysToKeep) }` + * - If the new and old schema types are array and the `data` is an array then: + * - If the type of the old and new schema `items` are a non-array objects: + * - Retrieve the schema for any refs within each `oldKeySchema.items` and/or `newKeySchema.items` + * - If the `type`s of both items are the same (or the old does not have a type): + * - If the type is "object", then: + * - For each element in the `data` recursively sanitize the data, stopping at `maxItems` if specified + * - Otherwise, just return the `data` removing any values after `maxItems` if it is set + * - If the type of the old and new schema `items` are booleans of the same value, return `data` as is + * - Otherwise return `undefined` + * + * @param validator - An implementation of the `ValidatorType` interface that will be used when necessary + * @param rootSchema - The root JSON schema of the entire form + * @param [newSchema] - The new schema for which the data is being sanitized + * @param [oldSchema] - The old schema from which the data originated + * @param [data={}] - The form data associated with the schema, defaulting to an empty object when undefined + * @returns - 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. + */ +export default function sanitizeDataForNewSchema< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + validator: ValidatorType, + rootSchema: S, + newSchema?: S, + oldSchema?: S, + data: any = {} +): T { + // By default, we will clear the form data + let newFormData; + // If the new schema is of type object and that object contains a list of properties + if (has(newSchema, PROPERTIES_KEY)) { + // Create an object containing root-level keys in the old schema, setting each key to undefined to remove the data + const removeOldSchemaData: GenericObjectType = {}; + if (has(oldSchema, PROPERTIES_KEY)) { + const properties = get(oldSchema, PROPERTIES_KEY, {}); + Object.keys(properties).forEach((key) => { + if (has(data, key)) { + removeOldSchemaData[key] = undefined; + } + }); + } + const keys: string[] = Object.keys(get(newSchema, PROPERTIES_KEY, {})); + // Create a place to store nested data that will be a side-effect of the filter + const nestedData: GenericObjectType = {}; + keys.forEach((key) => { + const formValue = get(data, key); + let oldKeyedSchema: S = get(oldSchema, [PROPERTIES_KEY, key], {}); + let newKeyedSchema: S = get(newSchema, [PROPERTIES_KEY, key], {}); + // Resolve the refs if they exist + if (has(oldKeyedSchema, REF_KEY)) { + oldKeyedSchema = retrieveSchema( + validator, + oldKeyedSchema, + rootSchema, + formValue + ); + } + if (has(newKeyedSchema, REF_KEY)) { + newKeyedSchema = retrieveSchema( + validator, + newKeyedSchema, + rootSchema, + formValue + ); + } + // Now get types and see if they are the same + const oldSchemaTypeForKey = get(oldKeyedSchema, "type"); + const newSchemaTypeForKey = get(newKeyedSchema, "type"); + // Check if the old option has the same key with the same type + if (!oldSchemaTypeForKey || oldSchemaTypeForKey === newSchemaTypeForKey) { + if (has(removeOldSchemaData, key)) { + // SIDE-EFFECT: remove the undefined value for a key that has the same type between the old and new schemas + delete removeOldSchemaData[key]; + } + // If it is an object, we'll recurse and store the resulting sanitized data for the key + if ( + newSchemaTypeForKey === "object" || + (newSchemaTypeForKey === "array" && Array.isArray(formValue)) + ) { + // SIDE-EFFECT: process the new schema type of object recursively to save iterations + const itemData = sanitizeDataForNewSchema( + validator, + rootSchema, + newKeyedSchema, + oldKeyedSchema, + formValue + ); + if (itemData !== undefined || newSchemaTypeForKey === "array") { + // only put undefined values for the array type and not the object type + nestedData[key] = itemData; + } + } else { + // Ok, the non-object types match, let's make sure that a default or a const of a different value is replaced + // with the new default or const. This allows the case where two schemas differ that only by the default/const + // value to be properly selected + const newOptionDefault = get(newKeyedSchema, "default", NO_VALUE); + const oldOptionDefault = get(oldKeyedSchema, "default", NO_VALUE); + if (newOptionDefault !== NO_VALUE && newOptionDefault !== formValue) { + if (oldOptionDefault === formValue) { + // If the old default matches the formValue, we'll update the new value to match the new default + removeOldSchemaData[key] = newOptionDefault; + } else if (get(newKeyedSchema, "readOnly") === true) { + // If the new schema has the default set to read-only, treat it like a const and remove the value + removeOldSchemaData[key] = undefined; + } + } + + const newOptionConst = get(newKeyedSchema, "const", NO_VALUE); + const oldOptionConst = get(oldKeyedSchema, "const", NO_VALUE); + if (newOptionConst !== NO_VALUE && newOptionConst !== formValue) { + // Since this is a const, if the old value matches, replace the value with the new const otherwise clear it + removeOldSchemaData[key] = + oldOptionConst === formValue ? newOptionConst : undefined; + } + } + } + }); + + newFormData = { + ...data, + ...removeOldSchemaData, + ...nestedData, + }; + // First apply removing the old schema data, then apply the nested data, then apply the old data keys to keep + } else if ( + get(oldSchema, "type") === "array" && + get(newSchema, "type") === "array" && + Array.isArray(data) + ) { + let oldSchemaItems = get(oldSchema, "items"); + let newSchemaItems = get(newSchema, "items"); + // If any of the array types `items` are arrays (remember arrays are objects) then we'll just drop the data + // Eventually, we may want to deal with when either of the `items` are arrays since those tuple validations + if ( + typeof oldSchemaItems === "object" && + typeof newSchemaItems === "object" && + !Array.isArray(oldSchemaItems) && + !Array.isArray(newSchemaItems) + ) { + if (has(oldSchemaItems, REF_KEY)) { + oldSchemaItems = retrieveSchema( + validator, + oldSchemaItems as S, + rootSchema, + data as T + ); + } + if (has(newSchemaItems, REF_KEY)) { + newSchemaItems = retrieveSchema( + validator, + newSchemaItems as S, + rootSchema, + data as T + ); + } + // Now get types and see if they are the same + const oldSchemaType = get(oldSchemaItems, "type"); + const newSchemaType = get(newSchemaItems, "type"); + // Check if the old option has the same key with the same type + if (!oldSchemaType || oldSchemaType === newSchemaType) { + const maxItems = get(newSchema, "maxItems", -1); + if (newSchemaType === "object") { + newFormData = data.reduce((newValue, aValue) => { + const itemValue = sanitizeDataForNewSchema( + validator, + rootSchema, + newSchemaItems as S, + oldSchemaItems as S, + aValue + ); + if ( + itemValue !== undefined && + (maxItems < 0 || newValue.length < maxItems) + ) { + newValue.push(itemValue); + } + return newValue; + }, []); + } else { + newFormData = + maxItems > 0 && data.length > maxItems + ? data.slice(0, maxItems) + : data; + } + } + } else if ( + typeof oldSchemaItems === "boolean" && + typeof newSchemaItems === "boolean" && + oldSchemaItems === newSchemaItems + ) { + // If they are both booleans and have the same value just return the data as is otherwise fall-thru to undefined + newFormData = data; + } + // Also probably want to deal with `prefixItems` as tuples with the latest 2020 draft + } + return newFormData as T; +} diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index ad1d7637d2..260254e97b 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -1000,11 +1000,37 @@ export interface SchemaUtilsType< * @returns - True if the label should be displayed or false if it should not */ getDisplayLabel(schema: S, uiSchema?: UiSchema): boolean; + /** Determines which of the given `options` provided most closely matches the `formData`. + * Returns the index of the option that is valid and is the closest match, or 0 if there is no match. + * + * The closest match is determined using the number of matching properties, and more heavily favors options with + * matching readOnly, default, or const values. + * + * @param formData - The form data associated with the schema + * @param options - The list of options that can be selected from + * @param [selectedOption] - The index of the currently selected option, defaulted to -1 if not specified + * @returns - The index of the option that is the closest match to the `formData` or the `selectedOption` if no match + */ + getClosestMatchingOption( + formData: T | undefined, + options: S[], + selectedOption?: number + ): number; + /** 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. + * + * @param formData - The current formData, if any, used to figure out a match + * @param options - The list of options to find a matching options from + * @returns - The firstindex of the matched option or 0 if none is available + */ + getFirstMatchingOption(formData: T | undefined, options: S[]): number; /** Given the `formData` and list of `options`, attempts to find the index of the option that best matches the data. + * Deprecated, use `getFirstMatchingOption()` instead. * * @param formData - The current formData, if any, onto which to provide any missing defaults * @param options - The list of options to find a matching options from * @returns - The index of the matched option or 0 if none is available + * @deprecated */ getMatchingOption(formData: T, options: S[]): number; /** Checks to see if the `schema` and `uiSchema` combination represents an array of files @@ -1048,6 +1074,18 @@ export interface SchemaUtilsType< * @returns - The schema having its conditions, additional properties, references and dependencies resolved */ retrieveSchema(schema: S, formData?: T): S; + /** 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`. + * + * @param [newSchema] - The new schema for which the data is being sanitized + * @param [oldSchema] - The old schema from which the data originated + * @param [data={}] - The form data associated with the schema, defaulting to an empty object when undefined + * @returns - The new form data, with all of the fields uniquely associated with the old schema set + * to `undefined`. Will return `undefined` if the new schema is not an object containing properties. + */ + sanitizeDataForNewSchema(newSchema?: S, oldSchema?: S, data?: any): T; /** Generates an `IdSchema` object for the `schema`, recursively * * @param schema - The schema for which the display label flag is desired diff --git a/packages/utils/test/getSchemaType.test.ts b/packages/utils/test/getSchemaType.test.ts index ea0c4cb9f0..cb4a41090f 100644 --- a/packages/utils/test/getSchemaType.test.ts +++ b/packages/utils/test/getSchemaType.test.ts @@ -61,6 +61,22 @@ const cases: { schema: object; expected: string | undefined }[] = [ schema: { enum: ["foo"] }, expected: "string", }, + { + schema: { oneOf: [] }, + expected: undefined, + }, + { + schema: { oneOf: [{ type: "string" }] }, + expected: "string", + }, + { + schema: { anyOf: [] }, + expected: undefined, + }, + { + schema: { anyOf: [{ type: "number" }] }, + expected: "number", + }, { schema: {}, expected: undefined, diff --git a/packages/utils/test/schema.test.ts b/packages/utils/test/schema.test.ts index 6148baf4af..788fd01215 100644 --- a/packages/utils/test/schema.test.ts +++ b/packages/utils/test/schema.test.ts @@ -2,12 +2,14 @@ import getTestValidator from "./testUtils/getTestValidator"; import { getDefaultFormStateTest, getDisplayLabelTest, - getMatchingOptionTest, + getClosestMatchingOptionTest, + getFirstMatchingOptionTest, isFilesArrayTest, isMultiSelectTest, isSelectTest, mergeValidationDataTest, retrieveSchemaTest, + sanitizeDataForNewSchemaTest, toIdSchemaTest, toPathSchemaTest, } from "./schema"; @@ -16,11 +18,13 @@ const testValidator = getTestValidator({}); getDefaultFormStateTest(testValidator); getDisplayLabelTest(testValidator); -getMatchingOptionTest(testValidator); +getClosestMatchingOptionTest(testValidator); +getFirstMatchingOptionTest(testValidator); isFilesArrayTest(testValidator); isMultiSelectTest(testValidator); isSelectTest(testValidator); mergeValidationDataTest(testValidator); retrieveSchemaTest(testValidator); +sanitizeDataForNewSchemaTest(testValidator); toIdSchemaTest(testValidator); toPathSchemaTest(testValidator); diff --git a/packages/utils/test/schema/getClosestMatchingOptionTest.ts b/packages/utils/test/schema/getClosestMatchingOptionTest.ts new file mode 100644 index 0000000000..3b4853523c --- /dev/null +++ b/packages/utils/test/schema/getClosestMatchingOptionTest.ts @@ -0,0 +1,240 @@ +import get from "lodash/get"; + +import { TestValidatorType } from "./types"; +import { + createSchemaUtils, + getClosestMatchingOption, + RJSFSchema, + SchemaUtilsType, +} from "../../src"; +import { calculateIndexScore } from "../../src/schema/getClosestMatchingOption"; +import { + oneOfData, + oneOfSchema, + ONE_OF_SCHEMA_DATA, + OPTIONAL_ONE_OF_DATA, + OPTIONAL_ONE_OF_SCHEMA, + ONE_OF_SCHEMA_OPTIONS, + OPTIONAL_ONE_OF_SCHEMA_ONEOF, +} from "../testUtils/testData"; + +const firstOption = oneOfSchema.definitions!.first_option_def as RJSFSchema; +const secondOption = oneOfSchema.definitions!.second_option_def as RJSFSchema; + +export default function getClosestMatchingOptionTest( + testValidator: TestValidatorType +) { + let schemaUtils: SchemaUtilsType; + beforeAll(() => { + schemaUtils = createSchemaUtils(testValidator, oneOfSchema); + }); + describe("calculateIndexScore", () => { + it("returns 0 when schema is not specified", () => { + expect( + calculateIndexScore(testValidator, OPTIONAL_ONE_OF_SCHEMA) + ).toEqual(0); + }); + it("returns 0 when schema.properties is undefined", () => { + expect( + calculateIndexScore(testValidator, OPTIONAL_ONE_OF_SCHEMA, {}) + ).toEqual(0); + }); + it("returns 0 when schema.properties is not an object", () => { + expect( + calculateIndexScore(testValidator, OPTIONAL_ONE_OF_SCHEMA, { + properties: "foo", + } as unknown as RJSFSchema) + ).toEqual(0); + }); + it("returns 0 when properties type is boolean", () => { + expect( + calculateIndexScore(testValidator, OPTIONAL_ONE_OF_SCHEMA, { + properties: { foo: true }, + }) + ).toEqual(0); + }); + it("returns 0 when formData is empty object", () => { + expect( + calculateIndexScore(testValidator, oneOfSchema, firstOption, {}) + ).toEqual(0); + }); + it("returns 1 for first option in oneOf schema", () => { + 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); + }); + it("returns 1 for a schema that has a type matching the formData type", () => { + expect( + calculateIndexScore( + testValidator, + oneOfSchema, + { type: "boolean" }, + true + ) + ).toEqual(1); + }); + it("returns 2 for a schema that has a const matching the formData value", () => { + expect( + calculateIndexScore( + testValidator, + oneOfSchema, + { properties: { foo: { type: "string", const: "constValue" } } }, + { foo: "constValue" } + ) + ).toEqual(2); + }); + it("returns 0 for a schema that has a const that does not match the formData value", () => { + expect( + calculateIndexScore( + testValidator, + oneOfSchema, + { properties: { foo: { type: "string", const: "constValue" } } }, + { foo: "aValue" } + ) + ).toEqual(0); + }); + }); + describe("oneOfMatchingOption", () => { + it("oneOfSchema, oneOfData data, no options, returns -1", () => { + expect(schemaUtils.getClosestMatchingOption(oneOfData, [])).toEqual(-1); + }); + it("oneOfSchema, no data, 2 options, returns -1", () => { + expect( + schemaUtils.getClosestMatchingOption(undefined, [ + { type: "string" }, + { type: "number" }, + ]) + ).toEqual(-1); + }); + it("oneOfSchema, oneOfData, no options, selectedOption 2, returns 2", () => { + expect(schemaUtils.getClosestMatchingOption(oneOfData, [], 2)).toEqual(2); + }); + it("oneOfSchema, no data, 2 options, returns -1", () => { + expect( + schemaUtils.getClosestMatchingOption( + undefined, + [{ type: "string" }, { type: "number" }], + 2 + ) + ).toEqual(2); + }); + it("returns the first option, which kind of matches the data", () => { + expect( + getClosestMatchingOption( + testValidator, + oneOfSchema, + { flag: true }, + ONE_OF_SCHEMA_OPTIONS + ) + ).toEqual(0); + }); + it("returns the second option, which exactly matches the data", () => { + // First 3 are mocked false, with the fourth being true for the real second option + testValidator.setReturnValues({ isValid: [false, false, false, true] }); + expect( + getClosestMatchingOption( + testValidator, + oneOfSchema, + ONE_OF_SCHEMA_DATA, + ONE_OF_SCHEMA_OPTIONS + ) + ).toEqual(1); + }); + it("returns the first matching option (i.e. second index) when data is ambiguous", () => { + testValidator.setReturnValues({ + isValid: [false, false, false, true, false, true], + }); + const formData = { flag: false }; + expect( + getClosestMatchingOption( + testValidator, + OPTIONAL_ONE_OF_SCHEMA, + formData, + OPTIONAL_ONE_OF_SCHEMA_ONEOF + ) + ).toEqual(1); + }); + it("returns the third index when data is clear", () => { + testValidator.setReturnValues({ + isValid: [false, false, false, false, false, true], + }); + expect( + getClosestMatchingOption( + testValidator, + OPTIONAL_ONE_OF_SCHEMA, + OPTIONAL_ONE_OF_DATA, + OPTIONAL_ONE_OF_SCHEMA_ONEOF + ) + ).toEqual(2); + }); + it("returns the second option when data matches", () => { + // From https://github.com/rjsf-team/react-jsonschema-form/issues/2944 + const schema: RJSFSchema = { + type: "array", + items: { + oneOf: [ + { + properties: { + lorem: { + type: "string", + }, + }, + required: ["lorem"], + }, + { + properties: { + ipsum: { + oneOf: [ + { + properties: { + day: { + type: "string", + }, + }, + }, + { + properties: { + night: { + type: "string", + }, + }, + }, + ], + }, + }, + required: ["ipsum"], + }, + ], + }, + }; + const formData = { ipsum: { night: "nicht" } }; + // Mock to return true for the last of the second one-ofs + testValidator.setReturnValues({ + isValid: [false, false, false, false, false, false, false, true], + }); + expect( + getClosestMatchingOption( + testValidator, + schema, + formData, + get(schema, "items.oneOf") + ) + ).toEqual(1); + }); + }); +} diff --git a/packages/utils/test/schema/getDefaultFormStateTest.ts b/packages/utils/test/schema/getDefaultFormStateTest.ts index beb887b34e..4fb323f7bc 100644 --- a/packages/utils/test/schema/getDefaultFormStateTest.ts +++ b/packages/utils/test/schema/getDefaultFormStateTest.ts @@ -896,7 +896,7 @@ export default function getDefaultFormStateTest( }, }; // Mock errors so that getMatchingOption works as expected - testValidator.setReturnValues({ isValid: [false, true] }); + testValidator.setReturnValues({ isValid: [false, false, false, true] }); expect( getDefaultFormState(testValidator, schema, { test: { b: "b" } }) ).toEqual({ @@ -996,7 +996,7 @@ export default function getDefaultFormStateTest( }, }; // Mock errors so that getMatchingOption works as expected - testValidator.setReturnValues({ isValid: [false, true] }); + testValidator.setReturnValues({ isValid: [false, false, false, true] }); expect( getDefaultFormState(testValidator, schema, { test: { b: "b" } }) ).toEqual({ diff --git a/packages/utils/test/schema/getMatchingOptionTest.ts b/packages/utils/test/schema/getFirstMatchingOptionTest.ts similarity index 78% rename from packages/utils/test/schema/getMatchingOptionTest.ts rename to packages/utils/test/schema/getFirstMatchingOptionTest.ts index 91ede1810e..a03d5077e2 100644 --- a/packages/utils/test/schema/getMatchingOptionTest.ts +++ b/packages/utils/test/schema/getFirstMatchingOptionTest.ts @@ -1,10 +1,15 @@ -import { createSchemaUtils, getMatchingOption, RJSFSchema } from "../../src"; +import { + createSchemaUtils, + getFirstMatchingOption, + RJSFSchema, +} from "../../src"; import { TestValidatorType } from "./types"; -export default function getMatchingOptionTest( +// Since getFirstMatchingOption() simply calls getMatchingOption() there is no need to have tests for that +export default function getFirstMatchingOptionTest( testValidator: TestValidatorType ) { - describe("getMatchingOption()", () => { + describe("getFirstMatchingOption()", () => { let rootSchema: RJSFSchema; beforeAll(() => { rootSchema = { @@ -39,7 +44,7 @@ export default function getMatchingOptionTest( }, ]; expect( - getMatchingOption(testValidator, undefined, options, rootSchema) + getFirstMatchingOption(testValidator, undefined, options, rootSchema) ).toEqual(0); }); it("should infer correct anyOf schema with properties also having anyOf/allOf", () => { @@ -62,7 +67,7 @@ export default function getMatchingOptionTest( }, ]; expect( - getMatchingOption(testValidator, null, options, rootSchema) + getFirstMatchingOption(testValidator, null, options, rootSchema) ).toEqual(0); }); it("returns 0 if no options match", () => { @@ -74,7 +79,7 @@ export default function getMatchingOptionTest( { type: "null" }, ]; expect( - getMatchingOption(testValidator, null, options, rootSchema) + getFirstMatchingOption(testValidator, null, options, rootSchema) ).toEqual(2); }); it("should infer correct anyOf schema based on data if passing null and option 2 is {type: null}", () => { @@ -86,7 +91,7 @@ export default function getMatchingOptionTest( { type: "null" }, ]; expect( - getMatchingOption(testValidator, null, options, rootSchema) + getFirstMatchingOption(testValidator, null, options, rootSchema) ).toEqual(2); }); it("should infer correct anyOf schema based on data", () => { @@ -112,6 +117,10 @@ export default function getMatchingOptionTest( }, }; const schemaUtils = createSchemaUtils(testValidator, rootSchema); + expect(schemaUtils.getFirstMatchingOption(formData, options)).toEqual(1); + // Mock again isValid fail the first non-nested value + testValidator.setReturnValues({ isValid: [false, true] }); + // Test getMatchingOption call from `schemaUtils` to maintain coverage, delete when getMatchingOption is removed expect(schemaUtils.getMatchingOption(formData, options)).toEqual(1); }); }); diff --git a/packages/utils/test/schema/index.ts b/packages/utils/test/schema/index.ts index cb450a2c46..dce6c4c6ff 100644 --- a/packages/utils/test/schema/index.ts +++ b/packages/utils/test/schema/index.ts @@ -1,11 +1,13 @@ import getDefaultFormStateTest from "./getDefaultFormStateTest"; import getDisplayLabelTest from "./getDisplayLabelTest"; -import getMatchingOptionTest from "./getMatchingOptionTest"; +import getClosestMatchingOptionTest from "./getClosestMatchingOptionTest"; +import getFirstMatchingOptionTest from "./getFirstMatchingOptionTest"; import isFilesArrayTest from "./isFilesArrayTest"; import isMultiSelectTest from "./isMultiSelectTest"; import isSelectTest from "./isSelectTest"; import mergeValidationDataTest from "./mergeValidationDataTest"; import retrieveSchemaTest from "./retrieveSchemaTest"; +import sanitizeDataForNewSchemaTest from "./sanitizeDataForNewSchemaTest"; import toIdSchemaTest from "./toIdSchemaTest"; import toPathSchemaTest from "./toPathSchemaTest"; @@ -14,12 +16,14 @@ export * from "./types"; export { getDefaultFormStateTest, getDisplayLabelTest, - getMatchingOptionTest, + getClosestMatchingOptionTest, + getFirstMatchingOptionTest, isFilesArrayTest, isMultiSelectTest, isSelectTest, mergeValidationDataTest, retrieveSchemaTest, + sanitizeDataForNewSchemaTest, toIdSchemaTest, toPathSchemaTest, }; diff --git a/packages/utils/test/schema/retrieveSchemaTest.ts b/packages/utils/test/schema/retrieveSchemaTest.ts index adbccfa2fc..c2b9685f79 100644 --- a/packages/utils/test/schema/retrieveSchemaTest.ts +++ b/packages/utils/test/schema/retrieveSchemaTest.ts @@ -171,6 +171,64 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { }); }); + it("should `resolve` and stub out a schema which contains an `additionalProperties` with oneOf", () => { + const oneOf: RJSFSchema[] = [ + { + type: "string", + }, + { + type: "number", + }, + ]; + const schema: RJSFSchema = { + additionalProperties: { + oneOf, + }, + type: "object", + }; + + const formData = { newKey: {} }; + expect(retrieveSchema(testValidator, schema, {}, formData)).toEqual({ + ...schema, + properties: { + newKey: { + type: "object", + oneOf, + [ADDITIONAL_PROPERTY_FLAG]: true, + }, + }, + }); + }); + + it("should `resolve` and stub out a schema which contains an `additionalProperties` with anyOf", () => { + const anyOf: RJSFSchema[] = [ + { + type: "string", + }, + { + type: "number", + }, + ]; + const schema: RJSFSchema = { + additionalProperties: { + anyOf, + }, + type: "object", + }; + + const formData = { newKey: {} }; + expect(retrieveSchema(testValidator, schema, {}, formData)).toEqual({ + ...schema, + properties: { + newKey: { + type: "object", + anyOf, + [ADDITIONAL_PROPERTY_FLAG]: true, + }, + }, + }); + }); + it("should handle null formData for schema which contains additionalProperties", () => { const schema: RJSFSchema = { additionalProperties: { diff --git a/packages/utils/test/schema/sanitizeDataForNewSchemaTest.ts b/packages/utils/test/schema/sanitizeDataForNewSchemaTest.ts new file mode 100644 index 0000000000..03d36523c1 --- /dev/null +++ b/packages/utils/test/schema/sanitizeDataForNewSchemaTest.ts @@ -0,0 +1,480 @@ +import cloneDeep from "lodash/cloneDeep"; +import set from "lodash/set"; + +import { TestValidatorType } from "./types"; +import { + createSchemaUtils, + sanitizeDataForNewSchema, + SchemaUtilsType, + RJSFSchema, +} from "../../src"; +import { + FIRST_ONE_OF, + oneOfData, + oneOfSchema, + SECOND_ONE_OF, +} from "../testUtils/testData"; + +export default function sanitizeDataForNewSchemaTest( + testValidator: TestValidatorType +) { + describe("sanitizeDataForNewSchema", () => { + let schemaUtils: SchemaUtilsType; + beforeAll(() => { + schemaUtils = createSchemaUtils(testValidator, oneOfSchema); + }); + it('returns undefined when the new schema does not contain a "property" object', () => { + expect( + sanitizeDataForNewSchema(testValidator, oneOfSchema, {}, {}) + ).toBeUndefined(); + }); + it("returns input formData when the old schema is not an object", () => { + const newSchema = schemaUtils.retrieveSchema(SECOND_ONE_OF, oneOfSchema); + expect( + sanitizeDataForNewSchema( + testValidator, + oneOfSchema, + newSchema, + undefined, + oneOfData + ) + ).toEqual(oneOfData); + }); + it('returns input formData when the old schema does not contain a "property" object', () => { + const newSchema = schemaUtils.retrieveSchema(SECOND_ONE_OF, oneOfSchema); + expect( + sanitizeDataForNewSchema( + testValidator, + oneOfSchema, + newSchema, + {}, + oneOfData + ) + ).toEqual(oneOfData); + }); + it("returns input formData when the new schema matches the data for the new schema rather than the old", () => { + const newSchema = schemaUtils.retrieveSchema(SECOND_ONE_OF, oneOfSchema); + const oldSchema = cloneDeep( + schemaUtils.retrieveSchema(FIRST_ONE_OF, oneOfSchema) + ); + // Change the type of name to trigger a fall-thru + set(oldSchema, ["properties", "name", "type"], "boolean"); + // By changing the type, the name will be marked as undefined + const expected = { ...oneOfData, name: undefined }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, oneOfData) + ).toEqual(expected); + }); + it("returns input formData when the new schema and old schema match on a default", () => { + const oldSchema: RJSFSchema = { + type: "object", + properties: { + defaultField: { + type: "string", + default: "myData", + }, + anotherField: { + type: "boolean", + }, + }, + }; + const newSchema: RJSFSchema = { + type: "object", + properties: { + defaultField: { + type: "string", + default: "myData", + }, + anotherField: { + type: "string", + }, + }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, { + notInEitherSchema: "keep", + defaultField: "myData", + anotherField: true, + }) + ).toEqual({ notInEitherSchema: "keep", defaultField: "myData" }); + }); + it("returns new schema const in formData when the old schema default matches in the formData", () => { + const oldSchema: RJSFSchema = { + type: "object", + properties: { + defaultField: { + type: "string", + default: "myData", + }, + anotherField: { + type: "boolean", + }, + }, + }; + const newSchema: RJSFSchema = { + type: "object", + properties: { + defaultField: { + type: "string", + default: "yourData", + }, + anotherField: { + type: "string", + }, + }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, { + defaultField: "myData", + anotherField: true, + }) + ).toEqual({ defaultField: "yourData" }); + }); + it("returns input formData when the old schema default does not match in the formData", () => { + const oldSchema: RJSFSchema = { + type: "object", + properties: { + defaultField: { + type: "string", + default: "myData", + }, + anotherField: { + type: "boolean", + }, + }, + }; + const newSchema: RJSFSchema = { + type: "object", + properties: { + defaultField: { + type: "string", + default: "yourData", + }, + anotherField: { + type: "string", + }, + }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, { + defaultField: "fooData", + anotherField: true, + }) + ).toEqual({ defaultField: "fooData" }); + }); + it("returns empty formData when the old schema default does not match in the formData, and new schema default is readOnly", () => { + const oldSchema: RJSFSchema = { + type: "object", + properties: { + defaultField: { + type: "string", + default: "myData", + }, + anotherField: { + type: "boolean", + }, + }, + }; + const newSchema: RJSFSchema = { + type: "object", + properties: { + defaultField: { + type: "string", + default: "yourData", + readOnly: true, + }, + anotherField: { + type: "string", + }, + }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, { + defaultField: "fooData", + anotherField: true, + }) + ).toEqual({}); + }); + it("returns input formData when the new schema and old schema match on a const", () => { + const oldSchema: RJSFSchema = { + type: "object", + properties: { + constField: { + type: "string", + const: "myData", + }, + anotherField: { + type: "boolean", + }, + }, + }; + const newSchema: RJSFSchema = { + type: "object", + properties: { + constField: { + type: "string", + const: "myData", + }, + anotherField: { + type: "string", + }, + }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, { + notInEitherSchema: "keep", + constField: "myData", + anotherField: true, + }) + ).toEqual({ notInEitherSchema: "keep", constField: "myData" }); + }); + it("returns new schema const in formData when the old schema const matches in the formData", () => { + const oldSchema: RJSFSchema = { + type: "object", + properties: { + constField: { + type: "string", + const: "myData", + }, + anotherField: { + type: "boolean", + }, + }, + }; + const newSchema: RJSFSchema = { + type: "object", + properties: { + constField: { + type: "string", + const: "yourData", + }, + anotherField: { + type: "string", + }, + }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, { + constField: "myData", + anotherField: true, + }) + ).toEqual({ constField: "yourData" }); + }); + it("returns empty formData when the old schema const does not match in the formData", () => { + const oldSchema: RJSFSchema = { + type: "object", + properties: { + constField: { + type: "string", + const: "myData", + }, + anotherField: { + type: "boolean", + }, + }, + }; + const newSchema: RJSFSchema = { + type: "object", + properties: { + constField: { + type: "string", + const: "yourData", + }, + anotherField: { + type: "string", + }, + }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, { + constField: "fooData", + anotherField: true, + }) + ).toEqual({}); + }); + it("returns data when two arrays have same boolean items", () => { + const oldSchema: RJSFSchema = { + type: "array", + items: true, + }; + const newSchema: RJSFSchema = { + type: "array", + items: true, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, [1]) + ).toEqual([1]); + }); + it("returns undefined when two arrays have differing boolean items", () => { + const oldSchema: RJSFSchema = { + type: "array", + items: false, + }; + const newSchema: RJSFSchema = { + type: "array", + items: true, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, [1]) + ).toBeUndefined(); + }); + it("returns undefined when one array has boolean items", () => { + const oldSchema: RJSFSchema = { + type: "array", + items: false, + }; + const newSchema: RJSFSchema = { + type: "array", + items: { type: "string" }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, [1]) + ).toBeUndefined(); + }); + it("returns undefined when both arrays has array items", () => { + const oldSchema: RJSFSchema = { + type: "array", + items: [true], + }; + const newSchema: RJSFSchema = { + type: "array", + items: [{ type: "string" }], + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, [1]) + ).toBeUndefined(); + }); + it("returns undefined when one arrays has array items", () => { + const oldSchema: RJSFSchema = { + type: "array", + items: { type: "number" }, + }; + const newSchema: RJSFSchema = { + type: "array", + items: [{ type: "string" }], + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, [1]) + ).toBeUndefined(); + }); + it("returns undefined when the arrays has array items of different types", () => { + const oldSchema: RJSFSchema = { + type: "array", + items: { type: "number" }, + }; + const newSchema: RJSFSchema = { + type: "array", + items: { type: "string" }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, [1]) + ).toBeUndefined(); + }); + it("returns trimmed array when the new schema has maxItems < size for simple type", () => { + const oldSchema: RJSFSchema = { + type: "array", + items: { type: "string" }, + }; + const newSchema: RJSFSchema = { + type: "array", + maxItems: 1, + items: { type: "string" }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, ["1", "2"]) + ).toEqual(["1"]); + }); + it("returns whole array when the new schema does not have maxItems for simple type", () => { + const rootSchema: RJSFSchema = { + definitions: { + string_def: { + type: "string", + }, + }, + }; + const oldSchema: RJSFSchema = { + type: "array", + maxItems: 2, + items: { $ref: "#/definitions/string_def" }, + }; + const newSchema: RJSFSchema = { + type: "array", + items: { $ref: "#/definitions/string_def" }, + }; + expect( + sanitizeDataForNewSchema( + testValidator, + rootSchema, + newSchema, + oldSchema, + ["1", "2"] + ) + ).toEqual(["1", "2"]); + }); + it("returns trimmed array when the new schema has maxItems < size for object type", () => { + const oldSchema: RJSFSchema = { + type: "array", + items: { type: "object", properties: { foo: { type: "string" } } }, + }; + const newSchema: RJSFSchema = { + type: "array", + maxItems: 1, + items: { type: "object", properties: { foo: { type: "string" } } }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, [ + { foo: "1" }, + { foo: "2" }, + ]) + ).toEqual([{ foo: "1" }]); + }); + it("returns whole array when the new schema does not have maxItems for object type", () => { + const oldSchema: RJSFSchema = { + type: "array", + maxItems: 2, + items: { type: "object", properties: { foo: { type: "string" } } }, + }; + const newSchema: RJSFSchema = { + type: "array", + items: { type: "object", properties: { foo: { type: "string" } } }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, [ + { foo: "1" }, + { foo: "2" }, + ]) + ).toEqual([{ foo: "1" }, { foo: "2" }]); + }); + it("returns undefined object values when the new schema has different object type", () => { + const oldSchema: RJSFSchema = { + type: "array", + items: { type: "object", properties: { foo: { type: "string" } } }, + }; + const newSchema: RJSFSchema = { + type: "array", + items: { type: "object", properties: { foo: { type: "number" } } }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, [ + { foo: "1" }, + { foo: "2" }, + ]) + ).toEqual([{ foo: undefined }, { foo: undefined }]); + }); + it("returns undefined object values when the new schema has array with different object types", () => { + const oldSchema: RJSFSchema = { + type: "object", + properties: { foo: { type: "array", items: { type: "string" } } }, + }; + const newSchema: RJSFSchema = { + type: "object", + properties: { foo: { type: "array", items: { type: "number" } } }, + }; + expect( + schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, { + foo: ["1"], + }) + ).toEqual({ foo: undefined }); + }); + }); +} diff --git a/packages/utils/test/testUtils/testData.ts b/packages/utils/test/testUtils/testData.ts new file mode 100644 index 0000000000..1c1fb2eced --- /dev/null +++ b/packages/utils/test/testUtils/testData.ts @@ -0,0 +1,271 @@ +import { ONE_OF_KEY, RJSFSchema } from "../../src"; + +export const oneOfData = { + name: "second_option", + flag: true, + inner_spec: { + name: "inner_spec", + special_spec: { + name: "special_spec", + cpg_params: "blah", + }, + }, +}; +export const oneOfSchema: RJSFSchema = { + type: "object", + title: "Testing OneOfs", + definitions: { + special_spec_def: { + type: "object", + properties: { + name: { + type: "string", + default: "special_spec", + readOnly: true, + }, + cpg_params: { + type: "string", + }, + }, + required: ["name"], + }, + inner_first_choice_def: { + type: "object", + properties: { + name: { + type: "string", + default: "inner_first_choice", + readOnly: true, + }, + params: { + type: "string", + }, + }, + required: ["name", "params"], + additionalProperties: false, + }, + inner_second_choice_def: { + type: "object", + properties: { + name: { + type: "string", + default: "inner_second_choice", + readOnly: true, + }, + enumeration: { + type: "string", + enum: ["enum_1", "enum_2", "enum_3"], + }, + params: { + type: "string", + default: "", + }, + }, + required: ["name", "enumeration"], + additionalProperties: false, + }, + inner_spec_2_def: { + type: "object", + properties: { + name: { + type: "string", + default: "inner_spec_2", + readOnly: true, + }, + inner_one_of: { + oneOf: [ + { + $ref: "#/definitions/inner_first_choice_def", + title: "inner_first_choice", + }, + { + $ref: "#/definitions/inner_second_choice_def", + title: "inner_second_choice", + }, + ], + }, + }, + required: ["name", "inner_one_of"], + }, + first_option_def: { + type: "object", + properties: { + name: { + type: "string", + default: "first_option", + readOnly: true, + }, + flag: { + type: "boolean", + default: false, + }, + inner_spec: { + $ref: "#/definitions/inner_spec_2_def", + }, + unlabeled_options: { + oneOf: [ + { + type: "integer", + }, + { + type: "array", + items: { + type: "integer", + }, + }, + ], + }, + }, + required: ["name", "inner_spec"], + additionalProperties: false, + }, + inner_spec_def: { + type: "object", + properties: { + name: { + type: "string", + default: "inner_spec", + readOnly: true, + }, + inner_one_of: { + oneOf: [ + { + $ref: "#/definitions/inner_first_choice_def", + title: "inner_first_choice", + }, + { + $ref: "#/definitions/inner_second_choice_def", + title: "inner_second_choice", + }, + ], + }, + special_spec: { + $ref: "#/definitions/special_spec_def", + }, + }, + required: ["name"], + }, + second_option_def: { + type: "object", + properties: { + name: { + type: "string", + default: "second_option", + readOnly: true, + }, + flag: { + type: "boolean", + default: false, + }, + inner_spec: { + $ref: "#/definitions/inner_spec_def", + }, + unique_to_second: { + type: "integer", + }, + labeled_options: { + oneOf: [ + { + type: "string", + }, + { + type: "array", + items: { + type: "string", + }, + }, + ], + }, + }, + required: ["name", "inner_spec"], + additionalProperties: false, + }, + }, + oneOf: [ + { + $ref: "#/definitions/first_option_def", + title: "first option", + }, + { + $ref: "#/definitions/second_option_def", + title: "second option", + }, + ], +}; +export const ONE_OF_SCHEMA_OPTIONS = oneOfSchema[ONE_OF_KEY]! as RJSFSchema[]; +export const FIRST_ONE_OF: RJSFSchema = ONE_OF_SCHEMA_OPTIONS[0]; +export const SECOND_ONE_OF: RJSFSchema = ONE_OF_SCHEMA_OPTIONS[1]; +export const OPTIONAL_ONE_OF_SCHEMA: RJSFSchema = { + oneOf: [ + { + type: "object", + properties: { + name: { + type: "string", + default: "first_option", + readOnly: true, + }, + }, + additionalProperties: false, + }, + { + type: "object", + properties: { + name: { + type: "string", + default: "second_option", + readOnly: true, + }, + flag: { + type: "boolean", + default: false, + }, + }, + additionalProperties: false, + }, + { + type: "object", + properties: { + name: { + type: "string", + default: "third_option", + readOnly: true, + }, + flag: { + type: "boolean", + default: false, + }, + inner_obj: { + type: "object", + properties: { + foo: { + type: "string", + }, + }, + }, + }, + additionalProperties: false, + }, + ], +}; +export const OPTIONAL_ONE_OF_SCHEMA_ONEOF = OPTIONAL_ONE_OF_SCHEMA[ + ONE_OF_KEY +] as RJSFSchema[]; +export const OPTIONAL_ONE_OF_DATA = { flag: true, inner_obj: { foo: "bar" } }; +export const SIMPLE_ONE_OF_SCHEMA = { + oneOf: [ + {}, // object with no type should take the type from its parent schema + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], +} as RJSFSchema; +export const FIRST_OPTION_ONE_OF_DATA = { + flag: true, + inner_spec: { + name: "inner_spec_2", + special_spec: undefined, + }, + name: "first_option", + unique_to_second: undefined, +}; +export const ONE_OF_SCHEMA_DATA = { ...oneOfData, unique_to_second: 5 }; diff --git a/packages/validator-ajv6/test/utilsTests/schema.test.ts b/packages/validator-ajv6/test/utilsTests/schema.test.ts index 1b99a1dc68..270c67f254 100644 --- a/packages/validator-ajv6/test/utilsTests/schema.test.ts +++ b/packages/validator-ajv6/test/utilsTests/schema.test.ts @@ -2,12 +2,14 @@ import { getDefaultFormStateTest, getDisplayLabelTest, - getMatchingOptionTest, + getClosestMatchingOptionTest, + getFirstMatchingOptionTest, isFilesArrayTest, isMultiSelectTest, isSelectTest, mergeValidationDataTest, retrieveSchemaTest, + sanitizeDataForNewSchemaTest, toIdSchemaTest, toPathSchemaTest, } from "@rjsf/utils/test/schema"; @@ -17,11 +19,13 @@ const testValidator = getTestValidator({}); getDefaultFormStateTest(testValidator); getDisplayLabelTest(testValidator); -getMatchingOptionTest(testValidator); +getClosestMatchingOptionTest(testValidator); +getFirstMatchingOptionTest(testValidator); isFilesArrayTest(testValidator); isMultiSelectTest(testValidator); isSelectTest(testValidator); mergeValidationDataTest(testValidator); retrieveSchemaTest(testValidator); +sanitizeDataForNewSchemaTest(testValidator); toIdSchemaTest(testValidator); toPathSchemaTest(testValidator); diff --git a/packages/validator-ajv8/test/utilsTests/schema.test.ts b/packages/validator-ajv8/test/utilsTests/schema.test.ts index da36b92508..2ed7bec6a2 100644 --- a/packages/validator-ajv8/test/utilsTests/schema.test.ts +++ b/packages/validator-ajv8/test/utilsTests/schema.test.ts @@ -4,12 +4,14 @@ import Ajv2020 from "ajv/dist/2020"; import { getDefaultFormStateTest, getDisplayLabelTest, - getMatchingOptionTest, + getClosestMatchingOptionTest, + getFirstMatchingOptionTest, isFilesArrayTest, isMultiSelectTest, isSelectTest, mergeValidationDataTest, retrieveSchemaTest, + sanitizeDataForNewSchemaTest, toIdSchemaTest, toPathSchemaTest, } from "@rjsf/utils/test/schema"; @@ -19,12 +21,14 @@ const testValidator = getTestValidator({}); getDefaultFormStateTest(testValidator); getDisplayLabelTest(testValidator); -getMatchingOptionTest(testValidator); +getClosestMatchingOptionTest(testValidator); +getFirstMatchingOptionTest(testValidator); isFilesArrayTest(testValidator); isMultiSelectTest(testValidator); isSelectTest(testValidator); mergeValidationDataTest(testValidator); retrieveSchemaTest(testValidator); +sanitizeDataForNewSchemaTest(testValidator); toIdSchemaTest(testValidator); toPathSchemaTest(testValidator); @@ -32,12 +36,14 @@ const testValidator2019 = getTestValidator({ AjvClass: Ajv2019 }); getDefaultFormStateTest(testValidator2019); getDisplayLabelTest(testValidator2019); -getMatchingOptionTest(testValidator2019); +getClosestMatchingOptionTest(testValidator2019); +getFirstMatchingOptionTest(testValidator2019); isFilesArrayTest(testValidator2019); isMultiSelectTest(testValidator2019); isSelectTest(testValidator2019); mergeValidationDataTest(testValidator2019); retrieveSchemaTest(testValidator2019); +sanitizeDataForNewSchemaTest(testValidator2019); toIdSchemaTest(testValidator2019); toPathSchemaTest(testValidator2019); @@ -45,11 +51,13 @@ const testValidator2020 = getTestValidator({ AjvClass: Ajv2020 }); getDefaultFormStateTest(testValidator2020); getDisplayLabelTest(testValidator2020); -getMatchingOptionTest(testValidator2020); +getClosestMatchingOptionTest(testValidator2020); +getFirstMatchingOptionTest(testValidator2020); isFilesArrayTest(testValidator2020); isMultiSelectTest(testValidator2020); isSelectTest(testValidator2020); mergeValidationDataTest(testValidator2020); retrieveSchemaTest(testValidator2020); +retrieveSchemaTest(testValidator2020); toIdSchemaTest(testValidator2020); toPathSchemaTest(testValidator2020);