Repository: mozilla-services/react-jsonschema-form
Pull request #379 (2016-11-08 09:46:18)

Pass uiSchema to the field template 
### Reasons for making this change

I need to do some heavy customization on the rendering of the fields. Passing the `uiSchema` object to the field template allows the users to extend the functionality of this library.

There was discussion about if `uiSchema` should be passed to the template field in #304. There were many good reasons to not pass the `uiSchema`, but it was also implied that it could be passed in the future if given reason. The template field is a functionality for flexible UI. `uiSchema` is a description for the UI, and therefore I find it reasonable to be able to allows users to extend their own functionality to the UI rendering. Use cases would be for example more fine grained control of rendering different types of titles/descriptions, help block, tooltips which could be described in `uiSchema`.

This allows users to implement dirty hacks and abuse, like @n1k0 suggested in #304, but on the other hand this might make the hackering more clean, when hackering must be done to implement a feature.

### 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**
  - [ ] I've added and/or updated tests
  - [x] I've updated docs if needed
* [ ] **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 ecc15ab47..6622f4ba6 100644
--- a/README.md
+++ b/README.md
@@ -550,11 +550,9 @@ By default, checkboxes are stacked but if you prefer them inline:
 
 ```js
 const uiSchema = {
-  "ui:widget": {
-    component: "checkboxes",
-    options: {
-      inline: true
-    }
+  "ui:widget": "checkboxes",
+  "ui:options": {
+    inline: true
   }
 };
 ```
@@ -696,6 +694,8 @@ The following props are passed to a custom field template component:
 - `readonly`: A boolean value stating if the field is read-only.
 - `displayLabel`: A boolean value stating if the label should be rendered or not. This is useful for nested fields in arrays where you don't want to clutter the UI.
 - `fields`: An array containing all Form's fields including your [custom fields](#custom-field-components) and the built-in fields.
+- `schema`: The schema object for this field.
+- `uiSchema`: The uiSchema object for this field.
 - `formContext`: The `formContext` object that you passed to Form.
 
 > Note: you can only define a single field template for a form. If you need many, it's probably time to look at [custom fields](#custom-field-components) instead.
