Repository: mozilla-services/react-jsonschema-form
Pull request #1005 (2018-08-16 20:52:15)

Fix idSchema issue with dependencies
### Reasons for making this change

This fixes an issue where an `idSchema` would not be generated for property fields of objects that are in a dependency. It also adds a test to validate the fix.

[This PR](https://github.com/mozilla-services/react-jsonschema-form/pull/950) actually fixes the same issue as a part of its changes ([here](https://github.com/mozilla-services/react-jsonschema-form/pull/950/files#diff-2b4ca49d4bb0a774c4d4c1672d7aa781R626)), but it looks like it's been sitting idle for the past month, and I'd like to get this fix in. My PR touches much less code, so I figure it should be easy to get this through.

The change in `UnsupportedField.js` is just because I ran the `cs-format` script. I suppose it was missed before, and I figured I'd commit it.

### 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
* [ ] **I'm adding a new feature**
  - [ ] I've updated the playground with an example use of the feature



Patch:
diff --git a/README.md b/README.md
index e2a7fbd1c..d1c21e276 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,8 @@ A [live playground](https://mozilla-services.github.io/react-jsonschema-form/) i
            - [Multiple files](#multiple-files)
            - [File widget input ref](#file-widget-input-ref)
      - [Object fields ordering](#object-fields-ordering)
+     - [Object item options](#object-item-options)
+        - [expandable option](#expandable-option)
      - [Array item options](#array-item-options)
         - [orderable option](#orderable-option)
         - [addable option](#addable-option)
@@ -484,6 +486,22 @@ const uiSchema = {
 };
 ```
 
+### Object item options
+
+#### `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.
+
+You can turn support for `additionalProperties` off with the `expandable` option in `uiSchema`:
+
+```jsx
+const uiSchema = {
+  "ui:options":  {
+    expandable: false
+  }
+};
+```
+
 ### Array item options
 
 #### `orderable` option
@@ -799,7 +817,7 @@ const uiSchema = {
 
 ### Placeholders
 
-You can add placeholder texst to an input by using the `ui:placeholder` uiSchema directive:
+You can add placeholder text to an input by using the `ui:placeholder` uiSchema directive:
 
 ```jsx
 const schema = {type: "string", format: "uri"};
diff --git a/package.json b/package.json
index 114a14918..d137463e6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "react-jsonschema-form",
-  "version": "1.0.4",
+  "version": "1.0.5",
   "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/samples/additionalProperties.js b/playground/samples/additionalProperties.js
new file mode 100644
index 000000000..6a28a138f
--- /dev/null
+++ b/playground/samples/additionalProperties.js
@@ -0,0 +1,32 @@
+module.exports = {
+  schema: {
+    title: "A customizable registration form",
+    description: "A simple form with additional properties example.",
+    type: "object",
+    required: ["firstName", "lastName"],
+    additionalProperties: {
+      type: "string",
+    },
+    properties: {
+      firstName: {
+        type: "string",
+        title: "First name",
+      },
+      lastName: {
+        type: "string",
+        title: "Last name",
+      },
+    },
+  },
+  uiSchema: {
+    firstName: {
+      "ui:autofocus": true,
+      "ui:emptyValue": "",
+    },
+  },
+  formData: {
+    firstName: "Chuck",
+    lastName: "Norris",
+    assKickCount: "infinity",
+  },
+};
diff --git a/playground/samples/index.js b/playground/samples/index.js
index 9d8806f8d..b496c0fb6 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -17,6 +17,7 @@ import customObject from "./customObject";
 import alternatives from "./alternatives";
 import propertyDependencies from "./propertyDependencies";
 import schemaDependencies from "./schemaDependencies";
+import additionalProperties from "./additionalProperties";
 
 export const samples = {
   Simple: simple,
@@ -38,4 +39,5 @@ export const samples = {
   Alternatives: alternatives,
   "Property dependencies": propertyDependencies,
   "Schema dependencies": schemaDependencies,
+  "Additional Properties": additionalProperties,
 };
diff --git a/playground/samples/simple.js b/playground/samples/simple.js
index f30e10430..223d93a06 100644
--- a/playground/samples/simple.js
+++ b/playground/samples/simple.js
@@ -8,6 +8,7 @@ module.exports = {
       firstName: {
         type: "string",
         title: "First name",
+        default: "Chuck",
       },
       lastName: {
         type: "string",
@@ -60,7 +61,6 @@ module.exports = {
     },
   },
   formData: {
-    firstName: "Chuck",
     lastName: "Norris",
     age: 75,
     bio: "Roundhouse kicking asses since 1940",
diff --git a/src/components/AddButton.js b/src/components/AddButton.js
new file mode 100644
index 000000000..b16c3475d
--- /dev/null
+++ b/src/components/AddButton.js
@@ -0,0 +1,19 @@
+import React from "react";
+import IconButton from "./IconButton";
+
+export default function AddButton({ className, onClick, disabled }) {
+  return (
+    <div className="row">
+      <p className={`col-xs-3 col-xs-offset-9 text-right ${className}`}>
+        <IconButton
+          type="info"
+          icon="plus"
+          className="btn-add col-xs-12"
+          tabIndex="0"
+          onClick={onClick}
+          disabled={disabled}
+        />
+      </p>
+    </div>
+  );
+}
diff --git a/src/components/Form.js b/src/components/Form.js
index 88d92f096..b233621b1 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -9,6 +9,7 @@ import {
   toIdSchema,
   setState,
   getDefaultRegistry,
+  deepEquals,
 } from "../utils";
 import validateFormData, { toErrorList } from "../validate";
 
@@ -25,10 +26,23 @@ export default class Form extends Component {
   constructor(props) {
     super(props);
     this.state = this.getStateFromProps(props);
+    if (
+      this.props.onChange &&
+      !deepEquals(this.state.formData, this.props.formData)
+    ) {
+      this.props.onChange(this.state);
+    }
   }
 
   componentWillReceiveProps(nextProps) {
-    this.setState(this.getStateFromProps(nextProps));
+    const nextState = this.getStateFromProps(nextProps);
+    this.setState(nextState);
+    if (
+      !deepEquals(nextState.formData, nextProps.formData) &&
+      this.props.onChange
+    ) {
+      this.props.onChange(nextState);
+    }
   }
 
   getStateFromProps(props) {
diff --git a/src/components/IconButton.js b/src/components/IconButton.js
new file mode 100644
index 000000000..d67bf0bd6
--- /dev/null
+++ b/src/components/IconButton.js
@@ -0,0 +1,13 @@
+import React from "react";
+
+export default function IconButton(props) {
+  const { type = "default", icon, className, ...otherProps } = props;
+  return (
+    <button
+      type="button"
+      className={`btn btn-${type} ${className}`}
+      {...otherProps}>
+      <i className={`glyphicon glyphicon-${icon}`} />
+    </button>
+  );
+}
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index 991345bb1..4985bf3f1 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -1,3 +1,5 @@
+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";
@@ -35,18 +37,6 @@ function ArrayFieldDescription({ DescriptionField, idSchema, description }) {
   return <DescriptionField id={id} description={description} />;
 }
 
-function IconBtn(props) {
-  const { type = "default", icon, className, ...otherProps } = props;
-  return (
-    <button
-      type="button"
-      className={`btn btn-${type} ${className}`}
-      {...otherProps}>
-      <i className={`glyphicon glyphicon-${icon}`} />
-    </button>
-  );
-}
-
 // Used in the two templates
 function DefaultArrayItem(props) {
   const btnStyle = {
@@ -70,7 +60,7 @@ function DefaultArrayItem(props) {
               justifyContent: "space-around",
             }}>
             {(props.hasMoveUp || props.hasMoveDown) && (
-              <IconBtn
+              <IconButton
                 icon="arrow-up"
                 className="array-item-move-up"
                 tabIndex="-1"
@@ -81,7 +71,7 @@ function DefaultArrayItem(props) {
             )}
 
             {(props.hasMoveUp || props.hasMoveDown) && (
-              <IconBtn
+              <IconButton
                 icon="arrow-down"
                 className="array-item-move-down"
                 tabIndex="-1"
@@ -94,7 +84,7 @@ function DefaultArrayItem(props) {
             )}
 
             {props.hasRemove && (
-              <IconBtn
+              <IconButton
                 type="danger"
                 icon="remove"
                 className="array-item-remove"
@@ -138,6 +128,7 @@ function DefaultFixedArrayFieldTemplate(props) {
 
       {props.canAdd && (
         <AddButton
+          className="array-item-add"
           onClick={props.onAddClick}
           disabled={props.disabled || props.readonly}
         />
@@ -176,6 +167,7 @@ function DefaultNormalArrayFieldTemplate(props) {
 
       {props.canAdd && (
         <AddButton
+          className="array-item-add"
           onClick={props.onAddClick}
           disabled={props.disabled || props.readonly}
         />
@@ -668,23 +660,6 @@ class ArrayField extends Component {
   }
 }
 
-function AddButton({ onClick, disabled }) {
-  return (
-    <div className="row">
-      <p className="col-xs-3 col-xs-offset-9 array-item-add text-right">
-        <IconBtn
-          type="info"
-          icon="plus"
-          className="btn-add col-xs-12"
-          tabIndex="0"
-          onClick={onClick}
-          disabled={disabled}
-        />
-      </p>
-    </div>
-  );
-}
-
 if (process.env.NODE_ENV !== "production") {
   ArrayField.propTypes = {
     schema: PropTypes.object.isRequired,
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index b6908ae00..c4ab7cf80 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -1,3 +1,4 @@
+import AddButton from "../AddButton";
 import React, { Component } from "react";
 import PropTypes from "prop-types";
 
@@ -5,9 +6,27 @@ import {
   orderProperties,
   retrieveSchema,
   getDefaultRegistry,
+  getUiOptions,
 } from "../../utils";
 
 function DefaultObjectFieldTemplate(props) {
+  const canExpand = function canExpand() {
+    const { formData, schema, uiSchema } = props;
+    if (!schema.additionalProperties) {
+      return false;
+    }
+    const { expandable } = getUiOptions(uiSchema);
+    if (expandable === false) {
+      return expandable;
+    }
+    // if ui:options.expandable was not explicitly set to false, we can add
+    // another property if we have not exceeded maxProperties yet
+    if (schema.maxProperties !== undefined) {
+      return Object.keys(formData).length < schema.maxProperties;
+    }
+    return true;
+  };
+
   const { TitleField, DescriptionField } = props;
   return (
     <fieldset>
@@ -27,6 +46,13 @@ function DefaultObjectFieldTemplate(props) {
         />
       )}
       {props.properties.map(prop => prop.content)}
+      {canExpand() && (
+        <AddButton
+          className="object-property-expand"
+          onClick={props.onAddClick(props.schema)}
+          disabled={props.disabled || props.readonly}
+        />
+      )}
     </fieldset>
   );
 }
@@ -42,6 +68,10 @@ class ObjectField extends Component {
     readonly: false,
   };
 
+  state = {
+    additionalProperties: {},
+  };
+
   isRequired(name) {
     const schema = this.props.schema;
     return (
@@ -63,6 +93,62 @@ class ObjectField extends Component {
     };
   };
 
+  getAvailableKey = (preferredKey, formData) => {
+    var index = 0;
+    var newKey = preferredKey;
+    while (this.props.formData.hasOwnProperty(newKey)) {
+      newKey = `${preferredKey}-${++index}`;
+    }
+    return newKey;
+  };
+
+  onKeyChange = oldValue => {
+    return (value, errorSchema) => {
+      value = this.getAvailableKey(value, this.props.formData);
+      const newFormData = { ...this.props.formData };
+      const property = newFormData[oldValue];
+      delete newFormData[oldValue];
+      newFormData[value] = property;
+      this.props.onChange(
+        newFormData,
+        errorSchema &&
+          this.props.errorSchema && {
+            ...this.props.errorSchema,
+            [value]: errorSchema,
+          }
+      );
+    };
+  };
+
+  getDefaultValue(type) {
+    switch (type) {
+      case "string":
+        return "New Value";
+      case "array":
+        return [];
+      case "boolean":
+        return false;
+      case "null":
+        return null;
+      case "number":
+        return 0;
+      case "object":
+        return {};
+      default:
+        // We don't have a datatype for some reason (perhaps additionalProperties was true)
+        return "New Value";
+    }
+  }
+
+  handleAddClick = schema => () => {
+    const type = schema.additionalProperties.type;
+    const newFormData = { ...this.props.formData };
+    newFormData[
+      this.getAvailableKey("newKey", newFormData)
+    ] = this.getDefaultValue(type);
+    this.props.onChange(newFormData);
+  };
+
   render() {
     const {
       uiSchema,
@@ -120,6 +206,7 @@ class ObjectField extends Component {
               idSchema={idSchema[name]}
               idPrefix={idPrefix}
               formData={formData[name]}
+              onKeyChange={this.onKeyChange(name)}
               onChange={this.onPropertyChange(name)}
               onBlur={onBlur}
               onFocus={onFocus}
@@ -141,7 +228,7 @@ class ObjectField extends Component {
       formData,
       formContext,
     };
-    return <Template {...templateProps} />;
+    return <Template {...templateProps} onAddClick={this.handleAddClick} />;
   }
 }
 
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index ecbacdf0b..fd7ff9a79 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -1,3 +1,4 @@
+import { ADDITIONAL_PROPERTY_FLAG } from "../../utils";
 import React from "react";
 import PropTypes from "prop-types";
 
@@ -61,6 +62,19 @@ function Label(props) {
   );
 }
 
+function LabelInput(props) {
+  const { id, label, onChange } = props;
+  return (
+    <input
+      className="form-control"
+      type="text"
+      id={id}
+      onBlur={event => onChange(event.target.value)}
+      defaultValue={label}
+    />
+  );
+}
+
 function Help(props) {
   const { help } = props;
   if (!help) {
@@ -106,13 +120,27 @@ function DefaultTemplate(props) {
     hidden,
     required,
     displayLabel,
+    onKeyChange,
   } = props;
   if (hidden) {
     return children;
   }
+  const additional = props.schema.hasOwnProperty(ADDITIONAL_PROPERTY_FLAG);
+  const keyLabel = `${label} Key`;
 
   return (
     <div className={classNames}>
+      {additional && (
+        <div className="form-group">
+          <Label label={keyLabel} required={required} id={`${id}-key`} />
+          <LabelInput
+            label={label}
+            required={required}
+            id={`${id}-key`}
+            onChange={onKeyChange}
+          />
+        </div>
+      )}
       {displayLabel && <Label label={label} required={required} id={id} />}
       {displayLabel && description ? description : null}
       {children}
@@ -157,6 +185,7 @@ function SchemaFieldRender(props) {
     errorSchema,
     idPrefix,
     name,
+    onKeyChange,
     required,
     registry = getDefaultRegistry(),
   } = props;
@@ -255,6 +284,7 @@ function SchemaFieldRender(props) {
     id,
     label,
     hidden,
+    onKeyChange,
     required,
     disabled,
     readonly,
diff --git a/src/components/fields/UnsupportedField.js b/src/components/fields/UnsupportedField.js
index 0418955e4..33f875256 100644
--- a/src/components/fields/UnsupportedField.js
+++ b/src/components/fields/UnsupportedField.js
@@ -5,7 +5,8 @@ function UnsupportedField({ schema, idSchema, reason }) {
   return (
     <div className="unsupported-field">
       <p>
-        Unsupported field schema{idSchema &&
+        Unsupported field schema
+        {idSchema &&
           idSchema.$id && (
             <span>
               {" for"} field <code>{idSchema.$id}</code>
diff --git a/src/components/widgets/RadioWidget.js b/src/components/widgets/RadioWidget.js
index a78235319..fbbb34334 100644
--- a/src/components/widgets/RadioWidget.js
+++ b/src/components/widgets/RadioWidget.js
@@ -13,14 +13,17 @@ function RadioWidget(props) {
   } = props;
   // Generating a unique field name to identify this set of radio buttons
   const name = Math.random().toString();
-  const { enumOptions, inline } = options;
+  const { enumOptions, enumDisabled, inline } = options;
   // checked={checked} has been moved above name={name}, As mentioned in #349;
   // this is a temporary fix for radio button rendering bug in React, facebook/react#7630.
   return (
     <div className="field-radio-group">
       {enumOptions.map((option, i) => {
         const checked = option.value === value;
-        const disabledCls = disabled || readonly ? "disabled" : "";
+        const itemDisabled =
+          enumDisabled && enumDisabled.indexOf(option.value) != -1;
+        const disabledCls =
+          disabled || itemDisabled || readonly ? "disabled" : "";
         const radio = (
           <span>
             <input
@@ -29,7 +32,7 @@ function RadioWidget(props) {
               name={name}
               required={required}
               value={option.value}
-              disabled={disabled || readonly}
+              disabled={disabled || itemDisabled || readonly}
               autoFocus={autofocus && i === 0}
               onChange={_ => onChange(option.value)}
             />
diff --git a/src/utils.js b/src/utils.js
index 8d00e3fc8..8aeae8b9a 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -2,6 +2,8 @@ import React from "react";
 import validateFormData from "./validate";
 import fill from "core-js/library/fn/array/fill";
 
+export const ADDITIONAL_PROPERTY_FLAG = "__additional_property";
+
 const widgetMap = {
   boolean: {
     checkbox: "CheckboxWidget",
@@ -402,7 +404,56 @@ function findSchemaDefinition($ref, definitions = {}) {
   throw new Error(`Could not find a definition for ${$ref}.`);
 }
 
-export function retrieveSchema(schema, definitions = {}, formData = {}) {
+// In the case where we have to implicitly create a schema, it is useful to know what type to use
+//  based on the data we are defining
+const guessType = function guessType(value) {
+  if (Array.isArray(value)) {
+    return "array";
+  } else if (typeof value === "string") {
+    return "string";
+  } else if (value == null) {
+    return "null";
+  } else if (typeof value === "boolean") {
+    return "boolean";
+  } else if (!isNaN(value)) {
+    return "number";
+  } else if (typeof value === "object") {
+    return "object";
+  }
+  // Default to string if we can't figure it out
+  return "string";
+};
+
+// This function will create new "properties" items for each key in our formData
+export function stubExistingAdditionalProperties(
+  schema,
+  definitions = {},
+  formData = {}
+) {
+  // Clone the schema so we don't ruin the consumer's original
+  schema = {
+    ...schema,
+    properties: { ...schema.properties },
+  };
+  Object.keys(formData).forEach(key => {
+    if (schema.properties.hasOwnProperty(key)) {
+      // No need to stub, our schema already has the property
+      return;
+    }
+    const additionalProperties = schema.additionalProperties.hasOwnProperty(
+      "type"
+    )
+      ? { ...schema.additionalProperties }
+      : { type: guessType(formData[key]) };
+    // The type of our new key should match the additionalProperties value;
+    schema.properties[key] = additionalProperties;
+    // Set our additional property flag so we know it was dynamically added
+    schema.properties[key][ADDITIONAL_PROPERTY_FLAG] = true;
+  });
+  return schema;
+}
+
+export function resolveSchema(schema, definitions = {}, formData = {}) {
   if (schema.hasOwnProperty("$ref")) {
     // Retrieve the referenced schema definition.
     const $refSchema = findSchemaDefinition(schema.$ref, definitions);
@@ -423,6 +474,21 @@ export function retrieveSchema(schema, definitions = {}, formData = {}) {
   }
 }
 
+export function retrieveSchema(schema, definitions = {}, formData = {}) {
+  const resolvedSchema = resolveSchema(schema, definitions, formData);
+  const hasAdditionalProperties =
+    resolvedSchema.hasOwnProperty("additionalProperties") &&
+    resolvedSchema.additionalProperties !== false;
+  if (hasAdditionalProperties) {
+    return stubExistingAdditionalProperties(
+      resolvedSchema,
+      definitions,
+      formData
+    );
+  }
+  return resolvedSchema;
+}
+
 function resolveDependencies(schema, definitions, formData) {
   // Drop the dependencies from the source schema.
   let { dependencies = {}, ...resolvedSchema } = schema;
@@ -628,7 +694,7 @@ export function toIdSchema(
   const idSchema = {
     $id: id || idPrefix,
   };
-  if ("$ref" in schema) {
+  if ("$ref" in schema || "dependencies" in schema) {
     const _schema = retrieveSchema(schema, definitions, formData);
     return toIdSchema(_schema, id, definitions, formData, idPrefix);
   }
diff --git a/src/validate.js b/src/validate.js
index 2c2c37353..3c8273e7a 100644
--- a/src/validate.js
+++ b/src/validate.js
@@ -3,6 +3,7 @@ import Ajv from "ajv";
 const ajv = new Ajv({
   errorDataPath: "property",
   allErrors: true,
+  multipleOfPrecision: 8,
 });
 // add custom formats
 ajv.addFormat(
diff --git a/webpack.config.dist.js b/webpack.config.dist.js
index 6ee503b56..cf416b692 100644
--- a/webpack.config.dist.js
+++ b/webpack.config.dist.js
@@ -33,6 +33,10 @@ module.exports = {
       {
         test: /\.js$/,
         loaders: ["babel"],
+        exclude: [
+          path.join(__dirname, "node_modules", "core-js"),
+          path.join(__dirname, "node_modules", "babel-runtime"),
+        ],
       },
       {
         test: /\.json$/,


Test patch:
diff --git a/test/Form_test.js b/test/Form_test.js
index 08a24e67c..d90de7aed 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -44,6 +44,64 @@ describe("Form", () => {
     });
   });
 
+  describe("on component creation", () => {
+    let comp;
+    let onChangeProp;
+    let formData;
+    let schema;
+
+    function createComponent() {
+      comp = renderIntoDocument(
+        <Form schema={schema} onChange={onChangeProp} formData={formData}>
+          <button type="submit">Submit</button>
+          <button type="submit">Another submit</button>
+        </Form>
+      );
+    }
+
+    beforeEach(() => {
+      onChangeProp = sinon.spy();
+      schema = {
+        type: "object",
+        title: "root object",
+        required: ["count"],
+        properties: {
+          count: {
+            type: "number",
+            default: 789,
+          },
+        },
+      };
+    });
+
+    describe("when props.formData does not equal the default values", () => {
+      beforeEach(() => {
+        formData = {
+          foo: 123,
+        };
+        createComponent();
+      });
+
+      it("should call props.onChange with current state", () => {
+        sinon.assert.calledOnce(onChangeProp);
+        sinon.assert.calledWith(onChangeProp, comp.state);
+      });
+    });
+
+    describe("when props.formData equals the default values", () => {
+      beforeEach(() => {
+        formData = {
+          count: 789,
+        };
+        createComponent();
+      });
+
+      it("should not call props.onChange", () => {
+        sinon.assert.notCalled(onChangeProp);
+      });
+    });
+  });
+
   describe("Option idPrefix", function() {
     it("should change the rendered ids", function() {
       const schema = {
@@ -767,6 +825,88 @@ describe("Form", () => {
     });
   });
 
+  describe("Schema and external formData updates", () => {
+    let comp;
+    let onChangeProp;
+
+    beforeEach(() => {
+      onChangeProp = sinon.spy();
+      const formProps = {
+        schema: {
+          type: "string",
+          default: "foobar",
+        },
+        formData: "some value",
+        onChange: onChangeProp,
+      };
+      comp = createFormComponent(formProps).comp;
+    });
+
+    describe("when the form data is set to null", () => {
+      beforeEach(() => comp.componentWillReceiveProps({ formData: null }));
+
+      it("should call onChange", () => {
+        sinon.assert.calledOnce(onChangeProp);
+        sinon.assert.calledWith(onChangeProp, comp.state);
+        expect(comp.state.formData).eql("foobar");
+      });
+    });
+
+    describe("when the schema default is changed but formData is not changed", () => {
+      const newSchema = {
+        type: "string",
+        default: "the new default",
+      };
+
+      beforeEach(() =>
+        comp.componentWillReceiveProps({
+          schema: newSchema,
+          formData: "some value",
+        }));
+
+      it("should not call onChange", () => {
+        sinon.assert.notCalled(onChangeProp);
+        expect(comp.state.formData).eql("some value");
+        expect(comp.state.schema).deep.eql(newSchema);
+      });
+    });
+
+    describe("when the schema default is changed and formData is changed", () => {
+      const newSchema = {
+        type: "string",
+        default: "the new default",
+      };
+
+      beforeEach(() =>
+        comp.componentWillReceiveProps({
+          schema: newSchema,
+          formData: "something else",
+        }));
+
+      it("should not call onChange", () => {
+        sinon.assert.notCalled(onChangeProp);
+        expect(comp.state.formData).eql("something else");
+        expect(comp.state.schema).deep.eql(newSchema);
+      });
+    });
+
+    describe("when the schema default is changed and formData is nulled", () => {
+      const newSchema = {
+        type: "string",
+        default: "the new default",
+      };
+
+      beforeEach(() =>
+        comp.componentWillReceiveProps({ schema: newSchema, formData: null }));
+
+      it("should call onChange", () => {
+        sinon.assert.calledOnce(onChangeProp);
+        sinon.assert.calledWith(onChangeProp, comp.state);
+        expect(comp.state.formData).eql("the new default");
+      });
+    });
+  });
+
   describe("External formData updates", () => {
     describe("root level", () => {
       const formProps = {
diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js
index 1099f3581..04dab222c 100644
--- a/test/ObjectField_test.js
+++ b/test/ObjectField_test.js
@@ -3,6 +3,7 @@ import { expect } from "chai";
 import { Simulate } from "react-addons-test-utils";
 
 import { createFormComponent, createSandbox } from "./test_utils";
+import validateFormData from "../src/validate";
 
 describe("ObjectField", () => {
   let sandbox;
@@ -381,4 +382,268 @@ describe("ObjectField", () => {
       expect(node.querySelector("#title-")).to.be.null;
     });
   });
+
+  describe("additionalProperties", () => {
+    const schema = {
+      type: "object",
+      additionalProperties: {
+        type: "string",
+      },
+    };
+
+    it("should automatically add a property field if in formData", () => {
+      const { node } = createFormComponent({
+        schema,
+        formData: { first: 1 },
+      });
+
+      expect(node.querySelectorAll(".field-string")).to.have.length.of(1);
+    });
+
+    it("should pass through non-schema properties and not throw validation errors if additionalProperties is undefined", () => {
+      const undefinedAPSchema = {
+        ...schema,
+        properties: { second: { type: "string" } },
+      };
+      delete undefinedAPSchema.additionalProperties;
+      const { comp } = createFormComponent({
+        schema: undefinedAPSchema,
+        formData: { nonschema: 1 },
+      });
+
+      expect(comp.state.formData.nonschema).eql(1);
+
+      const result = validateFormData(comp.state.formData, comp.state.schema);
+      expect(result.errors).eql([]);
+    });
+
+    it("should pass through non-schema properties but throw a validation error if additionalProperties is false", () => {
+      const { comp } = createFormComponent({
+        schema: {
+          ...schema,
+          additionalProperties: false,
+          properties: { second: { type: "string" } },
+        },
+        formData: { nonschema: 1 },
+      });
+
+      expect(comp.state.formData.nonschema).eql(1);
+
+      const result = validateFormData(comp.state.formData, comp.state.schema);
+      expect(result.errors[0].name).eql("additionalProperties");
+    });
+
+    it("should still obey properties if additionalProperties is defined", () => {
+      const { node } = createFormComponent({
+        schema: {
+          ...schema,
+          properties: {
+            definedProperty: {
+              type: "string",
+            },
+          },
+        },
+      });
+
+      expect(node.querySelectorAll(".field-string")).to.have.length.of(1);
+    });
+
+    it("should render a label for the additional property key", () => {
+      const { node } = createFormComponent({
+        schema,
+        formData: { first: 1 },
+      });
+
+      expect(node.querySelector("[for='root_first-key']").textContent).eql(
+        "first Key"
+      );
+    });
+
+    it("should render a label for the additional property key if additionalProperties is true", () => {
+      const { node } = createFormComponent({
+        schema: { ...schema, additionalProperties: true },
+        formData: { first: 1 },
+      });
+
+      expect(node.querySelector("[for='root_first-key']").textContent).eql(
+        "first Key"
+      );
+    });
+
+    it("should not render a label for the additional property key if additionalProperties is false", () => {
+      const { node } = createFormComponent({
+        schema: { ...schema, additionalProperties: false },
+        formData: { first: 1 },
+      });
+
+      expect(node.querySelector("[for='root_first-key']")).eql(null);
+    });
+
+    it("should render a text input for the additional property key", () => {
+      const { node } = createFormComponent({
+        schema,
+        formData: { first: 1 },
+      });
+
+      expect(node.querySelector("#root_first-key").value).eql("first");
+    });
+
+    it("should render a label for the additional property value", () => {
+      const { node } = createFormComponent({
+        schema,
+        formData: { first: 1 },
+      });
+
+      expect(node.querySelector("[for='root_first']").textContent).eql("first");
+    });
+
+    it("should render a text input for the additional property value", () => {
+      const { node } = createFormComponent({
+        schema,
+        formData: { first: 1 },
+      });
+
+      expect(node.querySelector("#root_first").value).eql("1");
+    });
+
+    it("should rename formData key if key input is renamed", () => {
+      const { comp, node } = createFormComponent({
+        schema,
+        formData: { first: 1 },
+      });
+
+      const textNode = node.querySelector("#root_first-key");
+      Simulate.blur(textNode, {
+        target: { value: "newFirst" },
+      });
+
+      expect(comp.state.formData.newFirst).eql(1);
+    });
+
+    it("should attach suffix to formData key if new key already exists when key input is renamed", () => {
+      const formData = {
+        first: 1,
+        second: 2,
+      };
+      const { comp, node } = createFormComponent({
+        schema,
+        formData,
+      });
+
+      const textNode = node.querySelector("#root_first-key");
+      Simulate.blur(textNode, {
+        target: { value: "second" },
+      });
+
+      expect(comp.state.formData["second-1"]).eql(1);
+    });
+
+    it("should continue incrementing suffix to formData key until that key name is unique after a key input collision", () => {
+      const formData = {
+        first: 1,
+        second: 2,
+        "second-1": 2,
+        "second-2": 2,
+        "second-3": 2,
+        "second-4": 2,
+        "second-5": 2,
+        "second-6": 2,
+      };
+      const { comp, node } = createFormComponent({
+        schema,
+        formData,
+      });
+
+      const textNode = node.querySelector("#root_first-key");
+      Simulate.blur(textNode, {
+        target: { value: "second" },
+      });
+
+      expect(comp.state.formData["second-7"]).eql(1);
+    });
+
+    it("should have an expand button", () => {
+      const { node } = createFormComponent({ schema });
+
+      expect(node.querySelector(".object-property-expand button")).not.eql(
+        null
+      );
+    });
+
+    it("should not have an expand button if expandable is false", () => {
+      const { node } = createFormComponent({
+        schema,
+        uiSchema: { "ui:options": { expandable: false } },
+      });
+
+      expect(node.querySelector(".object-property-expand button")).to.be.null;
+    });
+
+    it("should add a new property when clicking the expand button", () => {
+      const { comp, node } = createFormComponent({ schema });
+
+      Simulate.click(node.querySelector(".object-property-expand button"));
+
+      expect(comp.state.formData.newKey).eql("New Value");
+    });
+
+    it("should add a new property with suffix when clicking the expand button and 'newKey' already exists", () => {
+      const { comp, node } = createFormComponent({
+        schema,
+        formData: { newKey: 1 },
+      });
+
+      Simulate.click(node.querySelector(".object-property-expand button"));
+
+      expect(comp.state.formData["newKey-1"]).eql("New Value");
+    });
+
+    it("should not provide an expand button if length equals maxProperties", () => {
+      const { node } = createFormComponent({
+        schema: { maxProperties: 1, ...schema },
+        formData: { first: 1 },
+      });
+
+      expect(node.querySelector(".object-property-expand button")).to.be.null;
+    });
+
+    it("should provide an expand button if length is less than maxProperties", () => {
+      const { node } = createFormComponent({
+        schema: { maxProperties: 2, ...schema },
+        formData: { first: 1 },
+      });
+
+      expect(node.querySelector(".object-property-expand button")).not.eql(
+        null
+      );
+    });
+
+    it("should not provide an expand button if expandable is expliclty false regardless of maxProperties value", () => {
+      const { node } = createFormComponent({
+        schema: { maxProperties: 2, ...schema },
+        formData: { first: 1 },
+        uiSchema: {
+          "ui:options": {
+            expandable: false,
+          },
+        },
+      });
+
+      expect(node.querySelector(".object-property-expand button")).to.be.null;
+    });
+
+    it("should ignore expandable value if maxProperties constraint is not satisfied", () => {
+      const { node } = createFormComponent({
+        schema: { maxProperties: 1, ...schema },
+        formData: { first: 1 },
+        uiSchema: {
+          "ui:options": {
+            expandable: true,
+          },
+        },
+      });
+
+      expect(node.querySelector(".object-property-expand button")).to.be.null;
+    });
+  });
 });
diff --git a/test/uiSchema_test.js b/test/uiSchema_test.js
index 01848d32c..5db743131 100644
--- a/test/uiSchema_test.js
+++ b/test/uiSchema_test.js
@@ -2,6 +2,7 @@ import { expect } from "chai";
 import React from "react";
 import { Simulate } from "react-addons-test-utils";
 import SelectWidget from "../src/components/widgets/SelectWidget";
+import RadioWidget from "../src/components/widgets/RadioWidget";
 import { createFormComponent, createSandbox } from "./test_utils";
 
 describe("uiSchema", () => {
@@ -439,6 +440,38 @@ describe("uiSchema", () => {
         );
       });
     });
+
+    describe("enum fields disabled radio options", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          field: {
+            type: "string",
+            enum: ["foo", "bar"],
+          },
+        },
+      };
+      const uiSchema = {
+        field: {
+          "ui:widget": RadioWidget,
+          "ui:options": {
+            className: "custom",
+          },
+          "ui:enumDisabled": ["foo"],
+        },
+      };
+      it("should have atleast one radio option disabled", () => {
+        const { node } = createFormComponent({ schema, uiSchema });
+        const disabledOptionsLen = uiSchema.field["ui:enumDisabled"].length;
+        expect(node.querySelectorAll("input:disabled")).to.have.length.of(
+          disabledOptionsLen
+        );
+        expect(node.querySelectorAll("input:enabled")).to.have.length.of(
+          // Two options, one disabled, plus the placeholder
+          2 - disabledOptionsLen
+        );
+      });
+    });
   });
 
   describe("ui:help", () => {
diff --git a/test/utils_test.js b/test/utils_test.js
index 2ada07514..24a3bf19e 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -1054,6 +1054,31 @@ describe("utils", () => {
       });
     });
 
+    it("should return an idSchema for property dependencies", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          foo: { type: "string" },
+        },
+        dependencies: {
+          foo: {
+            properties: {
+              bar: { type: "string" },
+            },
+          },
+        },
+      };
+      const formData = {
+        foo: "test",
+      };
+
+      expect(toIdSchema(schema, undefined, schema.definitions, formData)).eql({
+        $id: "root",
+        foo: { $id: "root_foo" },
+        bar: { $id: "root_bar" },
+      });
+    });
+
     it("should handle idPrefix parameter", () => {
       const schema = {
         definitions: {
diff --git a/test/validate_test.js b/test/validate_test.js
index d7eb36191..0b352b74b 100644
--- a/test/validate_test.js
+++ b/test/validate_test.js
@@ -43,6 +43,31 @@ describe("Validation", () => {
       });
     });
 
+    describe("Validating multipleOf with a float", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          price: {
+            title: "Price per task ($)",
+            type: "number",
+            multipleOf: 0.01,
+            minimum: 0,
+          },
+        },
+      };
+
+      let errors;
+
+      beforeEach(() => {
+        const result = validateFormData({ price: 0.14 }, schema);
+        errors = result.errors;
+      });
+
+      it("should not return an error", () => {
+        expect(errors).to.have.length.of(0);
+      });
+    });
+
     describe("Custom validate function", () => {
       let errors, errorSchema;