Repository: mozilla-services/react-jsonschema-form
Pull request #1226 (2019-03-18 17:54:29)

Added withTheme HOC
### Reasons for making this change
Implemented withTheme HOC in order to be able to create custom theme without having to create forks. #1222

Example usage:
```jsx
import React, { Component } from 'react'
import { withTheme } from 'react-jsonschema-form'
import Bootstrap4Theme from 'react-jsonschema-form-theme-bs4'

class Demo extends Component {
  render() {
    return withTheme(Bootstrap4Theme)
  }
}
```

Theme object can provide *widgets*, *templates*, *fields* and *form* in order to customize generated form.

[Example Bootstrap 4 theme](https://github.com/Kenny11CZ/react-jsonschema-form-theme-bs4)


### Checklist

* [ ] **I'm updating documentation**
  - [ ] I've [checked the rendering](https://react-jsonschema-form.readthedocs.io/en/latest/#contributing) of the Markdown text I've added
* [x] **I'm adding or updating code**
  - [ ] I've added and/or updated tests
  - [ ] I've updated [docs](https://react-jsonschema-form.readthedocs.io/) 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**
  - [ ] I've updated the playground with an example use of the feature


andronat: @epicfaace any news regarding this?
Kenny11CZ: So I added documentation, but I'm pretty bad at writing those things, so it would be nice if someone could look at it, fix typos, spelling errors and so on...
epicfaace: Thank you! Can you also add some tests? (using `withTheme` with a simple theme and making sure the corresponding widgets, templates, fields, etc. are used)
Kenny11CZ: I can try it, but I've written one test in javascript so far. And I don't have a lot of time, so don't know when I will do it...
epicfaace: No problem, I can add the tests if you are not comfortable doing so.
Kenny11CZ: That would be awesome, I have no idea how to test this functionality, so at least I would learn something new
andronat: Any news regarding this?
TwoAbove: Nice! Any news on this? Can't wait to implement the usage in my repo!
epicfaace: I'll be writing a test for this soon! Then will be ready to merge
epicfaace: @danbalarin I don't understand why the ability to pass in a custom form component to the theme is needed. Would there really be different kinds of data handling that each theme would require?
danbalarin: Umm, actually I used it in [Bootstrap 4](https://getbootstrap.com/docs/4.0/components/forms/#custom-styles) since there might be needed to add `was-validated` class after validation. This is because when you have some validation made by HTML5 input itself (*number*, *email*, *regex*,...) when you submit the form, it's browser dependent how it shows the error (Firefox shows error input slightly differently from Chromium) and therefore inconsistent. But by providing custom `<form>`, there comes other issues like remembering the data filled in and with each submit refill all inputs (the stuff default form does...).

Patch:
diff --git a/.travis.yml b/.travis.yml
index fd9843053..b562e9176 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,6 +3,12 @@ language:
 - node_js
 node_js:
 - "6"
+- "7"
+- "8"
+- "9"
+- "10"
+- "11"
+- "12"
 env:
 - ACTION=test
 - ACTION="run lint"
diff --git a/docs/form-customization.md b/docs/form-customization.md
index a42c0f6fa..6310c0f1d 100644
--- a/docs/form-customization.md
+++ b/docs/form-customization.md
@@ -146,6 +146,20 @@ The `ui:disabled` uiSchema directive will disable all child widgets from a given
 
 The `ui:readonly` uiSchema directive will mark all child widgets from a given field as read-only.
 
+You can also set specific fields to read-only by setting the `readOnly` property in the schema.
+
+```js
+const schema = {
+  type: "object",
+  properties: {
+    foo: {
+      type: "string",
+      readOnly: true
+    }
+  }
+};
+```
+
 > Note: If you're wondering about the difference between a `disabled` field and a `readonly` one: Marking a field as read-only will render it greyed out, but its text value will be selectable. Disabling it will prevent its value to be selected at all.
 
 #### Hidden widgets
@@ -596,7 +610,7 @@ const uiSchema = {
 
 ### Description texts
 
-Sometimes it's convenient to change description a field. This is the purpose of the `ui:description` uiSchema directive:
+Sometimes it's convenient to change the description of a field. This is the purpose of the `ui:description` uiSchema directive:
 
 ```js
 const schema = {type: "string"};
diff --git a/docs/index.md b/docs/index.md
index 07b63049b..2b7a65bd2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -205,6 +205,7 @@ This component follows [JSON Schema](http://json-schema.org/documentation.html)
  - 2 columns form with CSS and FieldTemplate: <https://jsfiddle.net/n1k0/bw0ffnz4/1/>
  - Validate and submit form from external control: <https://jsfiddle.net/spacebaboon/g5a1re63/>
  - Custom component for Help text with `ui:help`: <https://codesandbox.io/s/14pqx97xl7/>
+ - Collapsing / Showing and Hiding individual fields: <https://codesandbox.io/s/examplereactjsonschemaformcollapsefieldtemplate-t41dn>
 
 ## Contributing
 
@@ -260,7 +261,7 @@ $ npm run tdd
 
 #### Code coverage
 
-Code coverage reports are generated using [nyc](https://github.com/istanbuljs/nyc) each time the `npm test-coverage` script is run. 
+Code coverage reports are generated using [nyc](https://github.com/istanbuljs/nyc) each time the `npm test-coverage` script is run.
 The full report can be seen by opening `./coverage/lcov-report/index.html`.
 
 ### Releasing
@@ -289,7 +290,7 @@ A: Probably not. We use Bootstrap v3 and it works fine for our needs. We would l
 
 ### Q: Is there a way to "collapse" fields, for instance to show/hide individual fields?
 
-A: There's no specific built-in way to do this, but you can write your own FieldTemplate that supports hiding/showing fields according to user input. We don't yet have an example of this use, but if you write one, please add it to the "tips and tricks" section, above. See also: [#268](https://github.com/mozilla-services/react-jsonschema-form/issues/268), [#304](https://github.com/mozilla-services/react-jsonschema-form/pull/304), [#598](https://github.com/mozilla-services/react-jsonschema-form/issues/598), [#920](https://github.com/mozilla-services/react-jsonschema-form/issues/920).
+A: There's no specific built-in way to do this, but you can write your own FieldTemplate that supports hiding/showing fields according to user input. See the "tips and tricks" section above for one example implementation. See also: [#268](https://github.com/mozilla-services/react-jsonschema-form/issues/268), [#304](https://github.com/mozilla-services/react-jsonschema-form/pull/304), [#598](https://github.com/mozilla-services/react-jsonschema-form/issues/598), [#920](https://github.com/mozilla-services/react-jsonschema-form/issues/920).
 
 ## License
 
diff --git a/docs/theme-customization.md b/docs/theme-customization.md
new file mode 100644
index 000000000..7bdb1f6b7
--- /dev/null
+++ b/docs/theme-customization.md
@@ -0,0 +1,94 @@
+## Customizing with other frameworks
+
+### withTheme Higher-Order Component
+The `withTheme` component provides an easy way to extend the functionality of react-jsonschema-form by passing in a theme object that defines custom/overridden widgets and fields, as well as any of the other possible properties of the standard rjsf `Form` component. This theme-defining object is passed as the only parameter to the HOC (`withTheme(ThemeObj)`), and the HOC will return a themed-component which you use instead of the standard `Form` component.
+
+### Usage
+
+```jsx
+import React, { Component } from 'react';
+import { withTheme } from 'react-jsonschema-form';
+import Bootstrap4Theme from 'react-jsonschema-form-theme-bs4';
+
+const ThemedForm = withTheme(Bootstrap4Theme); 
+class Demo extends Component {
+    render() {
+        return <ThemedForm schema={{...}} uiSchema={{...}} />
+    }
+}
+```
+
+### Theme object properties
+The Theme object consists of the same properties as the rjsf `Form` component (such as **widgets** and **fields**). The themed-Form component merges together any theme-specific **widgets** and **fields** with the default **widgets** and **fields**. For instance, providing a single widget in **widgets** will merge this widget with all the default widgets of the rjsf `Form` component, but overrides the default if the theme's widget's name matches the default widget's name. Thus, for each default widget or field not specified/overridden, the themed-form will rely on the defaults from the rjsf `Form`. Note that you are not required to pass in either custom **widgets** or **fields** when using the custom-themed HOC component; you can make the essentially redefine the default Form by simply doing `const Form = withTheme({});`.
+
+#### Widgets and fields 
+**widgets** and **fields** should be in the same format as shown [here](/advanced-customization/#custom-widgets-and-fields).
+
+Example theme with custom widget:
+```jsx
+const MyCustomWidget = (props) => {
+  return (
+    <input type="text"
+      className="custom"
+      value={props.value}
+      required={props.required}
+      onChange={(event) => props.onChange(event.target.value)} />
+  );
+};
+
+const myWidgets = {
+  myCustomWidget: MyCustomWidget
+};
+
+const ThemeObject = {widgets: myWidgets};
+export default ThemeObject;
+```
+
+The above can be similarly done for **fields**.
+
+#### Templates
+Each template should be passed directly into the theme object just as you would into the rjsf Form component. Here is an example of how to use a custom [ArrayFieldTemplate](/advanced-customization/#array-field-template) and [ErrorListTemplate](/advanced-customization/#error-list-template) in the theme object:
+```jsx
+function MyArrayFieldTemplate(props) {
+  return (
+    <div>
+      {props.items.map(element => element.children)}
+      {props.canAdd && <button type="button" onClick={props.onAddClick}></button>}
+    </div>
+  );
+}
+
+function MyErrorListTemplate(props) {
+  const {errors} = props;
+  return (
+    <ul>
+      {errors.map(error => (
+          <li key={error.stack}>
+            {error.stack}
+          </li>
+        ))}
+    </ul>
+  );
+}
+
+const ThemeObject = {
+    ArrayFieldTemplate: MyArrayFieldTemplate, 
+    ErrorList: MyErrorListTemplate,
+    widgets: myWidgets
+};
+
+export default ThemeObject;
+```
+
+### Overriding other Form props
+Just as the theme can override **widgets**, **fields**, any of the field templates, and set default values to properties like **showErrorList**, you can do the same with the instance of the withTheme() Form component.
+```jsx
+const ThemeObject = {
+    ArrayFieldTemplate: MyArrayFieldTemplate, 
+    fields: myFields,
+    showErrorList: false,
+    widgets: myWidgets
+};
+```
+
+Thus, the user has higher priority than the withTheme HOC, and the theme has higher priority than the default values of the rjsf Form component (**User** > **Theme** > **Defaults**).
diff --git a/mkdocs.yml b/mkdocs.yml
index d3d8fcbdc..0ef3f9627 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -8,9 +8,10 @@ nav:
   - Definitions: definitions.md
   - Dependencies: dependencies.md
   - Form Customization: form-customization.md
+  - Theme Customization: theme-customization.md
   - Validation: validation.md
   - Playground: https://mozilla-services.github.io/react-jsonschema-form/
 
 markdown_extensions:
   - toc:
-      permalink: true
\ No newline at end of file
+      permalink: true
diff --git a/playground/samples/nullable.js b/playground/samples/nullable.js
index 1068ac974..2ccc62de9 100644
--- a/playground/samples/nullable.js
+++ b/playground/samples/nullable.js
@@ -15,7 +15,7 @@ module.exports = {
         title: "Last name",
       },
       age: {
-        type: "integer",
+        type: ["integer", "null"],
         title: "Age",
       },
       bio: {
@@ -43,6 +43,7 @@ module.exports = {
       "ui:widget": "updown",
       "ui:title": "Age of person",
       "ui:description": "(earthian year)",
+      "ui:emptyValue": null,
     },
     bio: {
       "ui:widget": "textarea",
diff --git a/playground/samples/widgets.js b/playground/samples/widgets.js
index 9a7d575d1..353033444 100644
--- a/playground/samples/widgets.js
+++ b/playground/samples/widgets.js
@@ -73,6 +73,12 @@ module.exports = {
         title: "A readonly field",
         default: "I am read-only.",
       },
+      readonly2: {
+        type: "string",
+        title: "Another readonly field",
+        default: "I am also read-only.",
+        readOnly: true,
+      },
       widgetOptions: {
         title: "Custom widget with options",
         type: "string",
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index 58d321f71..4f0d5781a 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -421,6 +421,7 @@ class ArrayField extends Component {
       formData,
       disabled,
       readonly,
+      required,
       autofocus,
       onBlur,
       onFocus,
@@ -448,6 +449,7 @@ class ArrayField extends Component {
         value={items}
         disabled={disabled}
         readonly={readonly}
+        required={required}
         formContext={formContext}
         autofocus={autofocus}
         rawErrors={rawErrors}
diff --git a/src/components/fields/MultiSchemaField.js b/src/components/fields/MultiSchemaField.js
index 6ecaa8276..44401dc06 100644
--- a/src/components/fields/MultiSchemaField.js
+++ b/src/components/fields/MultiSchemaField.js
@@ -1,7 +1,12 @@
 import React, { Component } from "react";
 import PropTypes from "prop-types";
 import * as types from "../../types";
-import { getUiOptions, getWidget, guessType } from "../../utils";
+import {
+  getUiOptions,
+  getWidget,
+  guessType,
+  retrieveSchema,
+} from "../../utils";
 import { isValid } from "../../validate";
 
 class AnyOfField extends Component {
@@ -29,8 +34,16 @@ class AnyOfField extends Component {
   }
 
   getMatchingOption(formData, options) {
+    const { definitions } = this.props.registry;
     for (let i = 0; i < options.length; i++) {
-      const option = options[i];
+      // Assign the definitions to the option, otherwise the match can fail if
+      // the new option uses a $ref
+      const option = Object.assign(
+        {
+          definitions,
+        },
+        options[i]
+      );
 
       // If the schema describes an object then we need to add slightly more
       // strict matching to the schema, because unless the schema uses the
@@ -81,15 +94,20 @@ class AnyOfField extends Component {
       }
     }
 
-    // If the form data matches none of the options, use the first option
-    return 0;
+    // If the form data matches none of the options, use the currently selected
+    // option, assuming it's available; otherwise use the first option
+    return this && this.state ? this.state.selectedOption : 0;
   }
 
   onOptionChange = option => {
     const selectedOption = parseInt(option, 10);
-    const { formData, onChange, options } = this.props;
-
-    const newOption = options[selectedOption];
+    const { formData, onChange, options, registry } = this.props;
+    const { definitions } = registry;
+    const newOption = retrieveSchema(
+      options[selectedOption],
+      definitions,
+      formData
+    );
 
     // If the new option is of type object and the current data is an object,
     // discard properties added using the old option.
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index 7090ee5d9..3fc14f582 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -251,7 +251,12 @@ function SchemaFieldRender(props) {
   const FieldComponent = getFieldComponent(schema, uiSchema, idSchema, fields);
   const { DescriptionField } = fields;
   const disabled = Boolean(props.disabled || uiSchema["ui:disabled"]);
-  const readonly = Boolean(props.readonly || uiSchema["ui:readonly"]);
+  const readonly = Boolean(
+    props.readonly ||
+      uiSchema["ui:readonly"] ||
+      props.schema.readOnly ||
+      schema.readOnly
+  );
   const autofocus = Boolean(props.autofocus || uiSchema["ui:autofocus"]);
   if (Object.keys(schema).length === 0) {
     return null;
diff --git a/src/components/fields/StringField.js b/src/components/fields/StringField.js
index 31dc31580..6f583ba9b 100644
--- a/src/components/fields/StringField.js
+++ b/src/components/fields/StringField.js
@@ -7,6 +7,7 @@ import {
   isSelect,
   optionsList,
   getDefaultRegistry,
+  hasWidget,
 } from "../../utils";
 
 function StringField(props) {
@@ -29,7 +30,10 @@ function StringField(props) {
   const { title, format } = schema;
   const { widgets, formContext } = registry;
   const enumOptions = isSelect(schema) && optionsList(schema);
-  const defaultWidget = format || (enumOptions ? "select" : "text");
+  let defaultWidget = enumOptions ? "select" : "text";
+  if (format && hasWidget(schema, format, widgets)) {
+    defaultWidget = format;
+  }
   const { widget = defaultWidget, placeholder = "", ...options } = getUiOptions(
     uiSchema
   );
diff --git a/src/components/widgets/FileWidget.js b/src/components/widgets/FileWidget.js
index 856dad00a..836c3c635 100644
--- a/src/components/widgets/FileWidget.js
+++ b/src/components/widgets/FileWidget.js
@@ -1,7 +1,7 @@
 import React, { Component } from "react";
 import PropTypes from "prop-types";
 
-import { dataURItoBlob, shouldRender, setState } from "../../utils";
+import { dataURItoBlob, shouldRender } from "../../utils";
 
 function addNameToDataURL(dataURL, name) {
   return dataURL.replace(";base64", `;name=${encodeURIComponent(name)};base64`);
@@ -79,7 +79,7 @@ class FileWidget extends Component {
         values: filesInfo.map(fileInfo => fileInfo.dataURL),
         filesInfo,
       };
-      setState(this, state, () => {
+      this.setState(state, () => {
         if (multiple) {
           onChange(state.values);
         } else {
diff --git a/src/index.js b/src/index.js
index ee2b98296..0e36f93a5 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,3 +1,5 @@
 import Form from "./components/Form";
+import withTheme from "./withTheme";
 
+export { withTheme };
 export default Form;
diff --git a/src/utils.js b/src/utils.js
index a4b2c8af1..22f08e186 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -123,6 +123,22 @@ export function getWidget(schema, widget, registeredWidgets = {}) {
   throw new Error(`No widget "${widget}" for type "${type}"`);
 }
 
+export function hasWidget(schema, widget, registeredWidgets = {}) {
+  try {
+    getWidget(schema, widget, registeredWidgets);
+    return true;
+  } catch (e) {
+    if (
+      e.message &&
+      (e.message.startsWith("No widget") ||
+        e.message.startsWith("Unsupported widget"))
+    ) {
+      return false;
+    }
+    throw e;
+  }
+}
+
 function computeDefaults(schema, parentDefaults, definitions = {}) {
   // Compute the defaults recursively: give highest priority to deepest nodes.
   let defaults = parentDefaults;
@@ -256,6 +272,9 @@ export function asNumber(value) {
   if (value === "") {
     return undefined;
   }
+  if (value === null) {
+    return null;
+  }
   if (/\.$/.test(value)) {
     // "3." can't really be considered a number even if it parses in js. The
     // user is most likely entering a float.
diff --git a/src/validate.js b/src/validate.js
index f20afc79e..526bceef9 100644
--- a/src/validate.js
+++ b/src/validate.js
@@ -14,6 +14,7 @@ function createAjvInstance() {
     allErrors: true,
     multipleOfPrecision: 8,
     schemaId: "auto",
+    unknownFormats: "ignore",
   });
 
   // add custom formats
@@ -215,13 +216,7 @@ export default function validateFormData(
     typeof validationError.message === "string" &&
     validationError.message.includes("no schema with key or ref ");
 
-  const unknownFormat =
-    validationError &&
-    validationError.message &&
-    typeof validationError.message === "string" &&
-    validationError.message.includes("unknown format");
-
-  if (noProperMetaSchema || unknownFormat) {
+  if (noProperMetaSchema) {
     errors = [
       ...errors,
       {
@@ -235,7 +230,7 @@ export default function validateFormData(
 
   let errorSchema = toErrorSchema(errors);
 
-  if (noProperMetaSchema || unknownFormat) {
+  if (noProperMetaSchema) {
     errorSchema = {
       ...errorSchema,
       ...{
diff --git a/src/withTheme.js b/src/withTheme.js
new file mode 100644
index 000000000..d3f2d10a1
--- /dev/null
+++ b/src/withTheme.js
@@ -0,0 +1,28 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import Form from "./";
+
+function withTheme(themeProps) {
+  return class extends Component {
+    render() {
+      let { fields, widgets, ...directProps } = this.props;
+      fields = { ...themeProps.fields, ...fields };
+      widgets = { ...themeProps.widgets, ...widgets };
+      return (
+        <Form
+          {...themeProps}
+          {...directProps}
+          fields={fields}
+          widgets={widgets}
+        />
+      );
+    }
+  };
+}
+
+withTheme.propTypes = {
+  widgets: PropTypes.object,
+  fields: PropTypes.object,
+};
+
+export default withTheme;


Test patch:
diff --git a/test/Form_test.js b/test/Form_test.js
index 388ed1e66..45cc89e7e 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -1984,13 +1984,13 @@ describe("Form", () => {
       const formProps = {
         liveValidate: true,
         formData: {
-          areaCode: 123,
+          areaCode: "123455",
         },
         schema: {
           type: "object",
           properties: {
             areaCode: {
-              type: "number",
+              type: "string",
               format: "area-code",
             },
           },
@@ -2007,22 +2007,20 @@ describe("Form", () => {
 
       const { comp } = createFormComponent(formProps);
 
-      expect(comp.state.errorSchema).eql({
-        $schema: {
-          __errors: [
-            'unknown format "area-code" is used in schema at path "#/properties/areaCode"',
-          ],
-        },
-      });
+      expect(comp.state.errorSchema).eql({});
 
       setProps(comp, {
         ...formProps,
         customFormats: {
-          "area-code": /\d{3}/,
+          "area-code": /^\d{3}$/,
         },
       });
 
-      expect(comp.state.errorSchema).eql({});
+      expect(comp.state.errorSchema).eql({
+        areaCode: {
+          __errors: ['should match format "area-code"'],
+        },
+      });
     });
   });
 
diff --git a/test/StringField_test.js b/test/StringField_test.js
index 9c34a783c..e3542dfe8 100644
--- a/test/StringField_test.js
+++ b/test/StringField_test.js
@@ -1566,7 +1566,7 @@ describe("StringField", () => {
   describe("FileWidget", () => {
     const initialValue = "data:text/plain;name=file1.txt;base64,dGVzdDE=";
 
-    it("should render a color field", () => {
+    it("should render a file field", () => {
       const { node } = createFormComponent({
         schema: {
           type: "string",
@@ -1581,7 +1581,7 @@ describe("StringField", () => {
       const { comp } = createFormComponent({
         schema: {
           type: "string",
-          format: "color",
+          format: "data-url",
           default: initialValue,
         },
       });
diff --git a/test/oneOf_test.js b/test/oneOf_test.js
index 98ba91ed6..3fa952faf 100644
--- a/test/oneOf_test.js
+++ b/test/oneOf_test.js
@@ -464,4 +464,66 @@ describe("oneOf", () => {
       expect(node.querySelectorAll("input#root_bar")).to.have.length.of(1);
     });
   });
+
+  describe("definitions", () => {
+    it("should handle the $ref keyword correctly", () => {
+      const schema = {
+        definitions: {
+          fieldEither: {
+            type: "object",
+            oneOf: [
+              {
+                type: "object",
+                properties: {
+                  value: {
+                    type: "string",
+                  },
+                },
+              },
+              {
+                type: "object",
+                properties: {
+                  value: {
+                    type: "array",
+                    items: {
+                      $ref: "#/definitions/fieldEither",
+                    },
+                  },
+                },
+              },
+            ],
+          },
+        },
+        type: "object",
+        properties: {
+          value: {
+            type: "array",
+            items: {
+              $ref: "#/definitions/fieldEither",
+            },
+          },
+        },
+      };
+
+      const { node } = createFormComponent({
+        schema,
+      });
+
+      expect(node.querySelector(".array-item-add button")).not.eql(null);
+
+      Simulate.click(node.querySelector(".array-item-add button"));
+
+      const $select = node.querySelector("select");
+      expect($select).not.eql(null);
+      Simulate.change($select, {
+        target: { value: $select.options[1].value },
+      });
+
+      // This works because the nested "add" button will now be the first to
+      // appear in the dom
+      Simulate.click(node.querySelector(".array-item-add button"));
+
+      expect($select.value).to.eql($select.options[1].value);
+    });
+  });
 });
diff --git a/test/uiSchema_test.js b/test/uiSchema_test.js
index a964cdada..adbe9fc25 100644
--- a/test/uiSchema_test.js
+++ b/test/uiSchema_test.js
@@ -2376,4 +2376,272 @@ describe("uiSchema", () => {
       });
     });
   });
+
+  describe("Readonly in schema", () => {
+    describe("Fields", () => {
+      describe("ArrayField", () => {
+        let node;
+
+        beforeEach(() => {
+          const schema = {
+            type: "array",
+            items: {
+              type: "string",
+            },
+            readOnly: true,
+          };
+          const uiSchema = {};
+          const formData = ["a", "b"];
+
+          let rendered = createFormComponent({ schema, uiSchema, formData });
+          node = rendered.node;
+        });
+
+        it("should mark as readonly an ArrayField", () => {
+          const disabled = [].map.call(
+            node.querySelectorAll("[type=text]"),
+            node => node.hasAttribute("readonly")
+          );
+          expect(disabled).eql([true, true]);
+        });
+
+        it("should disable the Add button", () => {
+          expect(node.querySelector(".array-item-add button").disabled).eql(
+            true
+          );
+        });
+
+        it("should disable the Delete button", () => {
+          expect(node.querySelector(".array-item-remove").disabled).eql(true);
+        });
+      });
+
+      describe("ObjectField", () => {
+        let node;
+
+        beforeEach(() => {
+          const schema = {
+            type: "object",
+            properties: {
+              foo: {
+                type: "string",
+              },
+              bar: {
+                type: "string",
+              },
+            },
+            readOnly: true,
+          };
+          const uiSchema = {};
+
+          let rendered = createFormComponent({ schema, uiSchema });
+          node = rendered.node;
+        });
+
+        it("should mark as readonly an ObjectField", () => {
+          const disabled = [].map.call(
+            node.querySelectorAll("[type=text]"),
+            node => node.hasAttribute("readonly")
+          );
+          expect(disabled).eql([true, true]);
+        });
+      });
+    });
+
+    describe("Widgets", () => {
+      function shouldBeReadonly(selector, schema, uiSchema) {
+        const { node } = createFormComponent({ schema, uiSchema });
+        expect(node.querySelector(selector).hasAttribute("readonly")).eql(true);
+      }
+      function shouldBeDisabled(selector, schema, uiSchema) {
+        const { node } = createFormComponent({ schema, uiSchema });
+        expect(node.querySelector(selector).disabled).eql(true);
+      }
+
+      it("should mark as readonly a text widget", () => {
+        shouldBeReadonly(
+          "input[type=text]",
+          {
+            type: "string",
+            readOnly: true,
+          },
+          {}
+        );
+      });
+
+      it("should mark as readonly a file widget", () => {
+        // We mark a file widget as readonly by disabling it.
+        const { node } = createFormComponent({
+          schema: {
+            type: "string",
+            format: "data-url",
+            readOnly: true,
+          },
+          uiSchema: {},
+        });
+        expect(
+          node.querySelector("input[type=file]").hasAttribute("disabled")
+        ).eql(true);
+      });
+
+      it("should mark as readonly a textarea widget", () => {
+        shouldBeReadonly(
+          "textarea",
+          {
+            type: "string",
+            readOnly: true,
+          },
+          {
+            "ui:widget": "textarea",
+          }
+        );
+      });
+
+      it("should mark as readonly a number text widget", () => {
+        shouldBeReadonly(
+          "input[type=number]",
+          {
+            type: "number",
+            readOnly: true,
+          },
+          {}
+        );
+      });
+
+      it("should mark as readonly a number widget", () => {
+        shouldBeReadonly(
+          "input[type=number]",
+          {
+            type: "number",
+            readOnly: true,
+          },
+          {
+            "ui:widget": "updown",
+          }
+        );
+      });
+
+      it("should mark as readonly a range widget", () => {
+        shouldBeReadonly(
+          "input[type=range]",
+          {
+            type: "number",
+            readOnly: true,
+          },
+          {
+            "ui:widget": "range",
+          }
+        );
+      });
+
+      it("should mark readonly as disabled on a select widget", () => {
+        shouldBeDisabled(
+          "select",
+          {
+            type: "string",
+            enum: ["a", "b"],
+            readOnly: true,
+          },
+          {}
+        );
+      });
+
+      it("should mark as readonly a color widget", () => {
+        shouldBeReadonly(
+          "input[type=color]",
+          {
+            type: "string",
+            format: "color",
+            readOnly: true,
+          },
+          {}
+        );
+      });
+
+      it("should mark as readonly a password widget", () => {
+        shouldBeReadonly(
+          "input[type=password]",
+          {
+            type: "string",
+            readOnly: true,
+          },
+          {
+            "ui:widget": "password",
+          }
+        );
+      });
+
+      it("should mark as readonly a url widget", () => {
+        shouldBeReadonly(
+          "input[type=url]",
+          {
+            type: "string",
+            format: "uri",
+            readOnly: true,
+          },
+          {}
+        );
+      });
+
+      it("should mark as readonly an email widget", () => {
+        shouldBeReadonly("input[type=email]", {
+          type: "string",
+          format: "email",
+          readOnly: true,
+        });
+      });
+
+      it("should mark as readonly a date widget", () => {
+        shouldBeReadonly("input[type=date]", {
+          type: "string",
+          format: "date",
+          readOnly: true,
+        });
+      });
+
+      it("should mark as readonly a datetime widget", () => {
+        shouldBeReadonly("input[type=datetime-local]", {
+          type: "string",
+          format: "date-time",
+          readOnly: true,
+        });
+      });
+
+      it("should mark readonly as disabled on an alternative date widget", () => {
+        const { node } = createFormComponent({
+          schema: {
+            type: "string",
+            format: "date",
+            readOnly: true,
+          },
+          uiSchema: {
+            "ui:widget": "alt-date",
+          },
+        });
+
+        const readonly = [].map.call(node.querySelectorAll("select"), node =>
+          node.hasAttribute("disabled")
+        );
+        expect(readonly).eql([true, true, true]);
+      });
+
+      it("should mark readonly as disabled on an alternative datetime widget", () => {
+        const { node } = createFormComponent({
+          schema: {
+            type: "string",
+            format: "date-time",
+            readOnly: true,
+          },
+          uiSchema: {
+            "ui:widget": "alt-datetime",
+          },
+        });
+
+        const readonly = [].map.call(node.querySelectorAll("select"), node =>
+          node.hasAttribute("disabled")
+        );
+        expect(readonly).eql([true, true, true, true, true, true]);
+      });
+    });
+  });
 });
diff --git a/test/utils_test.js b/test/utils_test.js
index 2f1ecc369..5acfcc229 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -342,6 +342,10 @@ describe("utils", () => {
     it("should return undefined if the input is empty", () => {
       expect(asNumber("")).eql(undefined);
     });
+
+    it("should return null if the input is null", () => {
+      expect(asNumber(null)).eql(null);
+    });
   });
 
   describe("orderProperties()", () => {
diff --git a/test/validate_test.js b/test/validate_test.js
index 3ea9d40fe..696ed0631 100644
--- a/test/validate_test.js
+++ b/test/validate_test.js
@@ -174,15 +174,9 @@ describe("Validation", () => {
         },
       };
 
-      it("should return a validation error if unknown string format is used", () => {
+      it("should not return a validation error if unknown string format is used", () => {
         const result = validateFormData({ phone: "800.555.2368" }, schema);
-        const errMessage =
-          'unknown format "phone-us" is used in schema at path "#/properties/phone"';
-
-        expect(result.errors[0].stack).include(errMessage);
-        expect(result.errorSchema).to.eql({
-          $schema: { __errors: [errMessage] },
-        });
+        expect(result.errors.length).eql(0);
       });
 
       it("should return a validation error about formData", () => {
diff --git a/test/withTheme_test.js b/test/withTheme_test.js
new file mode 100644
index 000000000..dc963c4b7
--- /dev/null
+++ b/test/withTheme_test.js
@@ -0,0 +1,236 @@
+import { expect } from "chai";
+import React from "react";
+
+import { withTheme } from "../src";
+import { createComponent, createSandbox } from "./test_utils";
+
+describe("withTheme", () => {
+  let sandbox;
+
+  beforeEach(() => {
+    sandbox = createSandbox();
+  });
+
+  afterEach(() => {
+    sandbox.restore();
+  });
+
+  describe("With fields", () => {
+    it("should use the withTheme field", () => {
+      const fields = {
+        StringField() {
+          return <div className="string-field" />;
+        },
+      };
+      const schema = {
+        type: "object",
+        properties: {
+          fieldA: {
+            type: "string",
+          },
+          fieldB: {
+            type: "string",
+          },
+        },
+      };
+      const uiSchema = {};
+      let { node } = createComponent(withTheme({ fields }), {
+        schema,
+        uiSchema,
+      });
+      expect(node.querySelectorAll(".string-field")).to.have.length.of(2);
+    });
+
+    it("should use withTheme field and the user defined field", () => {
+      const themeFields = {
+        StringField() {
+          return <div className="string-field" />;
+        },
+      };
+      const userFields = {
+        NumberField() {
+          return <div className="number-field" />;
+        },
+      };
+      const schema = {
+        type: "object",
+        properties: {
+          fieldA: {
+            type: "string",
+          },
+          fieldB: {
+            type: "number",
+          },
+        },
+      };
+      const uiSchema = {};
+      let { node } = createComponent(withTheme({ fields: themeFields }), {
+        schema,
+        uiSchema,
+        fields: userFields,
+      });
+      expect(node.querySelectorAll(".string-field")).to.have.length.of(1);
+      expect(node.querySelectorAll(".number-field")).to.have.length.of(1);
+    });
+
+    it("should use only the user defined field", () => {
+      const themeFields = {
+        StringField() {
+          return <div className="string-field" />;
+        },
+      };
+      const userFields = {
+        StringField() {
+          return <div className="form-control" />;
+        },
+      };
+      const schema = {
+        type: "object",
+        properties: {
+          fieldA: {
+            type: "string",
+          },
+          fieldB: {
+            type: "string",
+          },
+        },
+      };
+      const uiSchema = {};
+      let { node } = createComponent(withTheme({ fields: themeFields }), {
+        schema,
+        uiSchema,
+        fields: userFields,
+      });
+      expect(node.querySelectorAll(".string-field")).to.have.length.of(0);
+      expect(node.querySelectorAll(".form-control")).to.have.length.of(2);
+    });
+  });
+
+  describe("With widgets", () => {
+    it("should use the withTheme widget", () => {
+      const widgets = {
+        TextWidget: () => <div id="test" />,
+      };
+      const schema = {
+        type: "string",
+      };
+      const uiSchema = {};
+      let { node } = createComponent(withTheme({ widgets }), {
+        schema,
+        uiSchema,
+      });
+      expect(node.querySelectorAll("#test")).to.have.length.of(1);
+    });
+
+    it("should use the withTheme widget as well as user defined widget", () => {
+      const themeWidgets = {
+        TextWidget: () => <div id="test-theme-widget" />,
+      };
+      const userWidgets = {
+        DateWidget: () => <div id="test-user-widget" />,
+      };
+      const schema = {
+        type: "object",
+        properties: {
+          fieldA: {
+            type: "string",
+          },
+          fieldB: {
+            format: "date",
+            type: "string",
+          },
+        },
+      };
+      const uiSchema = {};
+      let { node } = createComponent(withTheme({ widgets: themeWidgets }), {
+        schema,
+        uiSchema,
+        widgets: userWidgets,
+      });
+      expect(node.querySelectorAll("#test-theme-widget")).to.have.length.of(1);
+      expect(node.querySelectorAll("#test-user-widget")).to.have.length.of(1);
+    });
+
+    it("should use only the user defined widget", () => {
+      const themeWidgets = {
+        TextWidget: () => <div id="test-theme-widget" />,
+      };
+      const userWidgets = {
+        TextWidget: () => <div id="test-user-widget" />,
+      };
+      const schema = {
+        type: "object",
+        properties: {
+          fieldA: {
+            type: "string",
+          },
+        },
+      };
+      const uiSchema = {};
+      let { node } = createComponent(withTheme({ widgets: themeWidgets }), {
+        schema,
+        uiSchema,
+        widgets: userWidgets,
+      });
+      expect(node.querySelectorAll("#test-theme-widget")).to.have.length.of(0);
+      expect(node.querySelectorAll("#test-user-widget")).to.have.length.of(1);
+    });
+  });
+
+  describe("With templates", () => {
+    it("should use the withTheme template", () => {
+      const themeTemplates = {
+        FieldTemplate() {
+          return <div className="with-theme-field-template" />;
+        },
+      };
+      const schema = {
+        type: "object",
+        properties: {
+          fieldA: {
+            type: "string",
+          },
+          fieldB: {
+            type: "string",
+          },
+        },
+      };
+      const uiSchema = {};
+      let { node } = createComponent(withTheme({ ...themeTemplates }), {
+        schema,
+        uiSchema,
+      });
+      expect(
+        node.querySelectorAll(".with-theme-field-template")
+      ).to.have.length.of(1);
+    });
+
+    it("should use only the user defined template", () => {
+      const themeTemplates = {
+        FieldTemplate() {
+          return <div className="with-theme-field-template" />;
+        },
+      };
+      const userTemplates = {
+        FieldTemplate() {
+          return <div className="user-field-template" />;
+        },
+      };
+
+      const schema = {
+        type: "object",
+        properties: { foo: { type: "string" }, bar: { type: "string" } },
+      };
+      let { node } = createComponent(withTheme({ ...themeTemplates }), {
+        schema,
+        ...userTemplates,
+      });
+      expect(
+        node.querySelectorAll(".with-theme-field-template")
+      ).to.have.length.of(0);
+      expect(node.querySelectorAll(".user-field-template")).to.have.length.of(
+        1
+      );
+    });
+  });
+});