Repository: mozilla-services/react-jsonschema-form
Pull request #1021 (2018-09-03 02:37:46)

Add preliminary support for object "additionalProperties"
### Reasons for making this change

This will add the ability for the form to understand "additionalProperties" definitions in the schema (see [json-schema properties documentation](https://json-schema.org/understanding-json-schema/reference/object.html#properties)).

Related issue: https://github.com/mozilla-services/react-jsonschema-form/issues/228

### Checklist

* [x] **I'm updating documentation**
  - [x] I've checked the rendering of the Markdown text I've added
  - [x] 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

### Details

Specifically, this will enable the following behaviors when additionalProperties is enabled:
* If the form data contains properties that are not explicitly defined, those fields will be automatically added to the form
* An "expand" button will be [optionally] added that allows the form user to manually add new properties.
* In any case in which an additional property has been added, a new text field will be rendered to allow the user to rename the key

Opportunities for improvement:
* Ability to remove additional properties
* Somewhat better test coverage (particularly around type detection)

christianclent: Thanks for the great feedback @graingert!
mjasinski5: 🙏 
mjasinski5: @christianclent CI failed because of the timeout.. I think all you have to do is to rerun it ;) It would be a big loss if such a remarkable work couldnt be merged 👍 
derberg: @christianclent maybe close this and open new PR as this will trigger travis again https://stackoverflow.com/questions/17606874/trigger-a-travis-ci-rebuild-without-pushing-a-commit
otherwise I'm not sure if anyone will treat this PR seriously
christianclent: Thank you for the hot tip @derberg!

I've been using this branch in one of my personal projects for a few weeks now, and there are definitely areas I would like to improve.

- The meat of the change in is SchemaField (with some ancillary code in ObjectField), but I wonder if I should have contained the change to ObjectField only
- Because the new keys are not known ahead of time, it typically isn't feasible to apply uiSchemas to the additional properties.
- It doesn't handle ordering very well
- Because ids of underlying DOM elements incorporate object keys, it will annoyingly bork your tab order when changing an additional property's key.
- Maybe more?

Some of those may be feasible to fix in the next few weekends if they are deal-breakers for anybody.
pkosiec: Hi @christianclent,
Thank you very much for your changes! I think the improvements you've mentioned could be made in another pull request. We really need object support in `additionalProperties`!

@graingert Could you please take another look at this PR? Thanks!

Patch:
diff --git a/README.md b/README.md
index e2a7fbd1c..809cf9003 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,20 @@ const uiSchema = {
 };
 ```
 
+### Object item options
+
+#### `expandable` option
+
+If `additionalProperties` contains a schema object, an add button for new properies is shown by default. You can turn this off with the `expandable` option in `uiSchema`:
+
+```jsx
+const uiSchema = {
+  "ui:options":  {
+    expandable: false
+  }
+};
+```
+
 ### Array item options
 
 #### `orderable` option
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/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..a9b9651ff 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;
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", () => {