Repository: mozilla-services/react-jsonschema-form
Pull request #1133 (2019-01-17 12:58:10)

Expand anyOf support to also support oneOf
Signed-off-by: Lucian <lucian.buzzo@gmail.com>

### Reasons for making this change

This is a follow-up to #1118 that expands the code to support `oneOf` as well.

### Checklist

* [x] **I'm updating documentation**
  - [x] I've checked the rendering of the Markdown text I've added
  - [ ] If I'm adding a new section, I've updated the Table of Content
* [x] **I'm adding or updating code**
  - [x] I've added and/or updated tests
  - [x] I've updated docs if needed
  - [x] I've run `npm run cs-format` on my branch to conform my code to [prettier](https://github.com/prettier/prettier) coding style
* [x] **I'm adding a new feature**
  - [x] I've updated the playground with an example use of the feature


LucianBuzzo: @glasserc If you have time a review would be great!

Patch:
diff --git a/README.md b/README.md
index 511f641e8..f5d8ffdda 100644
--- a/README.md
+++ b/README.md
@@ -512,7 +512,7 @@ const uiSchema = {
 
 #### `expandable` option
 
-If `additionalProperties` contains a schema object, an add button for new properies is shown by default. The UX for editing properties whose names are user-defined is still experimental.
+If `additionalProperties` contains a schema object, an add button for new properties is shown by default. The UX for editing properties whose names are user-defined is still experimental.
 
 You can turn support for `additionalProperties` off with the `expandable` option in `uiSchema`:
 
@@ -1334,7 +1334,7 @@ A field component will always be passed the following props:
  - `formData`: The data for this field;
  - `errorSchema`: The tree of errors for this field and its children;
  - `registry`: A [registry](#the-registry-object) object (read next).
- - `formContext`: A [formContext](#the-formcontext-object) object (read next next).
+ - `formContext`: A [formContext](#the-formcontext-object) object (read next).
 
 #### The `registry` object
 
@@ -1792,8 +1792,6 @@ The JSON Schema standard says that the dependency is triggered if the property i
 
 In this example the user is prompted with different follow-up questions dynamically based on their answer to the first question.
 
-Note that this is quite far from complete `oneOf` support!
-
 In these examples, the "Do you have any pets?" question is validated against the corresponding property in each schema in the `oneOf` array. If exactly one matches, the rest of that schema is merged with the existing schema.
 
 ## JSON Schema supporting status
@@ -1803,13 +1801,9 @@ This component follows [JSON Schema](http://json-schema.org/documentation.html)
 * `additionalItems` keyword for arrays
     This keyword works when `items` is an array. `additionalItems: true` is not supported because there's no widget to represent an item of any type. In this case it will be treated as no additional items allowed. `additionalItems` being a valid schema is supported.
 * `anyOf`, `allOf`, and `oneOf`, or multiple `types` (i.e. `"type": ["string", "array"]`
-    The `anyOf` keyword is supported but has the following caveats:
-    - The `anyOf` keyword is not supported when used inside the `items` keyword
-      for arrays.
-    - Properties declared inside the `anyOf` should not overlap with properties
-      "outside" of the `anyOf`.
+    The `anyOf`  and `oneOf` keywords are supported,  however, properties declared inside the `anyOf/oneOf` should not overlap with properties "outside" of the `anyOf/oneOf`.
 
-    You can use `oneOf` with [schema dependencies](#schema-dependencies) to dynamically add schema properties based on input data but this feature does not bring general support for `oneOf` elsewhere in a schema.
+    You can also use `oneOf` with [schema dependencies](#schema-dependencies) to dynamically add schema properties based on input data.
 
 ## Tips and tricks
 
@@ -1881,11 +1875,7 @@ $ git push --tags origin master
 
 ### Q: Does rjsf support `oneOf`, `anyOf`, multiple types in an array, etc.?
 
-A: The `anyOf` keyword is supported but has the following caveats:
-    - The `anyOf` keyword is not supported when used inside the `items` keyword
-      for arrays.
-    - Properties declared inside the `anyOf` should not overlap with properties
-      "outside" of the `anyOf`.
+A: The `anyOf`  and `oneOf` keywords are supported,  however, properties declared inside the `anyOf/oneOf` should not overlap with properties "outside" of the `anyOf/oneOf`.
 There is also special cased where you can use `oneOf` in [schema dependencies](#schema-dependencies), If you'd like to help improve support for these keywords, see the following issues for inspiration [#329](https://github.com/mozilla-services/react-jsonschema-form/pull/329) or [#417](https://github.com/mozilla-services/react-jsonschema-form/pull/417). See also: [#52](https://github.com/mozilla-services/react-jsonschema-form/issues/52), [#151](https://github.com/mozilla-services/react-jsonschema-form/issues/151), [#171](https://github.com/mozilla-services/react-jsonschema-form/issues/171), [#200](https://github.com/mozilla-services/react-jsonschema-form/issues/200), [#282](https://github.com/mozilla-services/react-jsonschema-form/issues/282), [#302](https://github.com/mozilla-services/react-jsonschema-form/pull/302), [#330](https://github.com/mozilla-services/react-jsonschema-form/issues/330), [#430](https://github.com/mozilla-services/react-jsonschema-form/issues/430), [#522](https://github.com/mozilla-services/react-jsonschema-form/issues/522), [#538](https://github.com/mozilla-services/react-jsonschema-form/issues/538), [#551](https://github.com/mozilla-services/react-jsonschema-form/issues/551), [#552](https://github.com/mozilla-services/react-jsonschema-form/issues/552), or [#648](https://github.com/mozilla-services/react-jsonschema-form/issues/648).
 
 ### Q: Will react-jsonschema-form support Material, Ant-Design, Foundation, or [some other specific widget library or frontend style]?
diff --git a/playground/samples/index.js b/playground/samples/index.js
index 8ad13741e..4f5f9346e 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -1,5 +1,6 @@
 import arrays from "./arrays";
 import anyOf from "./anyOf";
+import oneOf from "./oneOf";
 import nested from "./nested";
 import numbers from "./numbers";
 import simple from "./simple";
@@ -41,5 +42,6 @@ export const samples = {
   "Property dependencies": propertyDependencies,
   "Schema dependencies": schemaDependencies,
   "Additional Properties": additionalProperties,
-  "Optional Forms": anyOf,
+  "Any Of": anyOf,
+  "One Of": oneOf,
 };
diff --git a/playground/samples/oneOf.js b/playground/samples/oneOf.js
new file mode 100644
index 000000000..4ed400491
--- /dev/null
+++ b/playground/samples/oneOf.js
@@ -0,0 +1,24 @@
+module.exports = {
+  schema: {
+    type: "object",
+    oneOf: [
+      {
+        properties: {
+          lorem: {
+            type: "string",
+          },
+        },
+        required: ["lorem"],
+      },
+      {
+        properties: {
+          ipsum: {
+            type: "string",
+          },
+        },
+        required: ["ipsum"],
+      },
+    ],
+  },
+  formData: {},
+};
diff --git a/src/components/fields/AnyOfField.js b/src/components/fields/MultiSchemaField.js
similarity index 91%
rename from src/components/fields/AnyOfField.js
rename to src/components/fields/MultiSchemaField.js
index 52fbc74c2..e11b9434a 100644
--- a/src/components/fields/AnyOfField.js
+++ b/src/components/fields/MultiSchemaField.js
@@ -8,17 +8,17 @@ class AnyOfField extends Component {
   constructor(props) {
     super(props);
 
-    const { formData, schema } = this.props;
+    const { formData, options } = this.props;
 
     this.state = {
-      selectedOption: this.getMatchingOption(formData, schema.anyOf),
+      selectedOption: this.getMatchingOption(formData, options),
     };
   }
 
   componentWillReceiveProps(nextProps) {
     const matchingOption = this.getMatchingOption(
       nextProps.formData,
-      nextProps.schema.anyOf
+      nextProps.options
     );
 
     if (matchingOption === this.state.selectedOption) {
@@ -41,8 +41,7 @@ class AnyOfField extends Component {
 
   onOptionChange = event => {
     const selectedOption = parseInt(event.target.value, 10);
-    const { formData, onChange, schema } = this.props;
-    const options = schema.anyOf;
+    const { formData, onChange, options } = this.props;
 
     if (guessType(formData) === "object") {
       const newFormData = Object.assign({}, formData);
@@ -73,6 +72,7 @@ class AnyOfField extends Component {
 
   render() {
     const {
+      baseType,
       disabled,
       errorSchema,
       formData,
@@ -81,7 +81,7 @@ class AnyOfField extends Component {
       onBlur,
       onChange,
       onFocus,
-      schema,
+      options,
       registry,
       safeRenderCompletion,
       uiSchema,
@@ -90,8 +90,6 @@ class AnyOfField extends Component {
     const _SchemaField = registry.fields.SchemaField;
     const { selectedOption } = this.state;
 
-    const baseType = schema.type;
-    const options = schema.anyOf || [];
     const option = options[selectedOption] || null;
     let optionSchema;
 
@@ -151,7 +149,8 @@ AnyOfField.defaultProps = {
 
 if (process.env.NODE_ENV !== "production") {
   AnyOfField.propTypes = {
-    schema: PropTypes.object.isRequired,
+    options: PropTypes.arrayOf(PropTypes.object).isRequired,
+    baseType: PropTypes.string,
     uiSchema: PropTypes.object,
     idSchema: PropTypes.object,
     formData: PropTypes.any,
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index f55e8a030..b0965678e 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -39,9 +39,9 @@ function getFieldComponent(schema, uiSchema, idSchema, fields) {
 
   const componentName = COMPONENT_TYPES[getSchemaType(schema)];
 
-  // If the type is not defined and the schema uses 'anyOf', don't render
-  // a field and let the AnyOfField component handle the form display
-  if (!componentName && schema.anyOf) {
+  // If the type is not defined and the schema uses 'anyOf' or 'oneOf', don't
+  // render a field and let the MultiSchemaField component handle the form display
+  if (!componentName && (schema.anyOf || schema.oneOf)) {
     return () => null;
   }
 
@@ -330,14 +330,15 @@ function SchemaFieldRender(props) {
   };
 
   const _AnyOfField = registry.fields.AnyOfField;
+  const _OneOfField = registry.fields.OneOfField;
 
   return (
     <FieldTemplate {...fieldProps}>
       {field}
 
       {/*
-        If the schema `anyOf` can be rendered as a select control, don't
-        render the `anyOf` selection and let `StringField` component handle
+        If the schema `anyOf` or 'oneOf' can be rendered as a select control, don't
+        render the selection and let `StringField` component handle
         rendering
       */}
       {schema.anyOf && !isSelect(schema) && (
@@ -350,7 +351,26 @@ function SchemaFieldRender(props) {
           onBlur={props.onBlur}
           onChange={props.onChange}
           onFocus={props.onFocus}
-          schema={schema}
+          options={schema.anyOf}
+          baseType={schema.type}
+          registry={registry}
+          safeRenderCompletion={props.safeRenderCompletion}
+          uiSchema={uiSchema}
+        />
+      )}
+
+      {schema.oneOf && !isSelect(schema) && (
+        <_OneOfField
+          disabled={disabled}
+          errorSchema={errorSchema}
+          formData={formData}
+          idPrefix={idPrefix}
+          idSchema={idSchema}
+          onBlur={props.onBlur}
+          onChange={props.onChange}
+          onFocus={props.onFocus}
+          options={schema.oneOf}
+          baseType={schema.type}
           registry={registry}
           safeRenderCompletion={props.safeRenderCompletion}
           uiSchema={uiSchema}
diff --git a/src/components/fields/index.js b/src/components/fields/index.js
index 9337e209f..758cb322a 100644
--- a/src/components/fields/index.js
+++ b/src/components/fields/index.js
@@ -1,7 +1,7 @@
-import AnyOfField from "./AnyOfField";
 import ArrayField from "./ArrayField";
 import BooleanField from "./BooleanField";
 import DescriptionField from "./DescriptionField";
+import MultiSchemaField from "./MultiSchemaField";
 import NumberField from "./NumberField";
 import ObjectField from "./ObjectField";
 import SchemaField from "./SchemaField";
@@ -10,12 +10,13 @@ import TitleField from "./TitleField";
 import UnsupportedField from "./UnsupportedField";
 
 export default {
-  AnyOfField,
+  AnyOfField: MultiSchemaField,
   ArrayField,
   BooleanField,
   DescriptionField,
   NumberField,
   ObjectField,
+  OneOfField: MultiSchemaField,
   SchemaField,
   StringField,
   TitleField,
diff --git a/src/utils.js b/src/utils.js
index 47856324d..7deaa3edf 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -157,9 +157,12 @@ function computeDefaults(schema, parentDefaults, definitions = {}) {
           if (schema.minItems > defaultsLength) {
             const defaultEntries = defaults || [];
             // populate the array with the defaults
+            const fillerSchema = Array.isArray(schema.items)
+              ? schema.additionalItems
+              : schema.items;
             const fillerEntries = fill(
               new Array(schema.minItems - defaultsLength),
-              computeDefaults(schema.items, schema.items.defaults, definitions)
+              computeDefaults(fillerSchema, fillerSchema.defaults, definitions)
             );
             // then fill up the rest with either the item default or empty, up to minItems
 


Test patch:
diff --git a/test/oneOf_test.js b/test/oneOf_test.js
new file mode 100644
index 000000000..25fe39f97
--- /dev/null
+++ b/test/oneOf_test.js
@@ -0,0 +1,302 @@
+import React from "react";
+import { expect } from "chai";
+import { Simulate } from "react-addons-test-utils";
+
+import { createFormComponent, createSandbox, setProps } from "./test_utils";
+
+describe("oneOf", () => {
+  let sandbox;
+
+  beforeEach(() => {
+    sandbox = createSandbox();
+  });
+
+  afterEach(() => {
+    sandbox.restore();
+  });
+
+  it("should not render a select element if the oneOf keyword is not present", () => {
+    const schema = {
+      type: "object",
+      properties: {
+        foo: { type: "string" },
+      },
+    };
+
+    const { node } = createFormComponent({
+      schema,
+    });
+
+    expect(node.querySelectorAll("select")).to.have.length.of(0);
+  });
+
+  it("should render a select element if the oneOf keyword is present", () => {
+    const schema = {
+      type: "object",
+      oneOf: [
+        {
+          properties: {
+            foo: { type: "string" },
+          },
+        },
+        {
+          properties: {
+            bar: { type: "string" },
+          },
+        },
+      ],
+    };
+
+    const { node } = createFormComponent({
+      schema,
+    });
+
+    expect(node.querySelectorAll("select")).to.have.length.of(1);
+  });
+
+  it("should change the rendered form when the select value is changed", () => {
+    const schema = {
+      type: "object",
+      oneOf: [
+        {
+          properties: {
+            foo: { type: "string" },
+          },
+        },
+        {
+          properties: {
+            bar: { type: "string" },
+          },
+        },
+      ],
+    };
+
+    const { node } = createFormComponent({
+      schema,
+    });
+
+    expect(node.querySelectorAll("#root_foo")).to.have.length.of(1);
+    expect(node.querySelectorAll("#root_bar")).to.have.length.of(0);
+
+    const $select = node.querySelector("select");
+
+    Simulate.change($select, {
+      target: { value: $select.options[1].value },
+    });
+
+    expect(node.querySelectorAll("#root_foo")).to.have.length.of(0);
+    expect(node.querySelectorAll("#root_bar")).to.have.length.of(1);
+  });
+
+  it("should handle change events", () => {
+    const schema = {
+      type: "object",
+      oneOf: [
+        {
+          properties: {
+            foo: { type: "string" },
+          },
+        },
+        {
+          properties: {
+            bar: { type: "string" },
+          },
+        },
+      ],
+    };
+
+    const { comp, node } = createFormComponent({
+      schema,
+    });
+
+    Simulate.change(node.querySelector("input#root_foo"), {
+      target: { value: "Lorem ipsum dolor sit amet" },
+    });
+
+    expect(comp.state.formData.foo).eql("Lorem ipsum dolor sit amet");
+  });
+
+  it("should clear previous data when changing options", () => {
+    const schema = {
+      type: "object",
+      properties: {
+        buzz: { type: "string" },
+      },
+      oneOf: [
+        {
+          properties: {
+            foo: { type: "string" },
+          },
+        },
+        {
+          properties: {
+            bar: { type: "string" },
+          },
+        },
+      ],
+    };
+
+    const { comp, node } = createFormComponent({
+      schema,
+    });
+
+    Simulate.change(node.querySelector("input#root_buzz"), {
+      target: { value: "Lorem ipsum dolor sit amet" },
+    });
+
+    Simulate.change(node.querySelector("input#root_foo"), {
+      target: { value: "Consectetur adipiscing elit" },
+    });
+
+    expect(comp.state.formData.buzz).eql("Lorem ipsum dolor sit amet");
+    expect(comp.state.formData.foo).eql("Consectetur adipiscing elit");
+
+    const $select = node.querySelector("select");
+
+    Simulate.change($select, {
+      target: { value: $select.options[1].value },
+    });
+
+    expect(comp.state.formData.hasOwnProperty("foo")).to.be.false;
+    expect(comp.state.formData.buzz).eql("Lorem ipsum dolor sit amet");
+  });
+
+  it("should support options with different types", () => {
+    const schema = {
+      type: "object",
+      properties: {
+        userId: {
+          oneOf: [
+            {
+              type: "number",
+            },
+            {
+              type: "string",
+            },
+          ],
+        },
+      },
+    };
+
+    const { comp, node } = createFormComponent({
+      schema,
+    });
+
+    Simulate.change(node.querySelector("input#root_userId"), {
+      target: { value: 12345 },
+    });
+
+    expect(comp.state.formData).eql({
+      userId: 12345,
+    });
+
+    const $select = node.querySelector("select");
+
+    Simulate.change($select, {
+      target: { value: $select.options[1].value },
+    });
+
+    expect(comp.state.formData).eql({
+      userId: undefined,
+    });
+
+    Simulate.change(node.querySelector("input#root_userId"), {
+      target: { value: "Lorem ipsum dolor sit amet" },
+    });
+
+    expect(comp.state.formData).eql({
+      userId: "Lorem ipsum dolor sit amet",
+    });
+  });
+
+  it("should support custom fields", () => {
+    const schema = {
+      type: "object",
+      properties: {
+        userId: {
+          oneOf: [
+            {
+              type: "number",
+            },
+            {
+              type: "string",
+            },
+          ],
+        },
+      },
+    };
+
+    const CustomField = () => {
+      return <div id="custom-oneof-field" />;
+    };
+
+    const { node } = createFormComponent({
+      schema,
+      fields: {
+        OneOfField: CustomField,
+      },
+    });
+
+    expect(node.querySelectorAll("#custom-oneof-field")).to.have.length(1);
+  });
+
+  it("should select the correct field when the form is rendered from existing data", () => {
+    const schema = {
+      type: "object",
+      properties: {
+        userId: {
+          oneOf: [
+            {
+              type: "number",
+            },
+            {
+              type: "string",
+            },
+          ],
+        },
+      },
+    };
+
+    const { node } = createFormComponent({
+      schema,
+      formData: {
+        userId: "foobarbaz",
+      },
+    });
+
+    expect(node.querySelector("select").value).eql("1");
+  });
+
+  it("should select the correct field when the formData property is updated", () => {
+    const schema = {
+      type: "object",
+      properties: {
+        userId: {
+          oneOf: [
+            {
+              type: "number",
+            },
+            {
+              type: "string",
+            },
+          ],
+        },
+      },
+    };
+
+    const { comp, node } = createFormComponent({
+      schema,
+    });
+
+    expect(node.querySelector("select").value).eql("0");
+
+    setProps(comp, {
+      schema,
+      formData: {
+        userId: "foobarbaz",
+      },
+    });
+
+    expect(node.querySelector("select").value).eql("1");
+  });
+});
diff --git a/test/utils_test.js b/test/utils_test.js
index 0d97f0551..7ba1be760 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -292,6 +292,26 @@ describe("utils", () => {
           foo: 42,
         });
       });
+
+      it("should fill array with additional items schema when items is empty", () => {
+        const schema = {
+          type: "object",
+          properties: {
+            array: {
+              type: "array",
+              minItems: 1,
+              additionalItems: {
+                type: "string",
+                default: "foo",
+              },
+              items: [],
+            },
+          },
+        };
+        expect(getDefaultFormState(schema, {})).eql({
+          array: ["foo"],
+        });
+      });
     });
   });