diff --git a/docs/3.x upgrade guide.md b/docs/3.x upgrade guide.md index ad362ad384..479e7887c4 100644 --- a/docs/3.x upgrade guide.md +++ b/docs/3.x upgrade guide.md @@ -15,3 +15,11 @@ For a slightly more elaborate setup, [@babel/preset-env](https://babeljs.io/docs From `@babel/preset-env`'s docs > We leverage [`browserslist`, `compat-table`, and `electron-to-chromium`] to maintain mappings of which version of our supported target environments gained support of a JavaScript syntax or browser feature, as well as a mapping of those syntaxes and features to Babel transform plugins and core-js polyfills. + +### Dereferenced schemas for `anyOf`/`allOf` options + +`options` prop for `MultiSchemaField` has been changed slightly. + +Before an option could include a `$ref`. + +Now any option with a reference will be resolved/dereferenced when given as props for `MultiSchemaField`. diff --git a/packages/core/src/components/fields/SchemaField.js b/packages/core/src/components/fields/SchemaField.js index 526c008b28..5f4087be23 100644 --- a/packages/core/src/components/fields/SchemaField.js +++ b/packages/core/src/components/fields/SchemaField.js @@ -362,7 +362,9 @@ function SchemaFieldRender(props) { onBlur={props.onBlur} onChange={props.onChange} onFocus={props.onFocus} - options={schema.anyOf} + options={schema.anyOf.map(_schema => + retrieveSchema(_schema, rootSchema, formData) + )} baseType={schema.type} registry={registry} schema={schema} @@ -380,7 +382,9 @@ function SchemaFieldRender(props) { onBlur={props.onBlur} onChange={props.onChange} onFocus={props.onFocus} - options={schema.oneOf} + options={schema.oneOf.map(_schema => + retrieveSchema(_schema, rootSchema, formData) + )} baseType={schema.type} registry={registry} schema={schema} diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index d0e31714cb..83c20cdec8 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -1225,10 +1225,10 @@ export function getMatchingOption(formData, options, rootSchema) { // been filled in yet, which will mean that the schema is not valid delete augmentedSchema.required; - if (isValid(augmentedSchema, formData)) { + if (isValid(augmentedSchema, formData, rootSchema)) { return i; } - } else if (isValid(options[i], formData)) { + } else if (isValid(option, formData, rootSchema)) { return i; } } diff --git a/packages/core/src/validate.js b/packages/core/src/validate.js index 73046208df..f2bb6b9887 100644 --- a/packages/core/src/validate.js +++ b/packages/core/src/validate.js @@ -5,6 +5,7 @@ import { deepEquals, getDefaultFormState } from "./utils"; let formerCustomFormats = null; let formerMetaSchema = null; +const ROOT_SCHEMA_PREFIX = "__rjsf_rootSchema"; import { isObject, mergeObjects } from "./utils"; @@ -263,15 +264,53 @@ export default function validateFormData( }; } +/** + * Recursively prefixes all $ref's in a schema with `ROOT_SCHEMA_PREFIX` + * This is used in isValid to make references to the rootSchema + */ +export function withIdRefPrefix(schemaNode) { + let obj = schemaNode; + if (schemaNode.constructor === Object) { + obj = { ...schemaNode }; + for (const key in obj) { + const value = obj[key]; + if ( + key === "$ref" && + typeof value === "string" && + value.startsWith("#") + ) { + obj[key] = ROOT_SCHEMA_PREFIX + value; + } else { + obj[key] = withIdRefPrefix(value); + } + } + } else if (Array.isArray(schemaNode)) { + obj = [...schemaNode]; + for (var i = 0; i < obj.length; i++) { + obj[i] = withIdRefPrefix(obj[i]); + } + } + return obj; +} + /** * Validates data against a schema, returning true if the data is valid, or * false otherwise. If the schema is invalid, then this function will return * false. */ -export function isValid(schema, data) { +export function isValid(schema, data, rootSchema) { try { - return ajv.validate(schema, data); + // add the rootSchema ROOT_SCHEMA_PREFIX as id. + // then rewrite the schema ref's to point to the rootSchema + // this accounts for the case where schema have references to models + // that lives in the rootSchema but not in the schema in question. + return ajv + .addSchema(rootSchema, ROOT_SCHEMA_PREFIX) + .validate(withIdRefPrefix(schema), data); } catch (e) { return false; + } finally { + // make sure we remove the rootSchema from the global ajv instance + ajv.removeSchema(ROOT_SCHEMA_PREFIX); } } diff --git a/packages/core/test/anyOf_test.js b/packages/core/test/anyOf_test.js index a7bc43e388..382ed76dc5 100644 --- a/packages/core/test/anyOf_test.js +++ b/packages/core/test/anyOf_test.js @@ -90,6 +90,45 @@ describe("anyOf", () => { }); }); + it("should assign a default value and set defaults on option change when using references", () => { + const { node, onChange } = createFormComponent({ + schema: { + anyOf: [ + { + type: "object", + properties: { + foo: { type: "string", default: "defaultfoo" }, + }, + }, + { + $ref: "#/definitions/bar", + }, + ], + definitions: { + bar: { + type: "object", + properties: { + foo: { type: "string", default: "defaultbar" }, + }, + }, + }, + }, + }); + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { foo: "defaultfoo" }, + }); + + const $select = node.querySelector("select"); + + Simulate.change($select, { + target: { value: $select.options[1].value }, + }); + + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { foo: "defaultbar" }, + }); + }); + it("should assign a default value and set defaults on option change with 'type': 'object' missing", () => { const { node, onChange } = createFormComponent({ schema: { @@ -639,6 +678,98 @@ describe("anyOf", () => { }); }); + it("should use title from refs schema before using fallback generated value as title", () => { + const schema = { + definitions: { + address: { + title: "Address", + type: "object", + properties: { + street: { + title: "Street", + type: "string", + }, + }, + }, + person: { + title: "Person", + type: "object", + properties: { + name: { + title: "Name", + type: "string", + }, + }, + }, + nested: { + $ref: "#/definitions/person", + }, + }, + anyOf: [ + { + $ref: "#/definitions/address", + }, + { + $ref: "#/definitions/nested", + }, + ], + }; + + const { node } = createFormComponent({ + schema, + }); + + let options = node.querySelectorAll("option"); + expect(options[0].firstChild.nodeValue).eql("Address"); + expect(options[1].firstChild.nodeValue).eql("Person"); + }); + + it("should collect schema from $ref even when ref is within properties", () => { + const schema = { + properties: { + address: { + title: "Address", + type: "object", + properties: { + street: { + title: "Street", + type: "string", + }, + }, + }, + person: { + title: "Person", + type: "object", + properties: { + name: { + title: "Name", + type: "string", + }, + }, + }, + nested: { + $ref: "#/properties/person", + }, + }, + anyOf: [ + { + $ref: "#/properties/address", + }, + { + $ref: "#/properties/nested", + }, + ], + }; + + const { node } = createFormComponent({ + schema, + }); + + let options = node.querySelectorAll("option"); + expect(options[0].firstChild.nodeValue).eql("Address"); + expect(options[1].firstChild.nodeValue).eql("Person"); + }); + describe("Arrays", () => { it("should correctly render form inputs for anyOf inside array items", () => { const schema = { @@ -782,6 +913,46 @@ describe("anyOf", () => { expect(strInputs[1].value).eql("bar"); }); + it("should correctly set the label of the options", () => { + const schema = { + type: "object", + anyOf: [ + { + title: "Foo", + properties: { + foo: { type: "string" }, + }, + }, + { + properties: { + bar: { type: "string" }, + }, + }, + { + $ref: "#/definitions/baz", + }, + ], + definitions: { + baz: { + title: "Baz", + properties: { + baz: { type: "string" }, + }, + }, + }, + }; + + const { node } = createFormComponent({ + schema, + }); + + const $select = node.querySelector("select"); + + expect($select.options[0].text).eql("Foo"); + expect($select.options[1].text).eql("Option 2"); + expect($select.options[2].text).eql("Baz"); + }); + it("should correctly render mixed types for anyOf inside array items", () => { const schema = { type: "object", @@ -827,5 +998,90 @@ describe("anyOf", () => { expect(node.querySelectorAll("input#root_foo")).to.have.length.of(1); expect(node.querySelectorAll("input#root_bar")).to.have.length.of(1); }); + + it("should correctly infer the selected option based on value", () => { + const schema = { + $ref: "#/defs/any", + defs: { + chain: { + type: "object", + title: "Chain", + properties: { + id: { + enum: ["chain"], + }, + components: { + type: "array", + items: { $ref: "#/defs/any" }, + }, + }, + }, + + map: { + type: "object", + title: "Map", + properties: { + id: { enum: ["map"] }, + fn: { $ref: "#/defs/any" }, + }, + }, + + to_absolute: { + type: "object", + title: "To Absolute", + properties: { + id: { enum: ["to_absolute"] }, + base_url: { type: "string" }, + }, + }, + + transform: { + type: "object", + title: "Transform", + properties: { + id: { enum: ["transform"] }, + property_key: { type: "string" }, + transformer: { $ref: "#/defs/any" }, + }, + }, + any: { + anyOf: [ + { $ref: "#/defs/chain" }, + { $ref: "#/defs/map" }, + { $ref: "#/defs/to_absolute" }, + { $ref: "#/defs/transform" }, + ], + }, + }, + }; + + const { node } = createFormComponent({ + schema, + formData: { + id: "chain", + components: [ + { + id: "map", + fn: { + id: "transform", + property_key: "uri", + transformer: { + id: "to_absolute", + base_url: "http://localhost", + }, + }, + }, + ], + }, + }); + + const idSelects = node.querySelectorAll("select#root_id"); + + expect(idSelects).to.have.length(4); + expect(idSelects[0].value).eql("chain"); + expect(idSelects[1].value).eql("map"); + expect(idSelects[2].value).eql("transform"); + expect(idSelects[3].value).eql("to_absolute"); + }); }); }); diff --git a/packages/core/test/oneOf_test.js b/packages/core/test/oneOf_test.js index 2060d99052..7bb795078c 100644 --- a/packages/core/test/oneOf_test.js +++ b/packages/core/test/oneOf_test.js @@ -91,6 +91,44 @@ describe("oneOf", () => { }); }); + it("should assign a default value and set defaults on option change when using refs", () => { + const { node, onChange } = createFormComponent({ + schema: { + oneOf: [ + { + type: "object", + properties: { + foo: { type: "string", default: "defaultfoo" }, + }, + }, + { $ref: "#/definitions/bar" }, + ], + definitions: { + bar: { + type: "object", + properties: { + foo: { type: "string", default: "defaultbar" }, + }, + }, + }, + }, + }); + + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { foo: "defaultfoo" }, + }); + + const $select = node.querySelector("select"); + + Simulate.change($select, { + target: { value: $select.options[1].value }, + }); + + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { foo: "defaultbar" }, + }); + }); + it("should assign a default value and set defaults on option change with 'type': 'object' missing", () => { const { node, onChange } = createFormComponent({ schema: { @@ -611,5 +649,130 @@ describe("oneOf", () => { expect($select.value).to.eql($select.options[1].value); }); + + it("should correctly set the label of the options", () => { + const schema = { + type: "object", + oneOf: [ + { + title: "Foo", + properties: { + foo: { type: "string" }, + }, + }, + { + properties: { + bar: { type: "string" }, + }, + }, + { + $ref: "#/definitions/baz", + }, + ], + definitions: { + baz: { + title: "Baz", + properties: { + baz: { type: "string" }, + }, + }, + }, + }; + + const { node } = createFormComponent({ + schema, + }); + + const $select = node.querySelector("select"); + + expect($select.options[0].text).eql("Foo"); + expect($select.options[1].text).eql("Option 2"); + expect($select.options[2].text).eql("Baz"); + }); + }); + + it("should correctly infer the selected option based on value", () => { + const schema = { + $ref: "#/defs/any", + defs: { + chain: { + type: "object", + title: "Chain", + properties: { + id: { + enum: ["chain"], + }, + components: { + type: "array", + items: { $ref: "#/defs/any" }, + }, + }, + }, + + map: { + type: "object", + title: "Map", + properties: { + id: { enum: ["map"] }, + fn: { $ref: "#/defs/any" }, + }, + }, + + to_absolute: { + type: "object", + title: "To Absolute", + properties: { + id: { enum: ["to_absolute"] }, + base_url: { type: "string" }, + }, + }, + + transform: { + type: "object", + title: "Transform", + properties: { + id: { enum: ["transform"] }, + property_key: { type: "string" }, + transformer: { $ref: "#/defs/any" }, + }, + }, + any: { + oneOf: [ + { $ref: "#/defs/chain" }, + { $ref: "#/defs/map" }, + { $ref: "#/defs/to_absolute" }, + { $ref: "#/defs/transform" }, + ], + }, + }, + }; + + const { node } = createFormComponent({ + schema, + formData: { + id: "chain", + components: [ + { + id: "map", + fn: { + id: "transform", + property_key: "uri", + transformer: { + id: "to_absolute", + base_url: "http://localhost", + }, + }, + }, + ], + }, + }); + + const idSelects = node.querySelectorAll("select#root_id"); + + expect(idSelects).to.have.length(4); + expect(idSelects[0].value).eql("chain"); + expect(idSelects[1].value).eql("map"); + expect(idSelects[2].value).eql("transform"); + expect(idSelects[3].value).eql("to_absolute"); }); }); diff --git a/packages/core/test/utils_test.js b/packages/core/test/utils_test.js index 200ce167cc..d7c554141c 100644 --- a/packages/core/test/utils_test.js +++ b/packages/core/test/utils_test.js @@ -29,6 +29,7 @@ import { schemaRequiresTrueValue, canExpand, optionsList, + getMatchingOption, } from "../src/utils"; import { createSandbox } from "./test_utils"; @@ -3827,5 +3828,41 @@ describe("utils", () => { })) ); }); + it("should infer correct anyOf schema based on data", () => { + const rootSchema = { + defs: { + a: { type: "object", properties: { id: { enum: ["a"] } } }, + nested: { + type: "object", + properties: { + id: { enum: ["nested"] }, + child: { $ref: "#/defs/any" }, + }, + }, + any: { anyOf: [{ $ref: "#/defs/a" }, { $ref: "#/defs/nested" }] }, + }, + $ref: "#/defs/any", + }; + const options = [ + { type: "object", properties: { id: { enum: ["a"] } } }, + { + type: "object", + properties: { + id: { enum: ["nested"] }, + child: { $ref: "#/defs/any" }, + }, + }, + ]; + const formData = { + id: "nested", + child: { + id: "nested", + child: { + id: "a", + }, + }, + }; + expect(getMatchingOption(formData, options, rootSchema)).eql(1); + }); }); }); diff --git a/packages/core/test/validate_test.js b/packages/core/test/validate_test.js index 70d4e9398a..9c11778643 100644 --- a/packages/core/test/validate_test.js +++ b/packages/core/test/validate_test.js @@ -3,7 +3,11 @@ import { expect } from "chai"; import sinon from "sinon"; import { Simulate } from "react-dom/test-utils"; -import validateFormData, { isValid, toErrorList } from "../src/validate"; +import validateFormData, { + isValid, + toErrorList, + withIdRefPrefix, +} from "../src/validate"; import { createFormComponent, submitForm } from "./test_utils"; describe("Validation", () => { @@ -16,7 +20,7 @@ describe("Validation", () => { }, }; - expect(isValid(schema, { foo: "bar" })).to.be.true; + expect(isValid(schema, { foo: "bar" }, schema)).to.be.true; }); it("should return false if the data is not valid against the schema", () => { @@ -27,13 +31,71 @@ describe("Validation", () => { }, }; - expect(isValid(schema, { foo: 12345 })).to.be.false; + expect(isValid(schema, { foo: 12345 }, schema)).to.be.false; }); it("should return false if the schema is invalid", () => { const schema = "foobarbaz"; - expect(isValid(schema, { foo: "bar" })).to.be.false; + expect(isValid(schema, { foo: "bar" }, schema)).to.be.false; + }); + + it("should return true if the data is valid against the schema including refs to rootSchema", () => { + const schema = { + anyOf: [{ $ref: "#/defs/foo" }], + }; + const rootSchema = { + defs: { + foo: { + properties: { + name: { type: "string" }, + }, + }, + }, + }; + const formData = { + name: "John Doe", + }; + + expect(isValid(schema, formData, rootSchema)).to.be.true; + }); + }); + + describe("validate.withIdRefPrefix", () => { + it("should recursively add id prefix to all refs", () => { + const schema = { + anyOf: [{ $ref: "#/defs/foo" }], + }; + const expected = { + anyOf: [{ $ref: "__rjsf_rootSchema#/defs/foo" }], + }; + + expect(withIdRefPrefix(schema)).to.eql(expected); + }); + + it("shouldn't mutate the schema", () => { + const schema = { + anyOf: [{ $ref: "#/defs/foo" }], + }; + + withIdRefPrefix(schema); + + expect(schema).to.eql({ + anyOf: [{ $ref: "#/defs/foo" }], + }); + }); + + it("should not change a property named '$ref'", () => { + const schema = { + title: "A registration form", + description: "A simple form example.", + type: "object", + properties: { + $ref: { type: "string", title: "First name", default: "Chuck" }, + }, + }; + + expect(withIdRefPrefix(schema)).to.eql(schema); }); });