diff --git a/src/components/fields/MultiSchemaField.js b/src/components/fields/MultiSchemaField.js index 44401dc06a..21968ae729 100644 --- a/src/components/fields/MultiSchemaField.js +++ b/src/components/fields/MultiSchemaField.js @@ -6,8 +6,9 @@ import { getWidget, guessType, retrieveSchema, + getDefaultFormState, + getMatchingOption, } from "../../utils"; -import { isValid } from "../../validate"; class AnyOfField extends Component { constructor(props) { @@ -35,65 +36,11 @@ class AnyOfField extends Component { getMatchingOption(formData, options) { const { definitions } = this.props.registry; - for (let i = 0; i < options.length; i++) { - // Assign the definitions to the option, otherwise the match can fail if - // the new option uses a $ref - const option = Object.assign( - { - definitions, - }, - options[i] - ); - - // If the schema describes an object then we need to add slightly more - // strict matching to the schema, because unless the schema uses the - // "requires" keyword, an object will match the schema as long as it - // doesn't have matching keys with a conflicting type. To do this we use an - // "anyOf" with an array of requires. This augmentation expresses that the - // schema should match if any of the keys in the schema are present on the - // object and pass validation. - if (option.properties) { - // Create an "anyOf" schema that requires at least one of the keys in the - // "properties" object - const requiresAnyOf = { - anyOf: Object.keys(option.properties).map(key => ({ - required: [key], - })), - }; - - let augmentedSchema; - - // If the "anyOf" keyword already exists, wrap the augmentation in an "allOf" - if (option.anyOf) { - // Create a shallow clone of the option - const { ...shallowClone } = option; - - if (!shallowClone.allOf) { - shallowClone.allOf = []; - } else { - // If "allOf" already exists, shallow clone the array - shallowClone.allOf = shallowClone.allOf.slice(); - } - - shallowClone.allOf.push(requiresAnyOf); - - augmentedSchema = shallowClone; - } else { - augmentedSchema = Object.assign({}, option, requiresAnyOf); - } - - // Remove the "required" field as it's likely that not all fields have - // been filled in yet, which will mean that the schema is not valid - delete augmentedSchema.required; - if (isValid(augmentedSchema, formData)) { - return i; - } - } else if (isValid(options[i], formData)) { - return i; - } + let option = getMatchingOption(formData, options, definitions); + if (option !== 0) { + return option; } - // If the form data matches none of the options, use the currently selected // option, assuming it's available; otherwise use the first option return this && this.state ? this.state.selectedOption : 0; @@ -111,11 +58,12 @@ class AnyOfField extends Component { // If the new option is of type object and the current data is an object, // discard properties added using the old option. + let newFormData = undefined; if ( guessType(formData) === "object" && (newOption.type === "object" || newOption.properties) ) { - const newFormData = Object.assign({}, formData); + newFormData = Object.assign({}, formData); const optionsToDiscard = options.slice(); optionsToDiscard.splice(selectedOption, 1); @@ -130,11 +78,11 @@ class AnyOfField extends Component { } } } - - onChange(newFormData); - } else { - onChange(undefined); } + // Call getDefaultFormState to make sure defaults are populated on change. + onChange( + getDefaultFormState(options[selectedOption], newFormData, definitions) + ); this.setState({ selectedOption: parseInt(option, 10), diff --git a/src/utils.js b/src/utils.js index 4593a561c7..1eb8ebd28a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ import React from "react"; import * as ReactIs from "react-is"; import fill from "core-js/library/fn/array/fill"; -import validateFormData from "./validate"; +import validateFormData, { isValid } from "./validate"; export const ADDITIONAL_PROPERTY_FLAG = "__additional_property"; @@ -140,7 +140,7 @@ export function hasWidget(schema, widget, registeredWidgets = {}) { } } -function computeDefaults(schema, parentDefaults, definitions = {}) { +function computeDefaults(schema, parentDefaults, definitions, formData) { // Compute the defaults recursively: give highest priority to deepest nodes. let defaults = parentDefaults; if (isObject(defaults) && isObject(schema.default)) { @@ -153,12 +153,22 @@ function computeDefaults(schema, parentDefaults, definitions = {}) { } else if ("$ref" in schema) { // Use referenced schema defaults for this node. const refSchema = findSchemaDefinition(schema.$ref, definitions); - return computeDefaults(refSchema, defaults, definitions); + return computeDefaults(refSchema, defaults, definitions, formData); + } else if ("dependencies" in schema) { + const resolvedSchema = resolveDependencies(schema, definitions, formData); + return computeDefaults(resolvedSchema, defaults, definitions, formData); } else if (isFixedItems(schema)) { defaults = schema.items.map(itemSchema => - computeDefaults(itemSchema, undefined, definitions) + computeDefaults(itemSchema, undefined, definitions, formData) ); + } else if ("oneOf" in schema) { + schema = + schema.oneOf[getMatchingOption(undefined, schema.oneOf, definitions)]; + } else if ("anyOf" in schema) { + schema = + schema.anyOf[getMatchingOption(undefined, schema.anyOf, definitions)]; } + // Not defaults defined for this node, fallback to generic typed ones. if (typeof defaults === "undefined") { defaults = schema.default; @@ -173,7 +183,8 @@ function computeDefaults(schema, parentDefaults, definitions = {}) { acc[key] = computeDefaults( schema.properties[key], (defaults || {})[key], - definitions + definitions, + (formData || {})[key] ); return acc; }, {}); @@ -209,7 +220,12 @@ export function getDefaultFormState(_schema, formData, definitions = {}) { throw new Error("Invalid schema: " + _schema); } const schema = retrieveSchema(_schema, definitions, formData); - const defaults = computeDefaults(schema, _schema.default, definitions); + const defaults = computeDefaults( + schema, + _schema.default, + definitions, + formData + ); if (typeof formData === "undefined") { // No form data? Use schema defaults. return defaults; @@ -540,6 +556,17 @@ export function retrieveSchema(schema, definitions = {}, formData = {}) { function resolveDependencies(schema, definitions, formData) { // Drop the dependencies from the source schema. let { dependencies = {}, ...resolvedSchema } = schema; + if ("oneOf" in resolvedSchema) { + resolvedSchema = + resolvedSchema.oneOf[ + getMatchingOption(formData, resolvedSchema.oneOf, definitions) + ]; + } else if ("anyOf" in resolvedSchema) { + resolvedSchema = + resolvedSchema.anyOf[ + getMatchingOption(formData, resolvedSchema.anyOf, definitions) + ]; + } // Process dependencies updating the local schema properties as appropriate. for (const dependencyKey in dependencies) { // Skip this dependency if its trigger property is not present. @@ -871,3 +898,65 @@ export function rangeSpec(schema) { } return spec; } + +export function getMatchingOption(formData, options, definitions) { + for (let i = 0; i < options.length; i++) { + // Assign the definitions to the option, otherwise the match can fail if + // the new option uses a $ref + const option = Object.assign( + { + definitions, + }, + options[i] + ); + + // If the schema describes an object then we need to add slightly more + // strict matching to the schema, because unless the schema uses the + // "requires" keyword, an object will match the schema as long as it + // doesn't have matching keys with a conflicting type. To do this we use an + // "anyOf" with an array of requires. This augmentation expresses that the + // schema should match if any of the keys in the schema are present on the + // object and pass validation. + if (option.properties) { + // Create an "anyOf" schema that requires at least one of the keys in the + // "properties" object + const requiresAnyOf = { + anyOf: Object.keys(option.properties).map(key => ({ + required: [key], + })), + }; + + let augmentedSchema; + + // If the "anyOf" keyword already exists, wrap the augmentation in an "allOf" + if (option.anyOf) { + // Create a shallow clone of the option + const { ...shallowClone } = option; + + if (!shallowClone.allOf) { + shallowClone.allOf = []; + } else { + // If "allOf" already exists, shallow clone the array + shallowClone.allOf = shallowClone.allOf.slice(); + } + + shallowClone.allOf.push(requiresAnyOf); + + augmentedSchema = shallowClone; + } else { + augmentedSchema = Object.assign({}, option, requiresAnyOf); + } + + // Remove the "required" field as it's likely that not all fields have + // been filled in yet, which will mean that the schema is not valid + delete augmentedSchema.required; + + if (isValid(augmentedSchema, formData)) { + return i; + } + } else if (isValid(options[i], formData)) { + return i; + } + } + return 0; +} diff --git a/test/anyOf_test.js b/test/anyOf_test.js index 9f7cbf2b06..b5aba8d839 100644 --- a/test/anyOf_test.js +++ b/test/anyOf_test.js @@ -54,6 +54,37 @@ describe("anyOf", () => { expect(node.querySelectorAll("select")).to.have.length.of(1); }); + it("should assign a default value and set defaults on option change", () => { + const { comp, node } = createFormComponent({ + schema: { + anyOf: [ + { + type: "object", + properties: { + foo: { type: "string", default: "defaultfoo" }, + }, + }, + { + type: "object", + properties: { + foo: { type: "string", default: "defaultbar" }, + }, + }, + ], + }, + }); + + expect(comp.state.formData).eql({ foo: "defaultfoo" }); + + const $select = node.querySelector("select"); + + Simulate.change($select, { + target: { value: $select.options[1].value }, + }); + + expect(comp.state.formData).eql({ foo: "defaultbar" }); + }); + it("should render a custom widget", () => { const schema = { type: "object", diff --git a/test/oneOf_test.js b/test/oneOf_test.js index 3fa952faf2..386ae59b10 100644 --- a/test/oneOf_test.js +++ b/test/oneOf_test.js @@ -54,6 +54,37 @@ describe("oneOf", () => { expect(node.querySelectorAll("select")).to.have.length.of(1); }); + it("should assign a default value and set defaults on option change", () => { + const { comp, node } = createFormComponent({ + schema: { + oneOf: [ + { + type: "object", + properties: { + foo: { type: "string", default: "defaultfoo" }, + }, + }, + { + type: "object", + properties: { + foo: { type: "string", default: "defaultbar" }, + }, + }, + ], + }, + }); + + expect(comp.state.formData).eql({ foo: "defaultfoo" }); + + const $select = node.querySelector("select"); + + Simulate.change($select, { + target: { value: $select.options[1].value }, + }); + + expect(comp.state.formData).eql({ foo: "defaultbar" }); + }); + it("should render a custom widget", () => { const schema = { type: "object", diff --git a/test/utils_test.js b/test/utils_test.js index c49b390dcf..3641a3ef27 100644 --- a/test/utils_test.js +++ b/test/utils_test.js @@ -317,6 +317,283 @@ describe("utils", () => { }); }); }); + + describe("defaults with oneOf", () => { + it("should populate defaults for oneOf", () => { + const schema = { + type: "object", + properties: { + name: { + type: "string", + oneOf: [ + { type: "string", default: "a" }, + { type: "string", default: "b" }, + ], + }, + }, + }; + expect(getDefaultFormState(schema, {})).eql({ + name: "a", + }); + }); + + it("should populate nested default values for oneOf", () => { + const schema = { + type: "object", + properties: { + name: { + type: "object", + oneOf: [ + { + type: "object", + properties: { + first: { type: "string", default: "First Name" }, + }, + }, + { type: "string", default: "b" }, + ], + }, + }, + }; + expect(getDefaultFormState(schema, {})).eql({ + name: { + first: "First Name", + }, + }); + }); + + it("should populate defaults for oneOf + dependencies", () => { + const schema = { + oneOf: [ + { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + ], + dependencies: { + name: { + oneOf: [ + { + properties: { + name: { + type: "string", + }, + grade: { + default: "A", + }, + }, + }, + ], + }, + }, + }; + expect(getDefaultFormState(schema, { name: "Name" })).eql({ + name: "Name", + grade: "A", + }); + }); + }); + + describe("defaults with anyOf", () => { + it("should populate defaults for anyOf", () => { + const schema = { + type: "object", + properties: { + name: { + type: "string", + anyOf: [ + { type: "string", default: "a" }, + { type: "string", default: "b" }, + ], + }, + }, + }; + expect(getDefaultFormState(schema, {})).eql({ + name: "a", + }); + }); + + it("should populate nested default values for anyOf", () => { + const schema = { + type: "object", + properties: { + name: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + first: { type: "string", default: "First Name" }, + }, + }, + { type: "string", default: "b" }, + ], + }, + }, + }; + expect(getDefaultFormState(schema, {})).eql({ + name: { + first: "First Name", + }, + }); + }); + + it("should populate defaults for anyOf + dependencies", () => { + const schema = { + anyOf: [ + { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + ], + dependencies: { + name: { + oneOf: [ + { + properties: { + name: { + type: "string", + }, + grade: { + type: "string", + default: "A", + }, + }, + }, + ], + }, + }, + }; + expect(getDefaultFormState(schema, { name: "Name" })).eql({ + name: "Name", + grade: "A", + }); + }); + }); + + describe("with dependencies", () => { + it("should populate defaults for dependencies", () => { + const schema = { + type: "object", + properties: { + name: { + type: "string", + }, + }, + dependencies: { + name: { + oneOf: [ + { + properties: { + name: { + type: "string", + }, + grade: { + type: "string", + default: "A", + }, + }, + }, + ], + }, + }, + }; + expect(getDefaultFormState(schema, { name: "Name" })).eql({ + name: "Name", + grade: "A", + }); + }); + + it("should populate defaults for nested dependencies", () => { + const schema = { + type: "object", + properties: { + foo: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + dependencies: { + name: { + oneOf: [ + { + properties: { + name: { + type: "string", + }, + grade: { + type: "string", + default: "A", + }, + }, + }, + ], + }, + }, + }, + }, + }; + expect(getDefaultFormState(schema, { foo: { name: "Name" } })).eql({ + foo: { + name: "Name", + grade: "A", + }, + }); + }); + + it("should populate defaults for nested oneOf + dependencies", () => { + const schema = { + type: "object", + properties: { + foo: { + oneOf: [ + { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + ], + dependencies: { + name: { + oneOf: [ + { + properties: { + name: { + type: "string", + }, + grade: { + type: "string", + default: "A", + }, + }, + }, + ], + }, + }, + }, + }, + }; + expect(getDefaultFormState(schema, { foo: { name: "Name" } })).eql({ + foo: { + name: "Name", + grade: "A", + }, + }); + }); + }); }); describe("asNumber()", () => {