Repository: mozilla-services/react-jsonschema-form
Pull request #1118 (2019-01-03 10:23:24)

Add preliminary support for the anyOf keyword
This change introduces support for the `anyOf` keyword. Other PRs that add support for `anyOf`/`oneOf` exist, but are stale and/or incomplete ( #302, #417, #940 ).
The keyword is NOT supported in arrays (using the `items` keyword).
Options provided in the `anyOf` keyword are rendered using a new component called `AnyOfField`, this can be overridden with a custom field using the `fields` property.

I'd like to follow-up on this PR with work to add support for `anyOf` in arrays and `oneOf`. I decided against submitting that work in this PR to help keep reviews and discussion manageable.

![anyof-form](https://user-images.githubusercontent.com/15064535/50633109-0d7c0300-0f42-11e9-8493-0bd5206ae263.gif)

### Checklist

* [ ] **I'm updating documentation**
  - [ ] 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
  - [ ] 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: Note that the unit tests are failing due to the New year rollover, which I've fixed/band-aided in a separate PR here https://github.com/mozilla-services/react-jsonschema-form/pull/1119
cyplo: This looks good to me as a change and would be extremely useful if merged. Thanks ! :)
LucianBuzzo: @edi9999 Could you review this PR please?

LucianBuzzo: @edi9999 I've addressed your review comments

Patch:
diff --git a/README.md b/README.md
index b59790607..511f641e8 100644
--- a/README.md
+++ b/README.md
@@ -1063,7 +1063,9 @@ The following props are passed to each `ObjectFieldTemplate`:
 - `TitleField`: The `TitleField` from the registry (in case you wanted to utilize it).
 - `title`: A string value containing the title for the object.
 - `description`: A string value containing the description for the object.
+- `disabled`: A boolean value stating if the object is disabled.
 - `properties`: An array of object representing the properties in the array. Each of the properties represent a child with properties described below.
+- `readonly`: A boolean value stating if the object is read-only.
 - `required`: A boolean value stating if the object is required.
 - `schema`: The schema object for this object.
 - `uiSchema`: The uiSchema object for this object field.
@@ -1088,15 +1090,13 @@ An error list template is basically a React stateless component being passed err
 function ErrorListTemplate(props) {
   const {errors} = props;
   return (
-    <div>
-      {errors.map((error, i) => {
-        return (
-          <li key={i}>
+    <ul>
+      {errors.map(error => (
+          <li key={error.stack}>
             {error.stack}
           </li>
-        );
-      })}
-    </div>
+        ))}
+    </ul>
   );
 }
 
@@ -1261,7 +1261,7 @@ render((
 
 #### Customizing widgets text input
 
-All the widgets that render a text input use the `BaseInput` component internally. If you need to customize all text inputs without customizing all widgets individially, you can provide a `BaseInput` component in the `widgets` property of `Form` (see [Custom component registration](#custom-component-registration).
+All the widgets that render a text input use the `BaseInput` component internally. If you need to customize all text inputs without customizing all widgets individually, you can provide a `BaseInput` component in the `widgets` property of `Form` (see [Custom component registration](#custom-component-registration).
 
 ### Custom field components
 
@@ -1803,7 +1803,12 @@ 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"]`
-    Nobody yet has come up with a PR that adds this feature with a simple and easy-to-understand UX.
+    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`.
+
     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.
 
 ## Tips and tricks
@@ -1869,14 +1874,19 @@ $ git commit -m "Bump version $VERSION"
 $ git tag v$VERSION
 $ npm run dist
 $ npm publish
-$ git push --tags origin
+$ git push --tags origin master
 ```
 
 ## FAQ
 
 ### Q: Does rjsf support `oneOf`, `anyOf`, multiple types in an array, etc.?
 
-A: Not yet (except for a special case where you can use `oneOf` in [schema dependencies](#schema-dependencies)), but perhaps you will be the person whose PR will finally add the feature in a way that gets merged. For inspiration, see [#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).
+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`.
+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/package.json b/package.json
index 1db7ca591..d9d93b62a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "react-jsonschema-form",
-  "version": "1.0.7",
+  "version": "1.1.0",
   "description": "A simple React component capable of building HTML forms out of a JSON schema.",
   "scripts": {
     "build:readme": "toctoc README.md -w",
diff --git a/playground/app.js b/playground/app.js
index bf4f868c3..2a8dffdb6 100644
--- a/playground/app.js
+++ b/playground/app.js
@@ -357,6 +357,9 @@ class App extends Component {
   load = data => {
     // Reset the ArrayFieldTemplate whenever you load new data
     const { ArrayFieldTemplate, ObjectFieldTemplate } = data;
+    // uiSchema is missing on some examples. Provide a default to
+    // clear the field in all cases.
+    const { uiSchema = {} } = data;
     // force resetting form component instance
     this.setState({ form: false }, _ =>
       this.setState({
@@ -364,6 +367,7 @@ class App extends Component {
         form: true,
         ArrayFieldTemplate,
         ObjectFieldTemplate,
+        uiSchema,
       })
     );
   };
diff --git a/playground/samples/anyOf.js b/playground/samples/anyOf.js
new file mode 100644
index 000000000..432b0e0be
--- /dev/null
+++ b/playground/samples/anyOf.js
@@ -0,0 +1,37 @@
+module.exports = {
+  schema: {
+    type: "object",
+    properties: {
+      age: {
+        type: "integer",
+        title: "Age",
+      },
+    },
+    anyOf: [
+      {
+        title: "First method of identification",
+        properties: {
+          firstName: {
+            type: "string",
+            title: "First name",
+            default: "Chuck",
+          },
+          lastName: {
+            type: "string",
+            title: "Last name",
+          },
+        },
+      },
+      {
+        title: "Second method of identification",
+        properties: {
+          idCode: {
+            type: "string",
+            title: "ID code",
+          },
+        },
+      },
+    ],
+  },
+  formData: {},
+};
diff --git a/playground/samples/index.js b/playground/samples/index.js
index b496c0fb6..8ad13741e 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -1,4 +1,5 @@
 import arrays from "./arrays";
+import anyOf from "./anyOf";
 import nested from "./nested";
 import numbers from "./numbers";
 import simple from "./simple";
@@ -40,4 +41,5 @@ export const samples = {
   "Property dependencies": propertyDependencies,
   "Schema dependencies": schemaDependencies,
   "Additional Properties": additionalProperties,
+  "Optional Forms": anyOf,
 };
diff --git a/src/components/fields/AnyOfField.js b/src/components/fields/AnyOfField.js
new file mode 100644
index 000000000..52fbc74c2
--- /dev/null
+++ b/src/components/fields/AnyOfField.js
@@ -0,0 +1,163 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import * as types from "../../types";
+import { guessType } from "../../utils";
+import { isValid } from "../../validate";
+
+class AnyOfField extends Component {
+  constructor(props) {
+    super(props);
+
+    const { formData, schema } = this.props;
+
+    this.state = {
+      selectedOption: this.getMatchingOption(formData, schema.anyOf),
+    };
+  }
+
+  componentWillReceiveProps(nextProps) {
+    const matchingOption = this.getMatchingOption(
+      nextProps.formData,
+      nextProps.schema.anyOf
+    );
+
+    if (matchingOption === this.state.selectedOption) {
+      return;
+    }
+
+    this.setState({ selectedOption: matchingOption });
+  }
+
+  getMatchingOption(formData, options) {
+    for (let i = 0; i < options.length; i++) {
+      if (isValid(options[i], formData)) {
+        return i;
+      }
+    }
+
+    // If the form data matches none of the options, use the first option
+    return 0;
+  }
+
+  onOptionChange = event => {
+    const selectedOption = parseInt(event.target.value, 10);
+    const { formData, onChange, schema } = this.props;
+    const options = schema.anyOf;
+
+    if (guessType(formData) === "object") {
+      const newFormData = Object.assign({}, formData);
+
+      const optionsToDiscard = options.slice();
+      optionsToDiscard.splice(selectedOption, 1);
+
+      // Discard any data added using other options
+      for (const option of optionsToDiscard) {
+        if (option.properties) {
+          for (const key in option.properties) {
+            if (newFormData.hasOwnProperty(key)) {
+              delete newFormData[key];
+            }
+          }
+        }
+      }
+
+      onChange(newFormData);
+    } else {
+      onChange(undefined);
+    }
+
+    this.setState({
+      selectedOption: parseInt(event.target.value, 10),
+    });
+  };
+
+  render() {
+    const {
+      disabled,
+      errorSchema,
+      formData,
+      idPrefix,
+      idSchema,
+      onBlur,
+      onChange,
+      onFocus,
+      schema,
+      registry,
+      safeRenderCompletion,
+      uiSchema,
+    } = this.props;
+
+    const _SchemaField = registry.fields.SchemaField;
+    const { selectedOption } = this.state;
+
+    const baseType = schema.type;
+    const options = schema.anyOf || [];
+    const option = options[selectedOption] || null;
+    let optionSchema;
+
+    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 });
+    }
+
+    return (
+      <div className="panel panel-default panel-body">
+        <div className="form-group">
+          <select
+            className="form-control"
+            onChange={this.onOptionChange}
+            value={selectedOption}
+            id={`${idSchema.$id}_anyof_select`}>
+            {options.map((option, index) => {
+              return (
+                <option key={index} value={index}>
+                  {option.title || `Option ${index + 1}`}
+                </option>
+              );
+            })}
+          </select>
+        </div>
+
+        {option !== null && (
+          <_SchemaField
+            schema={optionSchema}
+            uiSchema={uiSchema}
+            errorSchema={errorSchema}
+            idSchema={idSchema}
+            idPrefix={idPrefix}
+            formData={formData}
+            onChange={onChange}
+            onBlur={onBlur}
+            onFocus={onFocus}
+            registry={registry}
+            safeRenderCompletion={safeRenderCompletion}
+            disabled={disabled}
+          />
+        )}
+      </div>
+    );
+  }
+}
+
+AnyOfField.defaultProps = {
+  disabled: false,
+  errorSchema: {},
+  idSchema: {},
+  uiSchema: {},
+};
+
+if (process.env.NODE_ENV !== "production") {
+  AnyOfField.propTypes = {
+    schema: PropTypes.object.isRequired,
+    uiSchema: PropTypes.object,
+    idSchema: PropTypes.object,
+    formData: PropTypes.any,
+    errorSchema: PropTypes.object,
+    registry: types.registry.isRequired,
+  };
+}
+
+export default AnyOfField;
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index a85bd6fa0..f60e5b86f 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -1,8 +1,8 @@
 import AddButton from "../AddButton";
 import IconButton from "../IconButton";
 import React, { Component } from "react";
-import PropTypes from "prop-types";
 import includes from "core-js/library/fn/array/includes";
+import * as types from "../../types";
 
 import UnsupportedField from "./UnsupportedField";
 import {
@@ -103,7 +103,7 @@ function DefaultArrayItem(props) {
 
 function DefaultFixedArrayFieldTemplate(props) {
   return (
-    <fieldset className={props.className}>
+    <fieldset className={props.className} id={props.idSchema.$id}>
       <ArrayFieldTitle
         key={`array-field-title-${props.idSchema.$id}`}
         TitleField={props.TitleField}
@@ -139,7 +139,7 @@ function DefaultFixedArrayFieldTemplate(props) {
 
 function DefaultNormalArrayFieldTemplate(props) {
   return (
-    <fieldset className={props.className}>
+    <fieldset className={props.className} id={props.idSchema.$id}>
       <ArrayFieldTitle
         key={`array-field-title-${props.idSchema.$id}`}
         TitleField={props.TitleField}
@@ -276,20 +276,19 @@ class ArrayField extends Component {
           }
         }
       }
-      onChange(
-        formData.map((item, i) => {
-          // i is string, index and newIndex are numbers,
-          // so using "==" to compare
-          if (i == newIndex) {
-            return formData[index];
-          } else if (i == index) {
-            return formData[newIndex];
-          } else {
-            return item;
-          }
-        }),
-        newErrorSchema
-      );
+
+      function reOrderArray() {
+        // Copy item
+        let newFormData = formData.slice();
+
+        // Moves item from index to newIndex
+        newFormData.splice(index, 1);
+        newFormData.splice(newIndex, 0, formData[index]);
+
+        return newFormData;
+      }
+
+      onChange(reOrderArray(), newErrorSchema);
     };
   };
 
@@ -661,34 +660,7 @@ class ArrayField extends Component {
 }
 
 if (process.env.NODE_ENV !== "production") {
-  ArrayField.propTypes = {
-    schema: PropTypes.object.isRequired,
-    uiSchema: PropTypes.shape({
-      "ui:options": PropTypes.shape({
-        addable: PropTypes.bool,
-        orderable: PropTypes.bool,
-        removable: PropTypes.bool,
-      }),
-    }),
-    idSchema: PropTypes.object,
-    errorSchema: PropTypes.object,
-    onChange: PropTypes.func.isRequired,
-    onBlur: PropTypes.func,
-    onFocus: PropTypes.func,
-    formData: PropTypes.array,
-    required: PropTypes.bool,
-    disabled: PropTypes.bool,
-    readonly: PropTypes.bool,
-    autofocus: PropTypes.bool,
-    registry: PropTypes.shape({
-      widgets: PropTypes.objectOf(
-        PropTypes.oneOfType([PropTypes.func, PropTypes.object])
-      ).isRequired,
-      fields: PropTypes.objectOf(PropTypes.func).isRequired,
-      definitions: PropTypes.object.isRequired,
-      formContext: PropTypes.object.isRequired,
-    }),
-  };
+  ArrayField.propTypes = types.fieldProps;
 }
 
 export default ArrayField;
diff --git a/src/components/fields/BooleanField.js b/src/components/fields/BooleanField.js
index 00014f28c..7677a528f 100644
--- a/src/components/fields/BooleanField.js
+++ b/src/components/fields/BooleanField.js
@@ -1,5 +1,5 @@
 import React from "react";
-import PropTypes from "prop-types";
+import * as types from "../../types";
 
 import {
   getWidget,
@@ -55,28 +55,7 @@ function BooleanField(props) {
 }
 
 if (process.env.NODE_ENV !== "production") {
-  BooleanField.propTypes = {
-    schema: PropTypes.object.isRequired,
-    uiSchema: PropTypes.object,
-    idSchema: PropTypes.object,
-    onChange: PropTypes.func.isRequired,
-    onFocus: PropTypes.func,
-    onBlur: PropTypes.func,
-    formData: PropTypes.bool,
-    required: PropTypes.bool,
-    disabled: PropTypes.bool,
-    readonly: PropTypes.bool,
-    autofocus: PropTypes.bool,
-    registry: PropTypes.shape({
-      widgets: PropTypes.objectOf(
-        PropTypes.oneOfType([PropTypes.func, PropTypes.object])
-      ).isRequired,
-      fields: PropTypes.objectOf(PropTypes.func).isRequired,
-      definitions: PropTypes.object.isRequired,
-      formContext: PropTypes.object.isRequired,
-    }),
-    rawErrors: PropTypes.arrayOf(PropTypes.string),
-  };
+  BooleanField.propTypes = types.fieldProps;
 }
 
 BooleanField.defaultProps = {
diff --git a/src/components/fields/NumberField.js b/src/components/fields/NumberField.js
index 30c297b19..1f7f7c112 100644
--- a/src/components/fields/NumberField.js
+++ b/src/components/fields/NumberField.js
@@ -1,6 +1,6 @@
 import React from "react";
-import PropTypes from "prop-types";
 
+import * as types from "../../types";
 import { asNumber } from "../../utils";
 
 function NumberField(props) {
@@ -14,15 +14,7 @@ function NumberField(props) {
 }
 
 if (process.env.NODE_ENV !== "production") {
-  NumberField.propTypes = {
-    schema: PropTypes.object.isRequired,
-    uiSchema: PropTypes.object,
-    idSchema: PropTypes.object,
-    onChange: PropTypes.func.isRequired,
-    formData: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
-    required: PropTypes.bool,
-    formContext: PropTypes.object.isRequired,
-  };
+  NumberField.propTypes = types.fieldProps;
 }
 
 NumberField.defaultProps = {
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index 982aa3aaf..9eb1732d8 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -1,6 +1,6 @@
 import AddButton from "../AddButton";
 import React, { Component } from "react";
-import PropTypes from "prop-types";
+import * as types from "../../types";
 
 import {
   orderProperties,
@@ -30,7 +30,7 @@ function DefaultObjectFieldTemplate(props) {
 
   const { TitleField, DescriptionField } = props;
   return (
-    <fieldset>
+    <fieldset id={props.idSchema.$id}>
       {(props.uiSchema["ui:title"] || props.title) && (
         <TitleField
           id={`${props.idSchema.$id}__title`}
@@ -82,22 +82,17 @@ class ObjectField extends Component {
 
   onPropertyChange = (name, addedByAdditionalProperties = false) => {
     return (value, errorSchema) => {
-      let newFormData;
-      //section below sets zero value of input field to empty string
-      //instead of undefined, so that value input in additionalProperties
-      //doesn't disappear when emptied
       if (!value && addedByAdditionalProperties) {
-        newFormData = {
-          ...this.props.formData,
-          [name]: "",
-        };
-      } else {
-        newFormData = {
-          ...this.props.formData,
-          [name]: value,
-        };
+        // Don't set value = undefined for fields added by
+        // additionalProperties. Doing so removes them from the
+        // formData, which causes them to completely disappear
+        // (including the input field for the property name). Unlike
+        // fields which are "mandated" by the schema, these fields can
+        // be set to undefined by clicking a "delete field" button, so
+        // set empty values to the empty string.
+        value = "";
       }
-
+      const newFormData = { ...this.props.formData, [name]: value };
       this.props.onChange(
         newFormData,
         errorSchema &&
@@ -109,7 +104,7 @@ class ObjectField extends Component {
     };
   };
 
-  onDropIndexClick = key => {
+  onDropPropertyClick = key => {
     return event => {
       event.preventDefault();
       const { onChange, formData } = this.props;
@@ -203,7 +198,7 @@ class ObjectField extends Component {
     const description = uiSchema["ui:description"] || schema.description;
     let orderedProperties;
     try {
-      const properties = Object.keys(schema.properties);
+      const properties = Object.keys(schema.properties || {});
       orderedProperties = orderProperties(properties, uiSchema["ui:order"]);
     } catch (err) {
       return (
@@ -250,7 +245,7 @@ class ObjectField extends Component {
               registry={registry}
               disabled={disabled}
               readonly={readonly}
-              onDropIndexClick={this.onDropIndexClick}
+              onDropPropertyClick={this.onDropPropertyClick}
             />
           ),
           name,
@@ -259,6 +254,8 @@ class ObjectField extends Component {
           required,
         };
       }),
+      readonly,
+      disabled,
       required,
       idSchema,
       uiSchema,
@@ -271,25 +268,7 @@ class ObjectField extends Component {
 }
 
 if (process.env.NODE_ENV !== "production") {
-  ObjectField.propTypes = {
-    schema: PropTypes.object.isRequired,
-    uiSchema: PropTypes.object,
-    errorSchema: PropTypes.object,
-    idSchema: PropTypes.object,
-    onChange: PropTypes.func.isRequired,
-    formData: PropTypes.object,
-    required: PropTypes.bool,
-    disabled: PropTypes.bool,
-    readonly: PropTypes.bool,
-    registry: PropTypes.shape({
-      widgets: PropTypes.objectOf(
-        PropTypes.oneOfType([PropTypes.func, PropTypes.object])
-      ).isRequired,
-      fields: PropTypes.objectOf(PropTypes.func).isRequired,
-      definitions: PropTypes.object.isRequired,
-      formContext: PropTypes.object.isRequired,
-    }),
-  };
+  ObjectField.propTypes = types.fieldProps;
 }
 
 export default ObjectField;
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index 1d10983c0..f55e8a030 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -2,9 +2,11 @@ import { ADDITIONAL_PROPERTY_FLAG } from "../../utils";
 import IconButton from "../IconButton";
 import React from "react";
 import PropTypes from "prop-types";
+import * as types from "../../types";
 
 import {
   isMultiSelect,
+  isSelect,
   retrieveSchema,
   toIdSchema,
   getDefaultRegistry,
@@ -36,6 +38,13 @@ 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) {
+    return () => null;
+  }
+
   return componentName in fields
     ? fields[componentName]
     : () => {
@@ -121,10 +130,10 @@ function DefaultTemplate(props) {
     required,
     displayLabel,
     onKeyChange,
-    onDropIndexClick,
+    onDropPropertyClick,
   } = props;
   if (hidden) {
-    return children;
+    return <div className="hidden">{children}</div>;
   }
 
   const additional = props.schema.hasOwnProperty(ADDITIONAL_PROPERTY_FLAG);
@@ -164,7 +173,7 @@ function DefaultTemplate(props) {
               tabIndex="-1"
               style={{ border: "0" }}
               disabled={props.disabled || props.readonly}
-              onClick={onDropIndexClick(props.label)}
+              onClick={onDropPropertyClick(props.label)}
             />
           )}
         </div>
@@ -208,7 +217,7 @@ function SchemaFieldRender(props) {
     idPrefix,
     name,
     onKeyChange,
-    onDropIndexClick,
+    onDropPropertyClick,
     required,
     registry = getDefaultRegistry(),
   } = props;
@@ -308,7 +317,7 @@ function SchemaFieldRender(props) {
     label,
     hidden,
     onKeyChange,
-    onDropIndexClick,
+    onDropPropertyClick,
     required,
     disabled,
     readonly,
@@ -320,7 +329,35 @@ function SchemaFieldRender(props) {
     uiSchema,
   };
 
-  return <FieldTemplate {...fieldProps}>{field}</FieldTemplate>;
+  const _AnyOfField = registry.fields.AnyOfField;
+
+  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
+        rendering
+      */}
+      {schema.anyOf && !isSelect(schema) && (
+        <_AnyOfField
+          disabled={disabled}
+          errorSchema={errorSchema}
+          formData={formData}
+          idPrefix={idPrefix}
+          idSchema={idSchema}
+          onBlur={props.onBlur}
+          onChange={props.onChange}
+          onFocus={props.onFocus}
+          schema={schema}
+          registry={registry}
+          safeRenderCompletion={props.safeRenderCompletion}
+          uiSchema={uiSchema}
+        />
+      )}
+    </FieldTemplate>
+  );
 }
 
 class SchemaField extends React.Component {
@@ -354,17 +391,7 @@ if (process.env.NODE_ENV !== "production") {
     idSchema: PropTypes.object,
     formData: PropTypes.any,
     errorSchema: PropTypes.object,
-    registry: PropTypes.shape({
-      widgets: PropTypes.objectOf(
-        PropTypes.oneOfType([PropTypes.func, PropTypes.object])
-      ).isRequired,
-      fields: PropTypes.objectOf(PropTypes.func).isRequired,
-      definitions: PropTypes.object.isRequired,
-      ArrayFieldTemplate: PropTypes.func,
-      ObjectFieldTemplate: PropTypes.func,
-      FieldTemplate: PropTypes.func,
-      formContext: PropTypes.object.isRequired,
-    }),
+    registry: types.registry.isRequired,
   };
 }
 
diff --git a/src/components/fields/StringField.js b/src/components/fields/StringField.js
index 6ef3f3cbf..31dc31580 100644
--- a/src/components/fields/StringField.js
+++ b/src/components/fields/StringField.js
@@ -1,5 +1,5 @@
 import React from "react";
-import PropTypes from "prop-types";
+import * as types from "../../types";
 
 import {
   getWidget,
@@ -57,33 +57,7 @@ function StringField(props) {
 }
 
 if (process.env.NODE_ENV !== "production") {
-  StringField.propTypes = {
-    schema: PropTypes.object.isRequired,
-    uiSchema: PropTypes.object.isRequired,
-    idSchema: PropTypes.object,
-    onChange: PropTypes.func.isRequired,
-    onBlur: PropTypes.func,
-    onFocus: PropTypes.func,
-    formData: PropTypes.oneOfType([
-      PropTypes.string,
-      PropTypes.number,
-      PropTypes.bool,
-    ]),
-    registry: PropTypes.shape({
-      widgets: PropTypes.objectOf(
-        PropTypes.oneOfType([PropTypes.func, PropTypes.object])
-      ).isRequired,
-      fields: PropTypes.objectOf(PropTypes.func).isRequired,
-      definitions: PropTypes.object.isRequired,
-      formContext: PropTypes.object.isRequired,
-    }),
-    formContext: PropTypes.object.isRequired,
-    required: PropTypes.bool,
-    disabled: PropTypes.bool,
-    readonly: PropTypes.bool,
-    autofocus: PropTypes.bool,
-    rawErrors: PropTypes.arrayOf(PropTypes.string),
-  };
+  StringField.propTypes = types.fieldProps;
 }
 
 StringField.defaultProps = {
diff --git a/src/components/fields/index.js b/src/components/fields/index.js
index c8e92e5dd..9337e209f 100644
--- a/src/components/fields/index.js
+++ b/src/components/fields/index.js
@@ -1,3 +1,4 @@
+import AnyOfField from "./AnyOfField";
 import ArrayField from "./ArrayField";
 import BooleanField from "./BooleanField";
 import DescriptionField from "./DescriptionField";
@@ -9,6 +10,7 @@ import TitleField from "./TitleField";
 import UnsupportedField from "./UnsupportedField";
 
 export default {
+  AnyOfField,
   ArrayField,
   BooleanField,
   DescriptionField,
diff --git a/src/types.js b/src/types.js
new file mode 100644
index 000000000..4d536df70
--- /dev/null
+++ b/src/types.js
@@ -0,0 +1,36 @@
+import PropTypes from "prop-types";
+
+export const registry = PropTypes.shape({
+  ArrayFieldTemplate: PropTypes.func,
+  FieldTemplate: PropTypes.func,
+  ObjectFieldTemplate: PropTypes.func,
+  definitions: PropTypes.object.isRequired,
+  fields: PropTypes.objectOf(PropTypes.func).isRequired,
+  formContext: PropTypes.object.isRequired,
+  widgets: PropTypes.objectOf(
+    PropTypes.oneOfType([PropTypes.func, PropTypes.object])
+  ).isRequired,
+});
+
+export const fieldProps = {
+  autofocus: PropTypes.bool,
+  disabled: PropTypes.bool,
+  errorSchema: PropTypes.object,
+  formData: PropTypes.any,
+  idSchema: PropTypes.object,
+  onBlur: PropTypes.func,
+  onChange: PropTypes.func.isRequired,
+  onFocus: PropTypes.func,
+  rawErrors: PropTypes.arrayOf(PropTypes.string),
+  readonly: PropTypes.bool,
+  registry: registry.isRequired,
+  required: PropTypes.bool,
+  schema: PropTypes.object.isRequired,
+  uiSchema: PropTypes.shape({
+    "ui:options": PropTypes.shape({
+      addable: PropTypes.bool,
+      orderable: PropTypes.bool,
+      removable: PropTypes.bool,
+    }),
+  }),
+};
diff --git a/src/validate.js b/src/validate.js
index 3c8273e7a..222b16328 100644
--- a/src/validate.js
+++ b/src/validate.js
@@ -162,6 +162,8 @@ export default function validateFormData(
   }
 
   let errors = transformAjvErrors(ajv.errors);
+  // Clear errors to prevent persistent errors, see #1104
+  ajv.errors = null;
 
   if (typeof transformErrors === "function") {
     errors = transformErrors(errors);
@@ -182,3 +184,16 @@ export default function validateFormData(
 
   return { errors: newErrors, errorSchema: newErrorSchema };
 }
+
+/**
+ * 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) {
+  try {
+    return ajv.validate(schema, data);
+  } catch (e) {
+    return false;
+  }
+}


Test patch:
diff --git a/test/ArrayField_test.js b/test/ArrayField_test.js
index 40e7f90b3..299b67090 100644
--- a/test/ArrayField_test.js
+++ b/test/ArrayField_test.js
@@ -41,7 +41,9 @@ describe("ArrayField", () => {
     it("should render a fieldset", () => {
       const { node } = createFormComponent({ schema });
 
-      expect(node.querySelectorAll("fieldset")).to.have.length.of(1);
+      const fieldset = node.querySelectorAll("fieldset");
+      expect(fieldset).to.have.length.of(1);
+      expect(fieldset[0].id).eql("root");
     });
 
     it("should render a fieldset legend", () => {
@@ -270,6 +272,50 @@ describe("ArrayField", () => {
       expect(inputs[2].value).eql("bar");
     });
 
+    it("should move from first to last in the list", () => {
+      function moveAnywhereArrayItemTemplate(props) {
+        const buttons = [];
+        for (let i = 0; i < 3; i++) {
+          buttons.push(
+            <button
+              key={i}
+              className={"array-item-move-to-" + i}
+              onClick={props.onReorderClick(props.index, i)}>
+              {"Move item to index " + i}
+            </button>
+          );
+        }
+        return (
+          <div key={props.index} className={"item-" + props.index}>
+            {props.children}
+            {buttons}
+          </div>
+        );
+      }
+
+      function moveAnywhereArrayFieldTemplate(props) {
+        return (
+          <div className="array">
+            {props.items.map(moveAnywhereArrayItemTemplate)}
+          </div>
+        );
+      }
+
+      const { node } = createFormComponent({
+        schema,
+        formData: ["foo", "bar", "baz"],
+        ArrayFieldTemplate: moveAnywhereArrayFieldTemplate,
+      });
+
+      const button = node.querySelector(".item-0 .array-item-move-to-2");
+      Simulate.click(button);
+
+      const inputs = node.querySelectorAll(".field-string input[type=text]");
+      expect(inputs[0].value).eql("bar");
+      expect(inputs[1].value).eql("baz");
+      expect(inputs[2].value).eql("foo");
+    });
+
     it("should disable move buttons on the ends of the list", () => {
       const { node } = createFormComponent({
         schema,
diff --git a/test/Form_test.js b/test/Form_test.js
index 85a39d7f8..793e3a075 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -127,6 +127,7 @@ describe("Form", () => {
         ids.push(input.getAttribute("id"));
       }
       expect(ids).to.eql(["rjsf_count"]);
+      expect(node.querySelector("fieldset").id).to.eql("rjsf");
     });
   });
 
@@ -151,6 +152,7 @@ describe("Form", () => {
         ids.push(input.getAttribute("id"));
       }
       expect(ids).to.eql(["rjsf_count"]);
+      expect(node.querySelector("fieldset").id).to.eql("rjsf");
     });
 
     it("should work with oneOf", function() {
diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js
index b546ba55b..2e115813d 100644
--- a/test/ObjectField_test.js
+++ b/test/ObjectField_test.js
@@ -40,7 +40,9 @@ describe("ObjectField", () => {
     it("should render a fieldset", () => {
       const { node } = createFormComponent({ schema });
 
-      expect(node.querySelectorAll("fieldset")).to.have.length.of(1);
+      const fieldset = node.querySelectorAll("fieldset");
+      expect(fieldset).to.have.length.of(1);
+      expect(fieldset[0].id).eql("root");
     });
 
     it("should render a fieldset legend", () => {
diff --git a/test/anyOf_test.js b/test/anyOf_test.js
new file mode 100644
index 000000000..ed5e67475
--- /dev/null
+++ b/test/anyOf_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("anyOf", () => {
+  let sandbox;
+
+  beforeEach(() => {
+    sandbox = createSandbox();
+  });
+
+  afterEach(() => {
+    sandbox.restore();
+  });
+
+  it("should not render a select element if the anyOf 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 anyOf keyword is present", () => {
+    const schema = {
+      type: "object",
+      anyOf: [
+        {
+          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",
+      anyOf: [
+        {
+          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",
+      anyOf: [
+        {
+          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" },
+      },
+      anyOf: [
+        {
+          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: {
+          anyOf: [
+            {
+              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: {
+          anyOf: [
+            {
+              type: "number",
+            },
+            {
+              type: "string",
+            },
+          ],
+        },
+      },
+    };
+
+    const CustomField = () => {
+      return <div id="custom-anyof-field" />;
+    };
+
+    const { node } = createFormComponent({
+      schema,
+      fields: {
+        AnyOfField: CustomField,
+      },
+    });
+
+    expect(node.querySelectorAll("#custom-anyof-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: {
+          anyOf: [
+            {
+              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: {
+          anyOf: [
+            {
+              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/validate_test.js b/test/validate_test.js
index 0b352b74b..cb437e894 100644
--- a/test/validate_test.js
+++ b/test/validate_test.js
@@ -3,10 +3,40 @@ import { expect } from "chai";
 import sinon from "sinon";
 import { Simulate } from "react-addons-test-utils";
 
-import validateFormData, { toErrorList } from "../src/validate";
+import validateFormData, { isValid, toErrorList } from "../src/validate";
 import { createFormComponent } from "./test_utils";
 
 describe("Validation", () => {
+  describe("validate.isValid()", () => {
+    it("should return true if the data is valid against the schema", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          foo: { type: "string" },
+        },
+      };
+
+      expect(isValid(schema, { foo: "bar" })).to.be.true;
+    });
+
+    it("should return false if the data is not valid against the schema", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          foo: { type: "string" },
+        },
+      };
+
+      expect(isValid(schema, { foo: 12345 })).to.be.false;
+    });
+
+    it("should return false if the schema is invalid", () => {
+      const schema = "foobarbaz";
+
+      expect(isValid(schema, { foo: "bar" })).to.be.false;
+    });
+  });
+
   describe("validate.validateFormData()", () => {
     describe("No custom validate function", () => {
       const illFormedKey = "bar.'\"[]()=+*&^%$#@!";