Repository: mozilla-services/react-jsonschema-form
Pull request #1186 (2019-02-20 23:46:48)

Custom string formats
### Reasons for making this change

This is based off https://github.com/mozilla-services/react-jsonschema-form/pull/1130, following it as a pattern for passing through additional configuration to AJV.

A prior PR, #889, attempted to add this. However I disagree with the guidance there to use custom widgets. That approach does not allow custom defined formats to be validated outside of the widget. Sure, validation logic can be implemented within and only emit values onChange on valid input. But should a parent form, custom validation functions, etc. not have awareness of validation status for _all_ fields?

#889 opted to ignore custom formats altogether which I agree is ill-advised. AJV implements an [addFormat method](https://github.com/epoberezkin/ajv#api-addformat) however that can associate validation patterns (and more) with named formats. This suppresses AJV warnings about these formats _and_ validates data. Using formats also allows AJV to be used externally for validation in other contexts, e.g. on a backend.

Granted this is all similar to schema definitions but the key benefit is automatic [assignment of custom widgets](https://github.com/mozilla-services/react-jsonschema-form/blob/6e79281a3c5ea99763cfce6ea78fcfe776824bad/src/utils.js#L14-L34) without requiring these to be specified as uiSchema wherever a definition is referenced. AJV's own custom validation functions, etc. may also be useful for advanced usage.

Lastly, https://github.com/json-schema-org/json-schema-spec/issues/563 documents broader need for this capability in JSON Schema. This library itself has added `color` and `data-url`. Meta-schemas are not a viable solution as the above issue notes. A separate _vocabulary definition file format_ may be specified in draft-08 but in the meantime being able to register such custom vocabularies with AJV would fill the need and give users of this library a solution until draft-08 arrives.

### Checklist

* [x] **I'm updating documentation**
  - [x] 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**
  - [x] I've added and/or updated tests
  - [x] 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

> :warning: I am opening this early, before https://github.com/mozilla-services/react-jsonschema-form/pull/1130 is merged, to demonstrate this separate (but somewhat related) use case and see whether this is an acceptable solution before doing anything further.

pushred: Something of a blocker I ran into trying to add this to the playground — there's not a way currently to define config that is not serializable to JSON. For `additionalFormats` I can specify validation patterns as a string, which [AJV converts to RegEx](https://github.com/epoberezkin/ajv#addformatstring-name-stringregexpfunctionobject-format---ajv).

But custom widgets must also be registered in order to use these formats in react-jsonschema-form. In my usage of custom formats I typically map these patterns or apply other configuration to instances of a TextWidget of my own implementation. I could add some examples of this to app.js as is done for the GeoPosition / custom component example. But the playground isn't really representative of real world use with this approach.

I think a broader overhaul of the playground is warranted, using something like [react-live](https://github.com/FormidableLabs/react-live) to allow some access to react-jsonschema-form. `schema` and `uiSchema` could still be editable externally and validated as JSON. Happy to take this on, I've done similar work for an internal UI library and have a design background so it would certainly be an improvement =)
epicfaace: Looks good, although of course we will need to wait for #1130 to be merged first before merging this one.
epicfaace: @pushred -- since #1130 has been merged with some changes, do you mind making your changes again off of `master` and updating this PR to reflect that?
epicfaace: @pushred just bumping this!
pushred: @epicfaace thanks for the diligence!

I've rebuilt the branch off the latest and adapted my changes to use the new AJV instance. I hoisted that call outside of the meta schema handling so that both types of additions are applied to it. I considered adding a test to validate both function concurrently but wasn't sure about adding it to the meta schemas describe block and didn't want to duplicate the setup there. Let me know if you think that's valuable — I think including it in the meta schemas block would be best if so. 

I also followed the lead of meta schemas and now accept new formats on prop updates.

Lastly, I renamed the prop and some code to _customFormats_ vs. copying the _additional_ prefix. This is shorter and consistent with AJV's docs.
epicfaace: @pushred looks good, thanks for the good work!

I don't think it's necessary to make a test to check for meta schemas and custom formats concurrently. However, could you please add a test in this style? (something like "should update allowed custom formats when customFormat is changed"): https://github.com/mozilla-services/react-jsonschema-form/blob/92233e40e75854b50754a836a9b999c11eff75e7/test/Form_test.js#L1983
pushred: @epicfaace good call on the prop update test, that caught a big oversight! Custom formats would not have been applied to validations on prop updates. Added the test and a fix for that.

Patch:
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..498baa3fb
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,15 @@
+# Community Participation Guidelines
+
+This repository is governed by Mozilla's code of conduct and etiquette guidelines. 
+For more details, please read the
+[Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 
+
+## How to Report
+For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page.
+
+<!--
+## Project Specific Etiquette
+
+In some cases, there will be additional project etiquette i.e.: (https://bugzilla.mozilla.org/page.cgi?id=etiquette.html).
+Please update for your project.
+-->
diff --git a/docs/advanced-customization.md b/docs/advanced-customization.md
index e74fb9eaa..69ec69fef 100644
--- a/docs/advanced-customization.md
+++ b/docs/advanced-customization.md
@@ -257,9 +257,11 @@ The following props are passed to custom widget components:
 - `id`: The generated id for this field;
 - `schema`: The JSONSchema subschema object for this field;
 - `value`: The current value for this field;
+- `placeholder`: the placeholder for the field, if any;
 - `required`: The required status of this field;
 - `disabled`: `true` if the widget is disabled;
 - `readonly`: `true` if the widget is read-only;
+- `autofocus`: `true` if the widget should autofocus;
 - `onChange`: The value change event handler; call it with the new value everytime it changes;
 - `onBlur`: The input blur event handler; call it with the the widget id and value;
 - `onFocus`: The input focus event handler; call it with the the widget id and value;
diff --git a/docs/form-customization.md b/docs/form-customization.md
index 07f77d938..a42c0f6fa 100644
--- a/docs/form-customization.md
+++ b/docs/form-customization.md
@@ -694,8 +694,7 @@ The `Form` component supports the following html attributes:
   action="/users/list"
   autocomplete="off"
   enctype="multipart/form-data"
-  acceptcharset="ISO-8859-1"
-  schema={} />
+  acceptcharset="ISO-8859-1" />
 ```
 
 ### Disabling a form
diff --git a/docs/validation.md b/docs/validation.md
index 9f259729a..3370a0ad1 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -47,6 +47,30 @@ render((
 >   received as second argument.
 > - The `validate()` function is called **after** the JSON schema validation.
 
+### Custom string formats
+
+[Pre-defined semantic formats](https://json-schema.org/latest/json-schema-validation.html#rfc.section.7) are limited. react-jsonschema-form adds two formats, `color` and `data-url`, to support certain [alternative widgets](form-customization.md#alternative-widgets). You can add formats of your own through the `customFormats` prop to your `Form` component:
+
+```jsx
+const schema = {
+  phoneNumber: {
+    type: 'string',
+    format: 'format-us'
+  }
+};
+
+const customFormats = {
+  'phone-us': /\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4}$/
+};
+
+render((
+  <Form schema={schema} 
+        customFormats={customFormats}/>
+), document.getElementById("app"));
+```
+
+Format values can be anything AJV’s [`addFormat` method](https://github.com/epoberezkin/ajv#addformatstring-name-stringregexpfunctionobject-format---ajv) accepts.
+
 ### Custom schema validation
 
 To have your schemas validated against any other meta schema than draft-07 (the current version of [JSON Schema](http://json-schema.org/)), make sure your schema has a `$schema` attribute that enables the validator to use the correct meta schema. For example:
diff --git a/package.json b/package.json
index 8d7cff345..46349d113 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "react-jsonschema-form",
-  "version": "1.3.0",
+  "version": "1.4.0",
   "description": "A simple React component capable of building HTML forms out of a JSON schema.",
   "scripts": {
     "build:lib": "rimraf lib && cross-env NODE_ENV=production babel -d lib/ src/",
diff --git a/src/components/Form.js b/src/components/Form.js
index 96f2fed95..bded37806 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -58,9 +58,10 @@ export default class Form extends Component {
     const { definitions } = schema;
     const formData = getDefaultFormState(schema, props.formData, definitions);
     const retrievedSchema = retrieveSchema(schema, definitions, formData);
+    const customFormats = props.customFormats;
     const additionalMetaSchemas = props.additionalMetaSchemas;
     const { errors, errorSchema } = mustValidate
-      ? this.validate(formData, schema, additionalMetaSchemas)
+      ? this.validate(formData, schema, additionalMetaSchemas, customFormats)
       : {
           errors: state.errors || [],
           errorSchema: state.errorSchema || {},
@@ -91,7 +92,8 @@ export default class Form extends Component {
   validate(
     formData,
     schema = this.props.schema,
-    additionalMetaSchemas = this.props.additionalMetaSchemas
+    additionalMetaSchemas = this.props.additionalMetaSchemas,
+    customFormats = this.props.customFormats
   ) {
     const { validate, transformErrors } = this.props;
     const { definitions } = this.getRegistry();
@@ -101,7 +103,8 @@ export default class Form extends Component {
       resolvedSchema,
       validate,
       transformErrors,
-      additionalMetaSchemas
+      additionalMetaSchemas,
+      customFormats
     );
   }
 
@@ -301,6 +304,7 @@ if (process.env.NODE_ENV !== "production") {
     transformErrors: PropTypes.func,
     safeRenderCompletion: PropTypes.bool,
     formContext: PropTypes.object,
+    customFormats: PropTypes.object,
     additionalMetaSchemas: PropTypes.arrayOf(PropTypes.object),
   };
 }
diff --git a/src/components/fields/MultiSchemaField.js b/src/components/fields/MultiSchemaField.js
index 325a1b985..6ecaa8276 100644
--- a/src/components/fields/MultiSchemaField.js
+++ b/src/components/fields/MultiSchemaField.js
@@ -1,7 +1,7 @@
 import React, { Component } from "react";
 import PropTypes from "prop-types";
 import * as types from "../../types";
-import { guessType } from "../../utils";
+import { getUiOptions, getWidget, guessType } from "../../utils";
 import { isValid } from "../../validate";
 
 class AnyOfField extends Component {
@@ -85,8 +85,8 @@ class AnyOfField extends Component {
     return 0;
   }
 
-  onOptionChange = event => {
-    const selectedOption = parseInt(event.target.value, 10);
+  onOptionChange = option => {
+    const selectedOption = parseInt(option, 10);
     const { formData, onChange, options } = this.props;
 
     const newOption = options[selectedOption];
@@ -119,7 +119,7 @@ class AnyOfField extends Component {
     }
 
     this.setState({
-      selectedOption: parseInt(event.target.value, 10),
+      selectedOption: parseInt(option, 10),
     });
   };
 
@@ -141,7 +141,10 @@ class AnyOfField extends Component {
     } = this.props;
 
     const _SchemaField = registry.fields.SchemaField;
+    const { widgets } = registry;
     const { selectedOption } = this.state;
+    const { widget = "select", ...uiOptions } = getUiOptions(uiSchema);
+    const Widget = getWidget({ type: "number" }, widget, widgets);
 
     const option = options[selectedOption] || null;
     let optionSchema;
@@ -154,22 +157,24 @@ class AnyOfField extends Component {
         : Object.assign({}, option, { type: baseType });
     }
 
+    const enumOptions = options.map((option, index) => ({
+      label: option.title || `Option ${index + 1}`,
+      value: index,
+    }));
+
     return (
       <div className="panel panel-default panel-body">
         <div className="form-group">
-          <select
-            className="form-control"
+          <Widget
+            id={`${idSchema.$id}_anyof_select`}
+            schema={{ type: "number", default: 0 }}
             onChange={this.onOptionChange}
+            onBlur={onBlur}
+            onFocus={onFocus}
             value={selectedOption}
-            id={`${idSchema.$id}_anyof_select`}>
-            {options.map((option, index) => {
-              return (
-                <option key={index} value={index}>
-                  {option.title || `Option ${index + 1}`}
-                </option>
-              );
-            })}
-          </select>
+            options={{ enumOptions }}
+            {...uiOptions}
+          />
         </div>
 
         {option !== null && (
diff --git a/src/utils.js b/src/utils.js
index 3445ad810..ce13634b5 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -229,6 +229,9 @@ export function getUiOptions(uiSchema) {
 }
 
 export function isObject(thing) {
+  if (thing instanceof File) {
+    return false;
+  }
   return typeof thing === "object" && thing !== null && !Array.isArray(thing);
 }
 
@@ -290,28 +293,32 @@ export function orderProperties(properties, order) {
       ? `properties '${arr.join("', '")}'`
       : `property '${arr[0]}'`;
   const propertyHash = arrayToHash(properties);
-  const orderHash = arrayToHash(order);
   const extraneous = order.filter(prop => prop !== "*" && !propertyHash[prop]);
   if (extraneous.length) {
-    throw new Error(
+    console.warn(
       `uiSchema order list contains extraneous ${errorPropList(extraneous)}`
     );
   }
+  const orderFiltered = order.filter(
+    prop => prop === "*" || propertyHash[prop]
+  );
+  const orderHash = arrayToHash(orderFiltered);
+
   const rest = properties.filter(prop => !orderHash[prop]);
-  const restIndex = order.indexOf("*");
+  const restIndex = orderFiltered.indexOf("*");
   if (restIndex === -1) {
     if (rest.length) {
       throw new Error(
         `uiSchema order list does not contain ${errorPropList(rest)}`
       );
     }
-    return order;
+    return orderFiltered;
   }
-  if (restIndex !== order.lastIndexOf("*")) {
+  if (restIndex !== orderFiltered.lastIndexOf("*")) {
     throw new Error("uiSchema order list contains more than one wildcard item");
   }
 
-  const complete = [...order];
+  const complete = [...orderFiltered];
   complete.splice(restIndex, 1, ...rest);
   return complete;
 }
diff --git a/src/validate.js b/src/validate.js
index 9e8d34f11..f20afc79e 100644
--- a/src/validate.js
+++ b/src/validate.js
@@ -3,6 +3,7 @@ import Ajv from "ajv";
 let ajv = createAjvInstance();
 import { deepEquals } from "./utils";
 
+let formerCustomFormats = null;
 let formerMetaSchema = null;
 
 import { isObject, mergeObjects } from "./utils";
@@ -167,19 +168,35 @@ export default function validateFormData(
   schema,
   customValidate,
   transformErrors,
-  additionalMetaSchemas = []
+  additionalMetaSchemas = [],
+  customFormats = {}
 ) {
+  const newMetaSchemas = !deepEquals(formerMetaSchema, additionalMetaSchemas);
+  const newFormats = !deepEquals(formerCustomFormats, customFormats);
+
+  if (newMetaSchemas || newFormats) {
+    ajv = createAjvInstance();
+  }
+
   // add more schemas to validate against
   if (
     additionalMetaSchemas &&
-    !deepEquals(formerMetaSchema, additionalMetaSchemas) &&
+    newMetaSchemas &&
     Array.isArray(additionalMetaSchemas)
   ) {
-    ajv = createAjvInstance();
     ajv.addMetaSchema(additionalMetaSchemas);
     formerMetaSchema = additionalMetaSchemas;
   }
 
+  // add more custom formats to validate against
+  if (customFormats && newFormats && isObject(customFormats)) {
+    Object.keys(customFormats).forEach(formatName => {
+      ajv.addFormat(formatName, customFormats[formatName]);
+    });
+
+    formerCustomFormats = customFormats;
+  }
+
   let validationError = null;
   try {
     ajv.validate(schema, formData);
@@ -198,7 +215,13 @@ export default function validateFormData(
     typeof validationError.message === "string" &&
     validationError.message.includes("no schema with key or ref ");
 
-  if (noProperMetaSchema) {
+  const unknownFormat =
+    validationError &&
+    validationError.message &&
+    typeof validationError.message === "string" &&
+    validationError.message.includes("unknown format");
+
+  if (noProperMetaSchema || unknownFormat) {
     errors = [
       ...errors,
       {
@@ -212,7 +235,7 @@ export default function validateFormData(
 
   let errorSchema = toErrorSchema(errors);
 
-  if (noProperMetaSchema) {
+  if (noProperMetaSchema || unknownFormat) {
     errorSchema = {
       ...errorSchema,
       ...{


Test patch:
diff --git a/test/Form_test.js b/test/Form_test.js
index ff1fb240d..388ed1e66 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -1979,6 +1979,53 @@ describe("Form", () => {
     });
   });
 
+  describe("Custom format updates", () => {
+    it("Should update custom formats when customFormats is changed", () => {
+      const formProps = {
+        liveValidate: true,
+        formData: {
+          areaCode: 123,
+        },
+        schema: {
+          type: "object",
+          properties: {
+            areaCode: {
+              type: "number",
+              format: "area-code",
+            },
+          },
+        },
+        uiSchema: {
+          areaCode: {
+            "ui:widget": "area-code",
+          },
+        },
+        widgets: {
+          "area-code": () => <div id="custom" />,
+        },
+      };
+
+      const { comp } = createFormComponent(formProps);
+
+      expect(comp.state.errorSchema).eql({
+        $schema: {
+          __errors: [
+            'unknown format "area-code" is used in schema at path "#/properties/areaCode"',
+          ],
+        },
+      });
+
+      setProps(comp, {
+        ...formProps,
+        customFormats: {
+          "area-code": /\d{3}/,
+        },
+      });
+
+      expect(comp.state.errorSchema).eql({});
+    });
+  });
+
   describe("Meta schema updates", () => {
     it("Should update allowed meta schemas when additionalMetaSchemas is changed", () => {
       const formProps = {
diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js
index e8c04af47..a44dbd639 100644
--- a/test/ObjectField_test.js
+++ b/test/ObjectField_test.js
@@ -231,7 +231,7 @@ describe("ObjectField", () => {
       expect(labels).eql(["baz", "bar", "qux", "foo"]);
     });
 
-    it("should throw when order list contains an extraneous property", () => {
+    it("should use provided order also if order list contains extraneous properties", () => {
       const { node } = createFormComponent({
         schema,
         uiSchema: {
@@ -239,9 +239,12 @@ describe("ObjectField", () => {
         },
       });
 
-      expect(node.querySelector(".config-error").textContent).to.match(
-        /contains extraneous properties 'wut\?', 'huh\?'/
+      const labels = [].map.call(
+        node.querySelectorAll(".field > label"),
+        l => l.textContent
       );
+
+      expect(labels).eql(["baz", "qux", "bar", "foo"]);
     });
 
     it("should throw when order list misses an existing property", () => {
diff --git a/test/anyOf_test.js b/test/anyOf_test.js
index a2bd14b20..9f7cbf2b0 100644
--- a/test/anyOf_test.js
+++ b/test/anyOf_test.js
@@ -54,6 +54,36 @@ describe("anyOf", () => {
     expect(node.querySelectorAll("select")).to.have.length.of(1);
   });
 
+  it("should render a custom widget", () => {
+    const schema = {
+      type: "object",
+      anyOf: [
+        {
+          properties: {
+            foo: { type: "string" },
+          },
+        },
+        {
+          properties: {
+            bar: { type: "string" },
+          },
+        },
+      ],
+    };
+    const widgets = {
+      SelectWidget: () => {
+        return <section id="CustomSelect">Custom Widget</section>;
+      },
+    };
+
+    const { node } = createFormComponent({
+      schema,
+      widgets,
+    });
+
+    expect(node.querySelector("#CustomSelect")).to.exist;
+  });
+
   it("should change the rendered form when the select value is changed", () => {
     const schema = {
       type: "object",
diff --git a/test/oneOf_test.js b/test/oneOf_test.js
index a7589dae6..98ba91ed6 100644
--- a/test/oneOf_test.js
+++ b/test/oneOf_test.js
@@ -54,6 +54,36 @@ describe("oneOf", () => {
     expect(node.querySelectorAll("select")).to.have.length.of(1);
   });
 
+  it("should render a custom widget", () => {
+    const schema = {
+      type: "object",
+      oneOf: [
+        {
+          properties: {
+            foo: { type: "string" },
+          },
+        },
+        {
+          properties: {
+            bar: { type: "string" },
+          },
+        },
+      ],
+    };
+    const widgets = {
+      SelectWidget: () => {
+        return <section id="CustomSelect">Custom Widget</section>;
+      },
+    };
+
+    const { node } = createFormComponent({
+      schema,
+      widgets,
+    });
+
+    expect(node.querySelector("#CustomSelect")).to.exist;
+  });
+
   it("should change the rendered form when the select value is changed", () => {
     const schema = {
       type: "object",
diff --git a/test/setup-jsdom.js b/test/setup-jsdom.js
index 3cbf2cd12..d9f1115da 100644
--- a/test/setup-jsdom.js
+++ b/test/setup-jsdom.js
@@ -6,6 +6,7 @@ if (!global.hasOwnProperty("window")) {
   global.document = jsdom.jsdom("<!doctype html><html><body></body></html>");
   global.window = document.defaultView;
   global.navigator = global.window.navigator;
+  global.File = global.window.File;
 }
 
 // atob
diff --git a/test/utils_test.js b/test/utils_test.js
index 49f2fb632..2f1ecc369 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -2,6 +2,7 @@ import { expect } from "chai";
 
 import {
   asNumber,
+  orderProperties,
   dataURItoBlob,
   deepEquals,
   getDefaultFormState,
@@ -343,6 +344,37 @@ describe("utils", () => {
     });
   });
 
+  describe("orderProperties()", () => {
+    it("should remove from order elements that are not in properties", () => {
+      const properties = ["foo", "baz"];
+      const order = ["foo", "bar", "baz", "qux"];
+      expect(orderProperties(properties, order)).eql(["foo", "baz"]);
+    });
+
+    it("should order properties according to the order", () => {
+      const properties = ["bar", "foo"];
+      const order = ["foo", "bar"];
+      expect(orderProperties(properties, order)).eql(["foo", "bar"]);
+    });
+
+    it("should replace * with properties that are absent in order", () => {
+      const properties = ["foo", "bar", "baz"];
+      const order = ["*", "foo"];
+      expect(orderProperties(properties, order)).eql(["bar", "baz", "foo"]);
+    });
+
+    it("should handle more complex ordering case correctly", () => {
+      const properties = ["foo", "baz", "qux", "bar"];
+      const order = ["quux", "foo", "*", "corge", "baz"];
+      expect(orderProperties(properties, order)).eql([
+        "foo",
+        "qux",
+        "bar",
+        "baz",
+      ]);
+    });
+  });
+
   describe("isConstant", () => {
     it("should return false when neither enum nor const is defined", () => {
       const schema = {};
@@ -517,6 +549,17 @@ describe("utils", () => {
       expect(mergeObjects(obj1, obj2)).eql(expected);
     });
 
+    it("should recursively merge File objects", () => {
+      const file = new File(["test"], "test.txt");
+      const obj1 = {
+        a: {},
+      };
+      const obj2 = {
+        a: file,
+      };
+      expect(mergeObjects(obj1, obj2).a).instanceOf(File);
+    });
+
     describe("concatArrays option", () => {
       it("should not concat arrays by default", () => {
         const obj1 = { a: [1] };
diff --git a/test/validate_test.js b/test/validate_test.js
index b881a8e39..3ea9d40fe 100644
--- a/test/validate_test.js
+++ b/test/validate_test.js
@@ -163,6 +163,69 @@ describe("Validation", () => {
       });
     });
 
+    describe("validating using custom string formats", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          phone: {
+            type: "string",
+            format: "phone-us",
+          },
+        },
+      };
+
+      it("should 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] },
+        });
+      });
+
+      it("should return a validation error about formData", () => {
+        const result = validateFormData(
+          { phone: "800.555.2368" },
+          schema,
+          null,
+          null,
+          null,
+          { "phone-us": /\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4}$/ }
+        );
+
+        expect(result.errors).to.have.lengthOf(1);
+        expect(result.errors[0].stack).to.equal(
+          '.phone should match format "phone-us"'
+        );
+      });
+
+      it("prop updates with new custom formats are accepted", () => {
+        const result = validateFormData(
+          { phone: "abc" },
+          {
+            type: "object",
+            properties: {
+              phone: {
+                type: "string",
+                format: "area-code",
+              },
+            },
+          },
+          null,
+          null,
+          null,
+          { "area-code": /\d{3}/ }
+        );
+
+        expect(result.errors).to.have.lengthOf(1);
+        expect(result.errors[0].stack).to.equal(
+          '.phone should match format "area-code"'
+        );
+      });
+    });
+
     describe("Custom validate function", () => {
       let errors, errorSchema;