Skip to content

Commit

Permalink
fix: Fixed rjsf-team#3744 and another precompiled schema issue
Browse files Browse the repository at this point in the history
Fixes rjsf-team#3744 as well as an issue associated with anyOf/oneOf in precompiled schema
- In `@rjsf/util`, fixed part of rjsf-team#3744 and the precompiled schema issue as follows:
  - Updated `getClosestMatchingOption()` to resolve refs in the options before computing the correct index, as well as supporting `anyOf` and discriminators in `calculateIndexScore()`
  - Updated `getDefaultFormState()` to merge in the remaining schema into the selected anyOf/oneOf option prior to computing new defaults
  - Updated `retrieveSchema()` to merge in the remaining schema into the anyOf/oneOf options when resolving dependencies
  - Added/updated tests to verify all the fixes
- In `@rjsf/core`, fixed the rest of rjsf-team#3744 by updating `MultiSchemaField` to merge the remaining schema into the selected anyOf/oneOf selected option
  - Also updated `SchemaField` to no longer pass in `baseType` to `MultiSchemaField` since it is no longer used
  • Loading branch information
heath-freenome committed Jul 12, 2023
1 parent 6d330ec commit b538dbd
Show file tree
Hide file tree
Showing 13 changed files with 432 additions and 36 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 7 additions & 5 deletions packages/core/src/components/fields/MultiSchemaField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,7 +21,7 @@ import {
type AnyOfFieldState<S extends StrictRJSFSchema = RJSFSchema> = {
/** The currently selected option */
selectedOption: number;
/* The option schemas after retrieving all $refs */
/** The option schemas after retrieving all $refs */
retrievedOptions: S[];
};

Expand Down Expand Up @@ -139,7 +141,6 @@ class AnyOfField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
render() {
const {
name,
baseType,
disabled = false,
errorSchema = {},
formContext,
Expand Down Expand Up @@ -170,9 +171,10 @@ class AnyOfField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
let optionSchema: S;

if (option) {
// If the subschema doesn't declare a type, infer the type from the
// parent schema
optionSchema = option.type ? option : Object.assign({}, option, { type: baseType });
const { oneOf, anyOf, ...remaining } = schema;
// Merge in all the non-oneOf/anyOf properties and also skip the special ADDITIONAL_PROPERTY_FLAG property
unset(remaining, ADDITIONAL_PROPERTY_FLAG);
optionSchema = !isEmpty(remaining) ? { ...remaining, ...option } : option;
}

const translateEnum: TranslatableString = title
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/components/fields/SchemaField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,6 @@ function SchemaFieldRender<T = any, S extends StrictRJSFSchema = RJSFSchema, F e
options={schema.anyOf.map((_schema) =>
schemaUtils.retrieveSchema(isObject(_schema) ? (_schema as S) : ({} as S), formData)
)}
baseType={schema.type}
registry={registry}
schema={schema}
uiSchema={uiSchema}
Expand All @@ -329,7 +328,6 @@ function SchemaFieldRender<T = any, S extends StrictRJSFSchema = RJSFSchema, F e
options={schema.oneOf.map((_schema) =>
schemaUtils.retrieveSchema(isObject(_schema) ? (_schema as S) : ({} as S), formData)
)}
baseType={schema.type}
registry={registry}
schema={schema}
uiSchema={uiSchema}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/anyOf.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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');
});
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/oneOf.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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');
});
Expand Down
33 changes: 24 additions & 9 deletions packages/utils/src/schema/getClosestMatchingOption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,9 +65,19 @@ export function calculateIndexScore<T = any, S extends StrictRJSFSchema = RJSFSc
const newSchema = retrieveSchema<T, S, F>(validator, value as S, rootSchema, formValue);
return score + calculateIndexScore<T, S, F>(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<S>(value as S);
return (
score + getClosestMatchingOption<T, S, F>(validator, rootSchema, formValue, get(value, ONE_OF_KEY) as S[])
score +
getClosestMatchingOption<T, S, F>(
validator,
rootSchema,
formValue,
get(value, key) as S[],
-1,
discriminator
)
);
}
if (value.type === 'object') {
Expand Down Expand Up @@ -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<T, S, F>(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<T, S, F>(validator, formData, testOptions, rootSchema, discriminatorField);
// The match is the real option, so add its index to list of valid indexes
Expand All @@ -149,18 +167,15 @@ 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<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<T, S, F>(validator, option, rootSchema, formData);
}
const option = resolvedOptions[index];
const score = calculateIndexScore(validator, rootSchema, option, formData);
scoreCount.add(score);
if (score > bestScore) {
Expand Down
16 changes: 10 additions & 6 deletions packages/utils/src/schema/getDefaultFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,35 +199,39 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
})
) as T[];
} else if (ONE_OF_KEY in schema) {
if (schema.oneOf!.length === 0) {
const { oneOf, ...remaining } = schema;
if (oneOf!.length === 0) {
return undefined;
}
const discriminator = getDiscriminatorFieldFromSchema<S>(schema);
schemaToCompute = schema.oneOf![
schemaToCompute = oneOf![
getClosestMatchingOption<T, S, F>(
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<S>(schema);
schemaToCompute = schema.anyOf![
schemaToCompute = anyOf![
getClosestMatchingOption<T, S, F>(
validator,
rootSchema,
isEmpty(formData) ? undefined : formData,
schema.anyOf as S[],
anyOf as S[],
0,
discriminator
)
] as S;
schemaToCompute = { ...remaining, ...schemaToCompute };
}

if (schemaToCompute) {
Expand Down
17 changes: 9 additions & 8 deletions packages/utils/src/schema/retrieveSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,9 @@ export function retrieveSchemaInternal<
if (IF_KEY in resolvedSchema) {
return resolveCondition<T, S, F>(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) {
Expand Down Expand Up @@ -321,10 +321,11 @@ export function resolveAnyOrOneOfSchemas<
F extends FormContextType = any
>(validator: ValidatorType<T, S, F>, 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
Expand All @@ -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<T, S, F>(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];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@ Object {
"$ref": "#/definitions/foo",
},
],
"title": "multi",
},
"passwords": Object {
"$ref": "#/definitions/passwords",
Expand All @@ -1025,6 +1026,9 @@ Object {
"$ref": "#/definitions/choice2",
},
],
"required": Array [
"choice",
],
},
},
"type": "object",
Expand Down
49 changes: 48 additions & 1 deletion packages/utils/test/schema/getClosestMatchingOptionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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] });
Expand Down
Loading

0 comments on commit b538dbd

Please sign in to comment.