@@ -785,9 +785,11 @@ render((
 
 This is useful if you expose the `uiSchema` as pure JSON, which can't carry functions.
 
+> Note: Until 0.40.0 it was possible to register a widget as object with shape `{ component: MyCustomWidget, options: {...} }`. This undocumented API has been removed. Instead, you can register a custom widget with a React `defaultProps` property. `defaultProps.options` can be an object containing your custom options.
+
 #### Custom widget options
 
-If you need to pass options to your custom widget, change your `ui:widget` value to be an object having the following structure:
+If you need to pass options to your custom widget, you can add a `ui:options` object containing those properties. If the widget has `defaultProps`, the options will be merged with the (optional) options object from `defaultProps`:
 
 ```jsx
 const schema = {
@@ -796,18 +798,24 @@ const schema = {
 
 function MyCustomWidget(props) {
   const {options} = props;
-  return <input style={{options.backgroundColor}} />;
+  const {color, backgroundColor} = options;
+  return <input style={{color, backgroundColor}} />;
 }
 
+MyCustomWidget.defaultProps = {
+  options: {
+    color: "red"
+  }
+};
+
 const uiSchema = {
-  "ui:widget": {
-    options: {
-      backgroundColor: "yellow",
-    },
-    component: MyCustomWidget
+  "ui:widget": MyCustomWidget,
+  "ui:options": {
+    backgroundColor: "yellow"
   }
 };
 
+// renders red on yellow input
 render((
   <Form schema={schema}
         uiSchema={uiSchema} />
@@ -816,6 +824,8 @@ render((
 
 > Note: This also applies to [registered custom components](#custom-component-registration).
 
+> Note: Since v0.41.0, the `ui:widget` object API, where a widget and options were specified with `"ui:widget": {component, options}` shape, is deprecated. It will be removed in a future release.
+
 ### Custom field components
 
 You can provide your own field components to a uiSchema for basically any json schema data type, by specifying a `ui:field` property.
diff --git a/playground/samples/numbers.js b/playground/samples/numbers.js
index 02a3d21b1..c67fd7c54 100644
--- a/playground/samples/numbers.js
+++ b/playground/samples/numbers.js
@@ -41,11 +41,9 @@ module.exports = {
       "ui:widget": "updown"
     },
     numberEnumRadio: {
-      "ui:widget": {
-        component: "radio",
-        options: {
-          inline: true
-        }
+      "ui:widget": "radio",
+      "ui:options": {
+        inline: true
       }
     },
     integerRange: {
diff --git a/playground/samples/widgets.js b/playground/samples/widgets.js
index 9482a9157..a91820598 100644
--- a/playground/samples/widgets.js
+++ b/playground/samples/widgets.js
@@ -111,39 +111,35 @@ module.exports = {
       "ui:readonly": true
     },
     widgetOptions: {
-      "ui:widget": {
-        component: ({value, onChange, options}) => {
-          const {backgroundColor} = options;
-          return (
-            <input className="form-control"
-              onChange={(event) => onChange(event.target.value)}
-              style={{backgroundColor}}
-              value={value} />
-          );
-        },
-        options: {
-          backgroundColor: "yellow",
-        }
+      "ui:widget": ({value, onChange, options}) => {
+        const {backgroundColor} = options;
+        return (
+          <input className="form-control"
+            onChange={(event) => onChange(event.target.value)}
+            style={{backgroundColor}}
+            value={value} />
+        );
+      },
+      "ui:options": {
+        backgroundColor: "yellow"
       }
     },
     selectWidgetOptions: {
-      "ui:widget": {
-        component: ({value, onChange, options}) => {
-          const {enumOptions, backgroundColor} = options;
-          return (
-            <select className="form-control"
-              style={{backgroundColor}}
-              value={value}
-              onChange={(event) => onChange(event.target.value)}>{
-              enumOptions.map(({label, value}, i) => {
-                return <option key={i} value={value}>{label}</option>;
-              })
-            }</select>
-          );
-        },
-        options: {
-          backgroundColor: "pink",
-        }
+      "ui:widget": ({value, onChange, options}) => {
+        const {enumOptions, backgroundColor} = options;
+        return (
+          <select className="form-control"
+            style={{backgroundColor}}
+            value={value}
+            onChange={(event) => onChange(event.target.value)}>{
+            enumOptions.map(({label, value}, i) => {
+              return <option key={i} value={value}>{label}</option>;
+            })
+          }</select>
+        );
+      },
+      "ui:options": {
+        backgroundColor: "pink"
       }
     },
   },
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index 2052218d0..1bb10fe0c 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -1,8 +1,9 @@
 import React, {Component, PropTypes} from "react";
 
 import {
-  getAlternativeWidget,
+  getWidget,
   getDefaultFormState,
+  getUiOptions,
   isMultiSelect,
   isFilesArray,
   isFixedItems,
@@ -178,10 +179,7 @@ class ArrayField extends Component {
     const {definitions, fields} = this.props.registry;
     const {TitleField, DescriptionField} = fields;
     const itemsSchema = retrieveSchema(schema.items, definitions);
-    const {addable} = {
-      addable: true,
-      ...uiSchema["ui:options"]
-    };
+    const {addable=true} = getUiOptions(uiSchema);
 
     return (
       <fieldset
@@ -226,17 +224,15 @@ class ArrayField extends Component {
     const {items} = this.state;
     const {widgets, definitions} = this.props.registry;
     const itemsSchema = retrieveSchema(schema.items, definitions);
-
-    const Widget = getAlternativeWidget(schema, uiSchema["ui:widget"] || "select", widgets);
+    const enumOptions = optionsList(itemsSchema);
+    const {widget="select", ...options} = {...getUiOptions(uiSchema), enumOptions};
+    const Widget = getWidget(schema, widget, widgets);
     return (
       <Widget
         id={idSchema && idSchema.$id}
         multiple
         onChange={this.onSelectChange}
-        options={{
-          ...Widget.defaultProps.options,
-          enumOptions: optionsList(itemsSchema),
-        }}
+        options={options}
         schema={schema}
         value={items}
         disabled={disabled}
@@ -246,12 +242,15 @@ class ArrayField extends Component {
   }
 
   renderFiles() {
-    const {schema, idSchema, name, disabled, readonly, autofocus} = this.props;
+    const {schema, uiSchema, idSchema, name, disabled, readonly, autofocus} = this.props;
     const title = schema.title || name;
     const {items} = this.state;
-    const {FileWidget} = this.props.registry.widgets;
+    const {widgets} = this.props.registry;
+    const {widget="files", ...options} = getUiOptions(uiSchema);
+    const Widget = getWidget(schema, widget, widgets);
     return (
-      <FileWidget
+      <Widget
+        options={options}
         id={idSchema && idSchema.$id}
         multiple
         onChange={this.onSelectChange}
@@ -284,10 +283,7 @@ class ArrayField extends Component {
       retrieveSchema(item, definitions));
     const additionalSchema = allowAdditionalItems(schema) ?
       retrieveSchema(schema.additionalItems, definitions) : null;
-    const {addable} = {
-      addable: true,
-      ...uiSchema["ui:options"]
-    };
+    const {addable=true} = getUiOptions(uiSchema);
     const canAdd = addable && additionalSchema;
 
     if (!items || items.length < itemSchemas.length) {
@@ -435,7 +431,9 @@ if (process.env.NODE_ENV !== "production") {
     schema: PropTypes.object.isRequired,
     uiSchema: PropTypes.shape({
       "ui:options": PropTypes.shape({
-        orderable: PropTypes.bool
+        addable: PropTypes.bool,
+        orderable: PropTypes.bool,
+        removable: PropTypes.bool
       })
     }),
     idSchema: PropTypes.object,
diff --git a/src/components/fields/BooleanField.js b/src/components/fields/BooleanField.js
index 1f6afb1a3..57ee2e82f 100644
--- a/src/components/fields/BooleanField.js
+++ b/src/components/fields/BooleanField.js
@@ -2,27 +2,12 @@ import React, {PropTypes} from "react";
 
 import {
   defaultFieldValue,
-  getAlternativeWidget,
+  getWidget,
+  getUiOptions,
   optionsList,
-  getDefaultRegistry,
-  isObject,
+  getDefaultRegistry
 } from "../../utils";
 
-
-function buildOptions(schema, uiWidget) {
-  // Note: uiWidget can be undefined, a string or an object; we only deal with
-  // the inline option when we're provided a definition object.
-  return {
-    inline: isObject(uiWidget) &&
-            isObject(uiWidget.options) &&
-            uiWidget.options.inline,
-    enumOptions: optionsList(Object.assign({
-      enumNames: ["true", "false"],
-      enum: [true, false]
-    }, {enumNames: schema.enumNames}))
-  };
-}
-
 function BooleanField(props) {
   const {
     schema,
@@ -39,26 +24,25 @@ function BooleanField(props) {
   } = props;
   const {title} = schema;
   const {widgets, formContext} = registry;
-  const widget = uiSchema["ui:widget"];
-  const commonProps = {
-    schema,
-    id: idSchema && idSchema.$id,
-    onChange,
-    label: (title === undefined) ? name : title,
-    value: defaultFieldValue(formData, schema),
-    required,
-    disabled,
-    readonly,
-    registry,
-    formContext,
-    autofocus,
-  };
-  if (widget) {
-    const Widget = getAlternativeWidget(schema, widget, widgets);
-    return <Widget options={buildOptions(schema, uiSchema["ui:widget"])} {...commonProps}/>;
-  }
-  const {CheckboxWidget} = registry.widgets;
-  return <CheckboxWidget {...commonProps}/>;
+  const {widget="checkbox", ...options} = getUiOptions(uiSchema);
+  const Widget = getWidget(schema, widget, widgets);
+  const enumOptions = optionsList({
+    enum: [true, false],
+    enumNames: schema.enumNames || ["yes", "no"]
+  });
+  return <Widget
+    options={{...options, enumOptions}}
+    schema={schema}
+    id={idSchema && idSchema.$id}
+    onChange={onChange}
+    label={title === undefined ? name : title}
+    value={defaultFieldValue(formData, schema)}
+    required={required}
+    disabled={disabled}
+    readonly={readonly}
+    registry={registry}
+    formContext={formContext}
+    autofocus={autofocus}/>;
 }
 
 if (process.env.NODE_ENV !== "production") {
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index 165e519dc..dae7a9208 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -157,12 +157,15 @@ function SchemaField(props) {
     displayLabel = false;
   }
 
+  const {__errors, ...fieldErrorSchema} = errorSchema;
+
   const field = (
     <FieldComponent {...props}
       schema={schema}
       disabled={disabled}
       readonly={readonly}
       autofocus={autofocus}
+      errorSchema={fieldErrorSchema}
       formContext={formContext}/>
   );
 
@@ -170,7 +173,7 @@ function SchemaField(props) {
   const id = idSchema.$id;
   const label = props.schema.title || schema.title || name;
   const description = props.schema.description || schema.description;
-  const errors = errorSchema.__errors;
+  const errors = __errors;
   const help = uiSchema["ui:help"];
   const hidden = uiSchema["ui:widget"] === "hidden";
   const classNames = [
@@ -199,6 +202,8 @@ function SchemaField(props) {
     classNames,
     formContext,
     fields,
+    schema,
+    uiSchema,
   };
 
   return <FieldTemplate {...fieldProps}>{field}</FieldTemplate>;
diff --git a/src/components/fields/StringField.js b/src/components/fields/StringField.js
index c777228dc..f1ed3eb0f 100644
--- a/src/components/fields/StringField.js
+++ b/src/components/fields/StringField.js
@@ -2,7 +2,8 @@ import React, {PropTypes} from "react";
 
 import {
   defaultFieldValue,
-  getAlternativeWidget,
+  getWidget,
+  getUiOptions,
   optionsList,
   getDefaultRegistry
 } from "../../utils";
@@ -15,46 +16,34 @@ function StringField(props) {
     uiSchema,
     idSchema,
     formData,
-    registry,
     required,
     disabled,
     readonly,
     autofocus,
+    registry,
     onChange
   } = props;
-  const {title} = schema;
+  const {title, format} = schema;
   const {widgets, formContext} = registry;
-  const widget = uiSchema["ui:widget"] || schema.format;
-  const placeholder = uiSchema["ui:placeholder"] || "";
-  const commonProps = {
-    schema,
-    id: idSchema && idSchema.$id,
-    label: (title === undefined) ? name : title,
-    value: defaultFieldValue(formData, schema),
-    onChange,
-    required,
-    disabled,
-    readonly,
-    formContext,
-    autofocus,
-    registry,
-  };
-
-  const {TextWidget, SelectWidget} = widgets;
+  const enumOptions = Array.isArray(schema.enum) && optionsList(schema);
+  const defaultWidget = format || (enumOptions ? "select" : "text");
+  const {widget=defaultWidget, placeholder="", ...options} = getUiOptions(uiSchema);
+  const Widget = getWidget(schema, widget, widgets);
 
-  if (Array.isArray(schema.enum)) {
-    const enumOptions = optionsList(schema);
-    if (widget) {
-      const Widget = getAlternativeWidget(schema, widget, widgets, {enumOptions});
-      return <Widget {...commonProps}/>;
-    }
-    return <SelectWidget options={{enumOptions}} {...commonProps}/>;
-  }
-  if (widget) {
-    const Widget = getAlternativeWidget(schema, widget, widgets);
-    return <Widget {...commonProps} placeholder={placeholder}/>;
-  }
-  return <TextWidget {...commonProps} placeholder={placeholder}/>;
+  return <Widget
+    options={{...options, enumOptions}}
+    schema={schema}
+    id={idSchema && idSchema.$id}
+    label={title === undefined ? name : title}
+    value={defaultFieldValue(formData, schema)}
+    onChange={onChange}
+    required={required}
+    disabled={disabled}
+    readonly={readonly}
+    formContext={formContext}
+    autofocus={autofocus}
+    registry={registry}
+    placeholder={placeholder}/>;
 }
 
 if (process.env.NODE_ENV !== "production") {
diff --git a/src/utils.js b/src/utils.js
index 9973f68d6..2b0bfa29f 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,32 +1,47 @@
+import React from "react";
+
 import "setimmediate";
 
 
-const altWidgetMap = {
+const widgetMap = {
   boolean: {
+    checkbox: "CheckboxWidget",
     radio: "RadioWidget",
     select: "SelectWidget",
     hidden: "HiddenWidget",
   },
   string: {
+    text: "TextWidget",
     password: "PasswordWidget",
+    email: "EmailWidget",
+    hostname: "TextWidget",
+    ipv4: "TextWidget",
+    ipv6: "TextWidget",
+    uri: "URLWidget",
+    "data-url": "FileWidget",
     radio: "RadioWidget",
     select: "SelectWidget",
     textarea: "TextareaWidget",
     hidden: "HiddenWidget",
     date: "DateWidget",
     datetime: "DateTimeWidget",
+    "date-time": "DateTimeWidget",
     "alt-date": "AltDateWidget",
     "alt-datetime": "AltDateTimeWidget",
     color: "ColorWidget",
     file: "FileWidget",
   },
   number: {
+    text: "TextWidget",
+    select: "SelectWidget",
     updown: "UpDownWidget",
     range: "RangeWidget",
     radio: "RadioWidget",
     hidden: "HiddenWidget",
   },
   integer: {
+    text: "TextWidget",
+    select: "SelectWidget",
     updown: "UpDownWidget",
     range: "RangeWidget",
     radio: "RadioWidget",
@@ -35,20 +50,10 @@ const altWidgetMap = {
   array: {
     select: "SelectWidget",
     checkboxes: "CheckboxesWidget",
+    files: "FileWidget"
   }
 };
 
-const stringFormatWidgets = {
-  "date-time": "DateTimeWidget",
-  "date": "DateWidget",
-  "email": "EmailWidget",
-  "hostname": "TextWidget",
-  "ipv4": "TextWidget",
-  "ipv6": "TextWidget",
-  "uri": "URLWidget",
-  "data-url": "FileWidget",
-};
-
 export function getDefaultRegistry() {
   const load = (prefix, arr) => arr.reduce((obj, comp) => {
     obj[comp] = require(`./components/${prefix}/${comp}`).default;
@@ -99,34 +104,21 @@ export function defaultFieldValue(formData, schema) {
   return typeof formData === "undefined" ? schema.default : formData;
 }
 
-export function getAlternativeWidget(
-  schema,
-  widget,
-  registeredWidgets={},
-  widgetOptions={}
-) {
-  const {type, format} = schema;
-
-  function setDefaultOptions(widget) {
-    const {defaultProps={}} = widget;
-    widget.defaultProps = {
-      ...defaultProps,
-      options: {
-        ...defaultProps.options,
-        ...widgetOptions
-      }
-    };
-    return widget;
-  }
+export function getWidget(schema, widget, registeredWidgets={}) {
+  const {type} = schema;
 
-  if (typeof widget === "function") {
-    return setDefaultOptions(widget);
+  function mergeOptions(Widget) {
+    // cache return value as property of widget for proper react reconciliation
+    if (!Widget.MergedWidget) {
+      const defaultOptions = Widget.defaultProps && Widget.defaultProps.options || {};
+      Widget.MergedWidget = ({options={}, ...props}) =>
+        <Widget options={{...defaultOptions, ...options}} {...props}/>;
+    }
+    return Widget.MergedWidget;
   }
 
-  if (isObject(widget)) {
-    const {component, options} = widget;
-    const mergedOptions = {...options, ...widgetOptions};
-    return getAlternativeWidget(schema, component, registeredWidgets, mergedOptions);
+  if (typeof widget === "function") {
+    return mergeOptions(widget);
   }
 
   if (typeof widget !== "string") {
@@ -135,25 +127,19 @@ export function getAlternativeWidget(
 
   if (registeredWidgets.hasOwnProperty(widget)) {
     const registeredWidget = registeredWidgets[widget];
-    return getAlternativeWidget(schema, registeredWidget, registeredWidgets, widgetOptions);
-  }
-
-  if (!altWidgetMap.hasOwnProperty(type)) {
-    throw new Error(`No alternative widget for type ${type}`);
+    return getWidget(schema, registeredWidget, registeredWidgets);
   }
 
-  if (altWidgetMap[type].hasOwnProperty(widget)) {
-    const altWidget = registeredWidgets[altWidgetMap[type][widget]];
-    return getAlternativeWidget(schema, altWidget, registeredWidgets, widgetOptions);
+  if (!widgetMap.hasOwnProperty(type)) {
+    throw new Error(`No widget for type "${type}"`);
   }
 
-  if (type === "string" && stringFormatWidgets.hasOwnProperty(format)) {
-    const stringFormatWidget = registeredWidgets[stringFormatWidgets[format]];
-    return getAlternativeWidget(schema, stringFormatWidget, registeredWidgets, widgetOptions);
+  if (widgetMap[type].hasOwnProperty(widget)) {
+    const registeredWidget = registeredWidgets[widgetMap[type][widget]];
+    return getWidget(schema, registeredWidget, registeredWidgets);
   }
 
-  const info = type === "string" && format ? `/${format}` : "";
-  throw new Error(`No alternative widget "${widget}" for type ${type}${info}`);
+  throw new Error(`No widget "${widget}" for type "${type}"`);
 }
 
 function computeDefaults(schema, parentDefaults, definitions={}) {
@@ -208,6 +194,22 @@ export function getDefaultFormState(_schema, formData, definitions={}) {
   return formData || defaults;
 }
 
+export function getUiOptions(uiSchema) {
+  // get all passed options from ui:widget, ui:options, and ui:<optionName>
+  return Object.keys(uiSchema).filter(key => key.indexOf("ui:") === 0).reduce((options, key) => {
+    const value = uiSchema[key];
+
+    if (key === "ui:widget" && isObject(value)) {
+      console.warn("Setting options via ui:widget object is deprecated, use ui:options instead");
+      return {...options, ...(value.options || {}), widget: value.component};
+    }
+    if (key === "ui:options" && isObject(value)) {
+      return {...options, ...value};
+    }
+    return {...options, [key.substring(3)]: value};
+  }, {});
+}
+
 export function isObject(thing) {
   return typeof thing === "object" && thing !== null && !Array.isArray(thing);
 }


Test patch:
diff --git a/test/SchemaField_test.js b/test/SchemaField_test.js
index 701809cd9..70924f81f 100644
--- a/test/SchemaField_test.js
+++ b/test/SchemaField_test.js
@@ -1,5 +1,6 @@
 import React from "react";
 import {expect} from "chai";
+import {Simulate} from "react-addons-test-utils";
 
 import SchemaField from "../src/components/fields/SchemaField";
 import TitleField from "../src/components/fields/TitleField";
@@ -195,4 +196,52 @@ describe("SchemaField", () => {
         .to.eql("A Foo field");
     });
   });
+
+  describe("errors", () => {
+    const schema = {
+      type: "object",
+      properties: {
+        foo: {type: "string"}
+      }
+    };
+
+    const uiSchema = {
+      "ui:field": (props) => {
+        const {uiSchema, ...fieldProps} = props; //eslint-disable-line
+        return <SchemaField {...fieldProps}/>;
+      }
+    };
+
+    function validate(formData, errors) {
+      errors.addError("container");
+      errors.foo.addError("test");
+      return errors;
+    }
+
+    function submit(node) {
+      try {
+        Simulate.submit(node);
+      } catch (e) {
+        // Silencing error thrown as failure is expected here
+      }
+    }
+
+    it("should render it's own errors", () => {
+      const {node} = createFormComponent({schema, uiSchema, validate});
+      submit(node);
+
+      const matches = node.querySelectorAll("form > .form-group > div > .error-detail .text-danger");
+      expect(matches).to.have.length.of(1);
+      expect(matches[0].textContent).to.eql("container");
+    });
+
+    it("should pass errors to child component", () => {
+      const {node} = createFormComponent({schema, uiSchema, validate});
+      submit(node);
+
+      const matches = node.querySelectorAll("form .form-group .form-group .text-danger");
+      expect(matches).to.have.length.of(1);
+      expect(matches[0].textContent).to.contain("test");
+    });
+  });
 });
diff --git a/test/uiSchema_test.js b/test/uiSchema_test.js
index eea810b03..40f7b875b 100644
--- a/test/uiSchema_test.js
+++ b/test/uiSchema_test.js
@@ -67,6 +67,125 @@ describe("uiSchema", () => {
       });
     });
 
+    describe("custom options", () => {
+      let widget, widgets, schema, uiSchema;
+
+      beforeEach(() => {
+        sandbox.stub(console, "warn");
+
+        widget = ({label, options}) => <div id={label} style={options}></div>;
+        widget.defaultProps = {options: {background: "yellow", color: "green"}};
+
+        widgets = {widget};
+
+        // all fields in one schema to catch errors where options passed to one
+        // instance of a widget are persistent across all instances
+        schema = {
+          type: "object",
+          properties: {
+            funcAll: {type: "string"},
+            funcNone: {type: "string"},
+            stringAll: {type: "string"},
+            stringNone: {type: "string"}
+          }
+        };
+
+        uiSchema = {
+          // pass widget as function
+          funcAll: {
+            "ui:widget": {
+              component: widget,
+              options: {
+                background: "purple"
+              }
+            },
+            "ui:options": {
+              margin: "7px"
+            },
+            "ui:padding": "42px"
+          },
+          funcNone: {
+            "ui:widget": widget
+          },
+
+          // pass widget as string
+          stringAll: {
+            "ui:widget": {
+              component: "widget",
+              options: {
+                background: "blue"
+              }
+            },
+            "ui:options": {
+              margin: "19px"
+            },
+            "ui:padding": "41px"
+          },
+          stringNone: {
+            "ui:widget": "widget"
+          }
+        };
+      });
+
+      it("should log warning when deprecated ui:widget: {component, options} api is used", () => {
+        createFormComponent({
+          schema: {type:"string"},
+          uiSchema: {"ui:widget": {component: "widget"}},
+          widgets
+        });
+        expect(console.warn.calledWithMatch(/ui:widget object is deprecated/)).to.be.true;
+      });
+
+      it("should cache MergedWidget instance", () => {
+        expect(widget.MergedWidget).not.to.be.ok;
+        createFormComponent({schema: {type:"string"}, uiSchema: {"ui:widget": "widget"}, widgets});
+        const cached = widget.MergedWidget;
+        expect(cached).to.be.ok;
+        createFormComponent({schema: {type:"string"}, uiSchema: {"ui:widget": "widget"}, widgets});
+        expect(widget.MergedWidget).to.equal(cached);
+      });
+
+      it("should render merged ui:widget options for widget referenced as function", () => {
+        const {node} = createFormComponent({schema, uiSchema, widgets});
+        const widget = node.querySelector("#funcAll");
+
+        expect(widget.style.background).to.equal("purple");
+        expect(widget.style.color).to.equal("green");
+        expect(widget.style.margin).to.equal("7px");
+        expect(widget.style.padding).to.equal("42px");
+      });
+
+      it("should render ui:widget default options for widget referenced as function", () => {
+        const {node} = createFormComponent({schema, uiSchema, widgets});
+        const widget = node.querySelector("#funcNone");
+
+        expect(widget.style.background).to.equal("yellow");
+        expect(widget.style.color).to.equal("green");
+        expect(widget.style.margin).to.equal("");
+        expect(widget.style.padding).to.equal("");
+      });
+
+      it("should render merged ui:widget options for widget referenced as string", () => {
+        const {node} = createFormComponent({schema, uiSchema, widgets});
+        const widget = node.querySelector("#stringAll");
+
+        expect(widget.style.background).to.equal("blue");
+        expect(widget.style.color).to.equal("green");
+        expect(widget.style.margin).to.equal("19px");
+        expect(widget.style.padding).to.equal("41px");
+      });
+
+      it("should render ui:widget default options for widget referenced as string", () => {
+        const {node} = createFormComponent({schema, uiSchema, widgets});
+        const widget = node.querySelector("#stringNone");
+
+        expect(widget.style.background).to.equal("yellow");
+        expect(widget.style.color).to.equal("green");
+        expect(widget.style.margin).to.equal("");
+        expect(widget.style.padding).to.equal("");
+      });
+    });
+
     describe("nested widget", () => {
       const schema = {
         "type": "object",
@@ -163,29 +282,6 @@ describe("uiSchema", () => {
           expect(node.querySelectorAll(".custom")).to.have.length.of(1);
         });
       });
-
-      describe("referenced descriptor", () => {
-        const uiSchema = {
-          "field": {
-            "ui:widget": "custom"
-          }
-        };
-
-        const widgets = {
-          custom: {
-            component: CustomWidget,
-            options: {
-              className: "custom"
-            }
-          }
-        };
-
-        it("should render a custom widget with options", () => {
-          const {node} = createFormComponent({schema, uiSchema, widgets});
-
-          expect(node.querySelectorAll(".custom")).to.have.length.of(1);
-        });
-      });
     });
 
     describe("enum fields native options", () => {
@@ -1020,7 +1116,7 @@ describe("uiSchema", () => {
           node => node.textContent);
 
         expect(labels)
-          .eql(["true", "false"]);
+          .eql(["yes", "no"]);
       });
 
       it("should support formData", () => {
@@ -1075,9 +1171,9 @@ describe("uiSchema", () => {
         const {node} = createFormComponent({schema, uiSchema});
 
         expect(node.querySelectorAll("option")[0].textContent)
-          .eql("true");
+          .eql("yes");
         expect(node.querySelectorAll("option")[1].textContent)
-          .eql("false");
+          .eql("no");
       });
 
       it("should update state when true is selected", () => {