Skip to content

Commit

Permalink
Fix defaults with oneof/anyof and nested dependencies (#1304)
Browse files Browse the repository at this point in the history
* fix: refactor out getMatchingOption

* fix: populate defaults for oneOf/anyOf

* fix: oneOf dependencies issue #1293

* fix: set defaults on oneof/anyof change

* fix: remove unneeded export

* test: reorg tests

* fix: populate defaults for nested dependencies

* fix: fix NIT by @LucianBuzzo

* fix: fix typo

* Update test/utils_test.js
  • Loading branch information
epicfaace authored Jun 2, 2019
1 parent e6518e2 commit 388612e
Show file tree
Hide file tree
Showing 5 changed files with 445 additions and 69 deletions.
74 changes: 11 additions & 63 deletions src/components/fields/MultiSchemaField.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import {
getWidget,
guessType,
retrieveSchema,
getDefaultFormState,
getMatchingOption,
} from "../../utils";
import { isValid } from "../../validate";

class AnyOfField extends Component {
constructor(props) {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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),
Expand Down
101 changes: 95 additions & 6 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
Expand All @@ -173,7 +183,8 @@ function computeDefaults(schema, parentDefaults, definitions = {}) {
acc[key] = computeDefaults(
schema.properties[key],
(defaults || {})[key],
definitions
definitions,
(formData || {})[key]
);
return acc;
}, {});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
31 changes: 31 additions & 0 deletions test/anyOf_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions test/oneOf_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 388612e

Please sign in to comment.