diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4e4241fd..04b74b81aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ should change the heading of the (upcoming) version to include a major version b ## @rjsf/core - Updated `getFieldComponent()` to support rendering a custom component by given schema id ($id). [#3740](https://github.com/rjsf-team/react-jsonschema-form/pull/3740) +- Updated `MultiSchemaField` to merge the selected `oneOf/anyOf` value into base `schema`, fixing [#3744](https://github.com/rjsf-team/react-jsonschema-form/issues/3744) + +## @rjsf/utils + +- Updated `getClosestMatchingOption()` to resolve refs in options before computing the closest matching option, fixing an issue with using precompiled validators + - Also, added support for nested `anyOf` and `discriminator` support in the recursive `calculateIndexScore()` +- Updated `getDefaultFormState()` to merge the remaining schema into `anyOf/oneOf` schema selected during the computation of values, fixing [#3744](https://github.com/rjsf-team/react-jsonschema-form/issues/3744) +- Updated `retrieveSchema()` to merge the remaining schema into the `anyOf/oneOf` schema selected during the computation of dependencies, fixing [#3744](https://github.com/rjsf-team/react-jsonschema-form/issues/3744) ## Dev / docs / playground diff --git a/packages/core/src/components/fields/MultiSchemaField.tsx b/packages/core/src/components/fields/MultiSchemaField.tsx index 2d135b5651..723022beb7 100644 --- a/packages/core/src/components/fields/MultiSchemaField.tsx +++ b/packages/core/src/components/fields/MultiSchemaField.tsx @@ -2,7 +2,9 @@ import { Component } from 'react'; import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; import omit from 'lodash/omit'; +import unset from 'lodash/unset'; import { + ADDITIONAL_PROPERTY_FLAG, deepEquals, ERRORS_KEY, FieldProps, @@ -19,7 +21,7 @@ import { type AnyOfFieldState = { /** The currently selected option */ selectedOption: number; - /* The option schemas after retrieving all $refs */ + /** The option schemas after retrieving all $refs */ retrievedOptions: S[]; }; @@ -139,7 +141,6 @@ class AnyOfField schemaUtils.retrieveSchema(isObject(_schema) ? (_schema as S) : ({} as S), formData) )} - baseType={schema.type} registry={registry} schema={schema} uiSchema={uiSchema} @@ -329,7 +328,6 @@ function SchemaFieldRender schemaUtils.retrieveSchema(isObject(_schema) ? (_schema as S) : ({} as S), formData) )} - baseType={schema.type} registry={registry} schema={schema} uiSchema={uiSchema} diff --git a/packages/core/test/anyOf.test.jsx b/packages/core/test/anyOf.test.jsx index dba79caf7a..ff18b74e07 100644 --- a/packages/core/test/anyOf.test.jsx +++ b/packages/core/test/anyOf.test.jsx @@ -34,6 +34,7 @@ describe('anyOf', () => { it('should render a select element if the anyOf keyword is present', () => { const schema = { type: 'object', + title: 'Merges into anyOf', anyOf: [ { properties: { @@ -52,6 +53,7 @@ describe('anyOf', () => { schema, }); + expect(node.querySelector('legend#root__title').innerHTML).eql(schema.title); expect(node.querySelectorAll('select')).to.have.length.of(1); expect(node.querySelector('select').id).eql('root__anyof_select'); }); diff --git a/packages/core/test/oneOf.test.jsx b/packages/core/test/oneOf.test.jsx index 3fa1081c1c..e009393e23 100644 --- a/packages/core/test/oneOf.test.jsx +++ b/packages/core/test/oneOf.test.jsx @@ -35,6 +35,7 @@ describe('oneOf', () => { it('should render a select element if the oneOf keyword is present', () => { const schema = { type: 'object', + title: 'Merges into oneOf', oneOf: [ { properties: { @@ -53,6 +54,7 @@ describe('oneOf', () => { schema, }); + expect(node.querySelector('legend#root__title').innerHTML).eql(schema.title); expect(node.querySelectorAll('select')).to.have.length.of(1); expect(node.querySelector('select').id).eql('root__oneof_select'); }); diff --git a/packages/utils/src/schema/getClosestMatchingOption.ts b/packages/utils/src/schema/getClosestMatchingOption.ts index 92f1204d31..8ddda4474b 100644 --- a/packages/utils/src/schema/getClosestMatchingOption.ts +++ b/packages/utils/src/schema/getClosestMatchingOption.ts @@ -7,9 +7,10 @@ import times from 'lodash/times'; import getFirstMatchingOption from './getFirstMatchingOption'; import retrieveSchema from './retrieveSchema'; -import { ONE_OF_KEY, REF_KEY, JUNK_OPTION_ID } from '../constants'; +import { ONE_OF_KEY, REF_KEY, JUNK_OPTION_ID, ANY_OF_KEY } from '../constants'; import guessType from '../guessType'; import { FormContextType, RJSFSchema, StrictRJSFSchema, ValidatorType } from '../types'; +import getDiscriminatorFieldFromSchema from '../getDiscriminatorFieldFromSchema'; /** A junk option used to determine when the getFirstMatchingOption call really matches an option rather than returning * the first item @@ -64,9 +65,19 @@ export function calculateIndexScore(validator, value as S, rootSchema, formValue); return score + calculateIndexScore(validator, rootSchema, newSchema, formValue || {}); } - if (has(value, ONE_OF_KEY) && formValue) { + if ((has(value, ONE_OF_KEY) || has(value, ANY_OF_KEY)) && formValue) { + const key = has(value, ONE_OF_KEY) ? ONE_OF_KEY : ANY_OF_KEY; + const discriminator = getDiscriminatorFieldFromSchema(value as S); return ( - score + getClosestMatchingOption(validator, rootSchema, formValue, get(value, ONE_OF_KEY) as S[]) + score + + getClosestMatchingOption( + validator, + rootSchema, + formValue, + get(value, key) as S[], + -1, + discriminator + ) ); } if (value.type === 'object') { @@ -132,8 +143,15 @@ export default function getClosestMatchingOption< selectedOption = -1, discriminatorField?: string ): number { + // First resolve any refs in the options + const resolvedOptions = options.map((option) => { + if (has(option, REF_KEY)) { + return retrieveSchema(validator, option, rootSchema, formData); + } + return option; + }); // 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 allValidIndexes = resolvedOptions.reduce((validList: number[], option, index: number) => { const testOptions: S[] = [JUNK_OPTION as S, option]; const match = getFirstMatchingOption(validator, formData, testOptions, rootSchema, discriminatorField); // The match is the real option, so add its index to list of valid indexes @@ -149,7 +167,7 @@ export default function getClosestMatchingOption< } 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)); + times(resolvedOptions.length, (i) => allValidIndexes.push(i)); } type BestType = { bestIndex: number; bestScore: number }; const scoreCount = new Set(); @@ -157,10 +175,7 @@ export default function getClosestMatchingOption< 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 option = resolvedOptions[index]; const score = calculateIndexScore(validator, rootSchema, option, formData); scoreCount.add(score); if (score > bestScore) { diff --git a/packages/utils/src/schema/getDefaultFormState.ts b/packages/utils/src/schema/getDefaultFormState.ts index a2a393b4d5..f00b0311c8 100644 --- a/packages/utils/src/schema/getDefaultFormState.ts +++ b/packages/utils/src/schema/getDefaultFormState.ts @@ -199,35 +199,39 @@ export function computeDefaults(schema); - schemaToCompute = schema.oneOf![ + schemaToCompute = oneOf![ getClosestMatchingOption( validator, rootSchema, isEmpty(formData) ? undefined : formData, - schema.oneOf as S[], + oneOf as S[], 0, discriminator ) ] as S; + schemaToCompute = { ...remaining, ...schemaToCompute }; } else if (ANY_OF_KEY in schema) { - if (schema.anyOf!.length === 0) { + const { anyOf, ...remaining } = schema; + if (anyOf!.length === 0) { return undefined; } const discriminator = getDiscriminatorFieldFromSchema(schema); - schemaToCompute = schema.anyOf![ + schemaToCompute = anyOf![ getClosestMatchingOption( validator, rootSchema, isEmpty(formData) ? undefined : formData, - schema.anyOf as S[], + anyOf as S[], 0, discriminator ) ] as S; + schemaToCompute = { ...remaining, ...schemaToCompute }; } if (schemaToCompute) { diff --git a/packages/utils/src/schema/retrieveSchema.ts b/packages/utils/src/schema/retrieveSchema.ts index 250076c395..db84e6c45f 100644 --- a/packages/utils/src/schema/retrieveSchema.ts +++ b/packages/utils/src/schema/retrieveSchema.ts @@ -279,9 +279,9 @@ export function retrieveSchemaInternal< if (IF_KEY in resolvedSchema) { return resolveCondition(validator, resolvedSchema, rootSchema, expandAllBranches, rawFormData as T); } - if (ALL_OF_KEY in schema) { + if (ALL_OF_KEY in resolvedSchema) { try { - resolvedSchema = mergeAllOf(s, { + resolvedSchema = mergeAllOf(resolvedSchema, { deep: false, } as Options) as S; } catch (e) { @@ -321,10 +321,11 @@ export function resolveAnyOrOneOfSchemas< F extends FormContextType = any >(validator: ValidatorType, schema: S, rootSchema: S, expandAllBranches: boolean, rawFormData?: T) { let anyOrOneOf: S[] | undefined; - if (Array.isArray(schema.oneOf)) { - anyOrOneOf = schema.oneOf as S[]; - } else if (Array.isArray(schema.anyOf)) { - anyOrOneOf = schema.anyOf as S[]; + const { oneOf, anyOf, ...remaining } = schema; + if (Array.isArray(oneOf)) { + anyOrOneOf = oneOf as S[]; + } else if (Array.isArray(anyOf)) { + anyOrOneOf = anyOf as S[]; } if (anyOrOneOf) { // Ensure that during expand all branches we pass an object rather than undefined so that all options are interrogated @@ -340,9 +341,9 @@ export function resolveAnyOrOneOfSchemas< // Call this to trigger the set of isValid() calls that the schema parser will need const option = getFirstMatchingOption(validator, formData, anyOrOneOf, rootSchema, discriminator); if (expandAllBranches) { - return anyOrOneOf; + return anyOrOneOf.map((item) => ({ ...remaining, ...item })); } - schema = anyOrOneOf[option] as S; + schema = { ...remaining, ...anyOrOneOf[option] } as S; } return [schema]; } diff --git a/packages/utils/test/parser/__snapshots__/schemaParser.test.ts.snap b/packages/utils/test/parser/__snapshots__/schemaParser.test.ts.snap index 696c877f82..cf8ebbc4e5 100644 --- a/packages/utils/test/parser/__snapshots__/schemaParser.test.ts.snap +++ b/packages/utils/test/parser/__snapshots__/schemaParser.test.ts.snap @@ -1009,6 +1009,7 @@ Object { "$ref": "#/definitions/foo", }, ], + "title": "multi", }, "passwords": Object { "$ref": "#/definitions/passwords", @@ -1025,6 +1026,9 @@ Object { "$ref": "#/definitions/choice2", }, ], + "required": Array [ + "choice", + ], }, }, "type": "object", diff --git a/packages/utils/test/schema/getClosestMatchingOptionTest.ts b/packages/utils/test/schema/getClosestMatchingOptionTest.ts index fcc63be3a8..e9141dc185 100644 --- a/packages/utils/test/schema/getClosestMatchingOptionTest.ts +++ b/packages/utils/test/schema/getClosestMatchingOptionTest.ts @@ -6,6 +6,7 @@ import { calculateIndexScore } from '../../src/schema/getClosestMatchingOption'; import { oneOfData, oneOfSchema, + // anyOfSchema, ONE_OF_SCHEMA_DATA, OPTIONAL_ONE_OF_DATA, OPTIONAL_ONE_OF_SCHEMA, @@ -120,7 +121,7 @@ export default function getClosestMatchingOptionTest(testValidator: TestValidato ) ).toEqual(2); }); - it('returns the second option when data matches', () => { + it('returns the second option when data matches for oneOf', () => { // From https://github.com/rjsf-team/react-jsonschema-form/issues/2944 const schema: RJSFSchema = { type: 'array', @@ -167,6 +168,52 @@ export default function getClosestMatchingOptionTest(testValidator: TestValidato }); expect(getClosestMatchingOption(testValidator, schema, formData, get(schema, 'items.oneOf'))).toEqual(1); }); + it('returns the second option when data matches for anyOf', () => { + const schema: RJSFSchema = { + type: 'array', + items: { + anyOf: [ + { + properties: { + lorem: { + type: 'string', + }, + }, + required: ['lorem'], + }, + { + properties: { + ipsum: { + anyOf: [ + { + 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.anyOf'))).toEqual(1); + }); it('should return 0 when schema has discriminator but no matching data', () => { // Mock isValid to fail both values testValidator.setReturnValues({ isValid: [false, false, false, false] }); diff --git a/packages/utils/test/schema/getDefaultFormStateTest.ts b/packages/utils/test/schema/getDefaultFormStateTest.ts index a44c373dfa..d2854b14bc 100644 --- a/packages/utils/test/schema/getDefaultFormStateTest.ts +++ b/packages/utils/test/schema/getDefaultFormStateTest.ts @@ -1469,6 +1469,66 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType }, }); }); + it('should not populate nested default values for oneOf, when not required', () => { + const schema: RJSFSchema = { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + first: { type: 'string', default: 'First Name' }, + }, + }, + { type: 'string', default: 'b' }, + ], + }, + }, + }; + expect( + getDefaultFormState(testValidator, schema, {}, undefined, undefined, { + emptyObjectFields: 'populateRequiredDefaults', + }) + ).toEqual({ name: {} }); + }); + it('should populate nested default values for oneOf, when required is merged in', () => { + const schema: RJSFSchema = { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'object', + required: ['first'], + oneOf: [ + { + type: 'object', + properties: { + first: { type: 'string', default: 'First Name' }, + }, + }, + { + type: 'object', + properties: { + first: { type: 'string', default: '1st Name' }, + }, + }, + ], + }, + }, + }; + expect( + getDefaultFormState(testValidator, schema, {}, undefined, undefined, { + emptyObjectFields: 'populateRequiredDefaults', + }) + ).toEqual({ + name: { + first: 'First Name', + }, + }); + }); it('should populate defaults for oneOf + dependencies', () => { const schema: RJSFSchema = { oneOf: [ @@ -1576,6 +1636,66 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType }, }); }); + it('should not populate nested default values for oneOf, when not required', () => { + const schema: RJSFSchema = { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'object', + anyOf: [ + { + type: 'object', + properties: { + first: { type: 'string', default: 'First Name' }, + }, + }, + { type: 'string', default: 'b' }, + ], + }, + }, + }; + expect( + getDefaultFormState(testValidator, schema, {}, undefined, undefined, { + emptyObjectFields: 'populateRequiredDefaults', + }) + ).toEqual({ name: {} }); + }); + it('should populate nested default values for oneOf, when required is merged in', () => { + const schema: RJSFSchema = { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'object', + required: ['first'], + anyOf: [ + { + type: 'object', + properties: { + first: { type: 'string', default: 'First Name' }, + }, + }, + { + type: 'object', + properties: { + first: { type: 'string', default: '1st Name' }, + }, + }, + ], + }, + }, + }; + expect( + getDefaultFormState(testValidator, schema, {}, undefined, undefined, { + emptyObjectFields: 'populateRequiredDefaults', + }) + ).toEqual({ + name: { + first: 'First Name', + }, + }); + }); it('should populate defaults for anyOf + dependencies', () => { const schema: RJSFSchema = { anyOf: [ diff --git a/packages/utils/test/schema/retrieveSchemaTest.ts b/packages/utils/test/schema/retrieveSchemaTest.ts index 0b72890071..72b59a584e 100644 --- a/packages/utils/test/schema/retrieveSchemaTest.ts +++ b/packages/utils/test/schema/retrieveSchemaTest.ts @@ -1276,17 +1276,26 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { }); }); describe('resolveAnyOrOneOfSchemas()', () => { - it('resolves anyOf with $ref for single element', () => { + it('resolves anyOf with $ref for single element, merging schemas', () => { const anyOfSchema: RJSFSchema = SUPER_SCHEMA.properties?.multi as RJSFSchema; expect(resolveAnyOrOneOfSchemas(testValidator, anyOfSchema, SUPER_SCHEMA, false)).toEqual([ - SUPER_SCHEMA.definitions?.foo, + { + ...(SUPER_SCHEMA.definitions?.foo as RJSFSchema), + title: 'multi', + }, ]); }); - it('resolves oneOf with $ref for expandedAll elements', () => { + it('resolves oneOf with $ref for expandedAll elements, merging schemas', () => { const oneOfSchema: RJSFSchema = SUPER_SCHEMA.properties?.single as RJSFSchema; expect(resolveAnyOrOneOfSchemas(testValidator, oneOfSchema, SUPER_SCHEMA, true)).toEqual([ - SUPER_SCHEMA.definitions?.choice1, - SUPER_SCHEMA.definitions?.choice2, + { + ...(SUPER_SCHEMA.definitions?.choice1 as RJSFSchema), + required: ['choice'], + }, + { + ...(SUPER_SCHEMA.definitions?.choice2 as RJSFSchema), + required: ['choice'], + }, ]); }); }); diff --git a/packages/utils/test/testUtils/testData.ts b/packages/utils/test/testUtils/testData.ts index 4f54580c8a..67ecbbd51b 100644 --- a/packages/utils/test/testUtils/testData.ts +++ b/packages/utils/test/testUtils/testData.ts @@ -21,6 +21,188 @@ export const oneOfData = { }, }, }; + +export const anyOfSchema: RJSFSchema = { + type: 'object', + title: 'Testing anyOfs', + 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_any_of: { + anyOf: [ + { + $ref: '#/definitions/inner_first_choice_def', + title: 'inner_first_choice', + }, + { + $ref: '#/definitions/inner_second_choice_def', + title: 'inner_second_choice', + }, + ], + }, + }, + required: ['name', 'inner_any_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: { + anyOf: [ + { + 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_any_of: { + anyOf: [ + { + $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: { + anyOf: [ + { + type: 'string', + }, + { + type: 'array', + items: { + type: 'string', + }, + }, + ], + }, + }, + required: ['name', 'inner_spec'], + additionalProperties: false, + }, + }, + anyOf: [ + { + $ref: '#/definitions/first_option_def', + title: 'first option', + }, + { + $ref: '#/definitions/second_option_def', + title: 'second option', + }, + ], +}; export const oneOfSchema: RJSFSchema = { type: 'object', title: 'Testing OneOfs', @@ -449,10 +631,12 @@ export const SUPER_SCHEMA: RJSFSchema = { passwords: { $ref: '#/definitions/passwords' }, dataUrlWithName: { type: 'string', format: 'data-url' }, multi: { + title: 'multi', anyOf: [{ $ref: '#/definitions/foo' }], }, list: { $ref: '#/definitions/list' }, single: { + required: ['choice'], oneOf: [{ $ref: '#/definitions/choice1' }, { $ref: '#/definitions/choice2' }], }, anything: {