Repository: mozilla-services/react-jsonschema-form (js)

Issue:
Add support for canonical and inline referencing
Spec: http://json-schema.org/latest/json-schema-core.html#anchor25

I suggest we start by supporting local definitions only:

```json
{
  "definitions": {
    "address": {
      "type": "object",
      "properties": {
        "street_address": { "type": "string" },
        "city":           { "type": "string" },
        "state":          { "type": "string" }
      },
      "required": ["street_address", "city", "state"]
    }
  },

  "type": "object",

  "properties": {
    "billing_address": { "$ref": "#/definitions/address" },
    "shipping_address": { "$ref": "#/definitions/address" }
  }
}
```

(Sample JSONSchema source extracted from http://spacetelescope.github.io/understanding-json-schema/structuring.html)

Pull request description:
Fixes #11: Added support for referenced schema definitions.
Refs #11.

## Schema definitions & references

This library partially supports [inline schema definition dereferencing]( http://json-schema.org/latest/json-schema-core.html#rfc.section.7.2.3), which is Barbarian for *avoiding to copy and paste commonly used field schemas*:

```json
{
  "definitions": {
    "address": {
      "type": "object",
      "properties": {
        "street_address": { "type": "string" },
        "city":           { "type": "string" },
        "state":          { "type": "string" }
      },
      "required": ["street_address", "city", "state"]
    }
  },
  "type": "object",
  "properties": {
    "billing_address": { "$ref": "#/definitions/address" },
    "shipping_address": { "$ref": "#/definitions/address" }
  }
}
```

*(Sample schema courtesy of the [Space Telescope Science Institute](http://spacetelescope.github.io/understanding-json-schema/structuring.html))*

Note that it only supports local definition referencing, we do not plan on fetching foreign schemas over HTTP anytime soon. Basically, you can only reference a definition from the very schema object defining it.

Feedback=? @magopian @leplatrem 

Patch:
diff --git a/README.md b/README.md
index facb7462c..28b263e93 100644
--- a/README.md
+++ b/README.md
@@ -349,6 +349,35 @@ render(
 
 **Warning:** there should be a button or an input with `type="submit"` to trigger the form submission (and then the form validation).
 
+## Schema definitions & references
+
+This library partially supports [inline schema definition dereferencing]( http://json-schema.org/latest/json-schema-core.html#rfc.section.7.2.3), which is Barbarian for *avoiding to copy and paste commonly used field schemas*:
+
+```json
+{
+  "definitions": {
+    "address": {
+      "type": "object",
+      "properties": {
+        "street_address": { "type": "string" },
+        "city":           { "type": "string" },
+        "state":          { "type": "string" }
+      },
+      "required": ["street_address", "city", "state"]
+    }
+  },
+  "type": "object",
+  "properties": {
+    "billing_address": { "$ref": "#/definitions/address" },
+    "shipping_address": { "$ref": "#/definitions/address" }
+  }
+}
+```
+
+*(Sample schema courtesy of the [Space Telescope Science Institute](http://spacetelescope.github.io/understanding-json-schema/structuring.html))*
+
+Note that it only supports local definition referencing, we do not plan on fetching foreign schemas over HTTP anytime soon. Basically, you can only reference a definition from the very schema object defining it.
+
 ## Development server
 
 ```
diff --git a/playground/samples/index.js b/playground/samples/index.js
index 5ba3fdab4..7765fb2e4 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -4,6 +4,7 @@ import numbers from "./numbers";
 import simple from "./simple";
 import widgets from "./widgets";
 import ordering from "./ordering";
+import references from "./references";
 
 export const samples = {
   Simple: simple,
@@ -12,4 +13,5 @@ export const samples = {
   Numbers: numbers,
   Widgets: widgets,
   Ordering: ordering,
+  References: references,
 };
diff --git a/playground/samples/references.js b/playground/samples/references.js
new file mode 100644
index 000000000..14e5d879a
--- /dev/null
+++ b/playground/samples/references.js
@@ -0,0 +1,35 @@
+module.exports = {
+  schema: {
+    definitions: {
+      address: {
+        type: "object",
+        properties: {
+          street_address: { type: "string" },
+          city:           { type: "string" },
+          state:          { type: "string" }
+        },
+        required: ["street_address", "city", "state"]
+      }
+    },
+    type: "object",
+    properties: {
+      billing_address: { $ref: "#/definitions/address" },
+      shipping_address: { $ref: "#/definitions/address" }
+    }
+  },
+  uiSchema: {
+    "ui:order": ["shipping_address", "billing_address"]
+  },
+  formData: {
+    billing_address: {
+      street_address: "21, Jump Street",
+      city: "Babel",
+      state: "Neverland"
+    },
+    shipping_address: {
+      street_address: "221B, Baker Street",
+      city: "London",
+      state: "N/A"
+    }
+  }
+};
diff --git a/src/components/Form.js b/src/components/Form.js
index fd1505233..c5f3ce0df 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -22,7 +22,8 @@ export default class Form extends Component {
   getStateFromProps(props) {
     const schema = "schema" in props ? props.schema : this.props.schema;
     const edit = !!props.formData;
-    const formData = getDefaultFormState(schema, props.formData) || null;
+    const {definitions} = schema;
+    const formData = getDefaultFormState(schema, props.formData, definitions) || null;
     return {
       status: "initial",
       formData,
@@ -80,6 +81,7 @@ export default class Form extends Component {
       SchemaField: this.props.SchemaField || SchemaField,
       TitleField: this.props.TitleField || TitleField,
       widgets: this.props.widgets || {},
+      definitions: this.props.schema.definitions || {},
     };
   }
 
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index b231c7e2e..aa0f113d8 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -1,6 +1,11 @@
 import React, { Component, PropTypes } from "react";
 
-import { getDefaultFormState, isMultiSelect, optionsList } from "../../utils";
+import {
+  getDefaultFormState,
+  isMultiSelect,
+  optionsList,
+  retrieveSchema
+} from "../../utils";
 import SelectWidget from "./../widgets/SelectWidget";
 
 
@@ -20,7 +25,10 @@ class ArrayField extends Component {
 
   getStateFromProps(props) {
     const formData = Array.isArray(props.formData) ? props.formData : null;
-    return {items: getDefaultFormState(props.schema, formData) || []};
+    const {definitions} = this.props.registry;
+    return {
+      items: getDefaultFormState(props.schema, formData, definitions) || []
+    };
   }
 
   get itemTitle() {
@@ -40,9 +48,10 @@ class ArrayField extends Component {
   onAddClick(event) {
     event.preventDefault();
     const {items} = this.state;
-    const {schema} = this.props;
+    const {schema, registry} = this.props;
+    const {definitions} = registry;
     this.asyncSetState({
-      items: items.concat(getDefaultFormState(schema.items))
+      items: items.concat(getDefaultFormState(schema.items, undefined, definitions))
     }, {validate: false});
   }
 
@@ -69,13 +78,14 @@ class ArrayField extends Component {
     const {schema, uiSchema, name} = this.props;
     const title = schema.title || name;
     const {items} = this.state;
-    const {SchemaField} = this.props.registry;
+    const {SchemaField, definitions} = this.props.registry;
+    const itemsSchema = retrieveSchema(schema.items, definitions);
     if (isMultiSelect(schema)) {
       return (
         <SelectWidget
           multiple
           onChange={this.onSelectChange.bind(this)}
-          options={optionsList(schema.items)}
+          options={optionsList(itemsSchema)}
           schema={schema}
           title={title}
           defaultValue={schema.default}
@@ -86,7 +96,7 @@ class ArrayField extends Component {
 
     return (
       <fieldset
-        className={`field field-array field-array-of-${schema.items.type}`}>
+        className={`field field-array field-array-of-${itemsSchema.type}`}>
         {title ? <legend>{title}</legend> : null}
         {schema.description ?
           <div className="field-description">{schema.description}</div> : null}
@@ -95,10 +105,10 @@ class ArrayField extends Component {
             return (
               <div key={index}>
                 <SchemaField
-                  schema={schema.items}
+                  schema={itemsSchema}
                   uiSchema={uiSchema.items}
                   formData={items[index]}
-                  required={this.isItemRequired(schema.items)}
+                  required={this.isItemRequired(itemsSchema)}
                   onChange={this.onChange.bind(this, index)}
                   registry={this.props.registry}/>
                 <p className="array-item-remove">
diff --git a/src/components/fields/NumberField.js b/src/components/fields/NumberField.js
index d3f61c9c1..8f857829c 100644
--- a/src/components/fields/NumberField.js
+++ b/src/components/fields/NumberField.js
@@ -14,7 +14,7 @@ if (process.env.NODE_ENV !== "production") {
   NumberField.propTypes = {
     schema: PropTypes.object.isRequired,
     onChange: PropTypes.func.isRequired,
-    formData: PropTypes.number,
+    formData: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
     required: PropTypes.bool,
   };
 }
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index 4bcc77512..adaffea71 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -1,6 +1,10 @@
 import React, { Component, PropTypes } from "react";
 
-import { getDefaultFormState, orderProperties } from "../../utils";
+import {
+  getDefaultFormState,
+  orderProperties,
+  retrieveSchema
+} from "../../utils";
 
 
 class ObjectField extends Component {
@@ -18,7 +22,8 @@ class ObjectField extends Component {
   }
 
   getStateFromProps(props) {
-    return getDefaultFormState(props.schema, props.formData) || {};
+    const {schema, formData, registry} = props;
+    return getDefaultFormState(schema, formData, registry.definitions) || {};
   }
 
   isRequired(name) {
@@ -37,18 +42,23 @@ class ObjectField extends Component {
   }
 
   render() {
-    const {schema, uiSchema, name} = this.props;
+    const {uiSchema, name} = this.props;
+    const {SchemaField, TitleField, definitions} = this.props.registry;
+    const schema = retrieveSchema(this.props.schema, definitions);
     const title = schema.title || name;
-    const {SchemaField, TitleField} = this.props.registry;
+    let orderedProperties;
     try {
-      var orderedProperties = orderProperties(
-        Object.keys(schema.properties), uiSchema["ui:order"]);
+      const properties = Object.keys(schema.properties);
+      orderedProperties = orderProperties(properties, uiSchema["ui:order"]);
     } catch(err) {
       return (
-        <p className="config-error" style={{color: "red"}}>
-          Invalid {name || "root"} object field configuration:
-          <em>{err.message}</em>.
-        </p>
+        <div>
+          <p className="config-error" style={{color: "red"}}>
+            Invalid {name || "root"} object field configuration:
+            <em>{err.message}</em>.
+          </p>
+          <pre>{JSON.stringify(schema)}</pre>
+        </div>
       );
     }
     return (
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index 491accf13..999ab559a 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -1,6 +1,6 @@
 import React, { PropTypes } from "react";
 
-import { isMultiSelect } from "../../utils";
+import { isMultiSelect, retrieveSchema } from "../../utils";
 import ArrayField from "./ArrayField";
 import BooleanField from "./BooleanField";
 import NumberField from "./NumberField";
@@ -68,8 +68,15 @@ Wrapper.defaultProps = {
 };
 
 function SchemaField(props) {
-  const {schema, uiSchema, name, required} = props;
+  const {uiSchema, name, required, registry} = props;
+  const {definitions} = registry;
+  const schema = retrieveSchema(props.schema, definitions);
   const FieldComponent = COMPONENT_TYPES[schema.type] || UnsupportedField;
+
+  if (Object.keys(schema).length === 0) {
+    return <div />;
+  }
+
   let displayLabel = true;
   if (schema.type === "array") {
     displayLabel = isMultiSelect(schema);
diff --git a/src/utils.js b/src/utils.js
index 6513f22d3..f224c2ff7 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -62,7 +62,7 @@ export function getAlternativeWidget(type, widget, registeredWidgets={}) {
   return altWidgetMap[type][widget];
 }
 
-function computeDefaults(schema, parentDefaults) {
+function computeDefaults(schema, parentDefaults, definitions={}) {
   // Compute the defaults recursively: give highest priority to deepest nodes.
   let defaults = parentDefaults;
   if (isObject(defaults) && isObject(schema.default)) {
@@ -72,6 +72,10 @@ function computeDefaults(schema, parentDefaults) {
   } else if ("default" in schema) {
     // Use schema defaults for this node.
     defaults = schema.default;
+  } else if ("$ref" in schema) {
+    // Use referenced schema defaults for this node.
+    const refSchema = findSchemaDefinition(schema.$ref, definitions);
+    defaults = computeDefaults(refSchema, defaults, definitions);
   }
   // Not defaults defined for this node, fallback to generic typed ones.
   if (typeof(defaults) === "undefined") {
@@ -82,18 +86,20 @@ function computeDefaults(schema, parentDefaults) {
     return Object.keys(schema.properties).reduce((acc, key) => {
       // Compute the defaults for this node, with the parent defaults we might
       // have from a previous run: defaults[key].
-      acc[key] = computeDefaults(schema.properties[key], defaults[key]);
+      acc[key] = computeDefaults(
+        schema.properties[key], defaults[key], definitions);
       return acc;
     }, {});
   }
   return defaults;
 }
 
-export function getDefaultFormState(schema, formData) {
-  if (!isObject(schema)) {
-    throw new Error("Invalid schema: " + schema);
+export function getDefaultFormState(_schema, formData, definitions={}) {
+  if (!isObject(_schema)) {
+    throw new Error("Invalid schema: " + _schema);
   }
-  const defaults = computeDefaults(schema);
+  const schema = retrieveSchema(_schema, definitions);
+  const defaults = computeDefaults(schema, undefined, definitions);
   if (typeof(formData) === "undefined") { // No form data? Use schema defaults.
     return defaults;
   }
@@ -158,3 +164,22 @@ export function optionsList(schema) {
     return {label, value};
   });
 }
+
+function findSchemaDefinition($ref, definitions={}) {
+  // Extract and use the referenced definition if we have it.
+  const match = /#\/definitions\/(.*)$/.exec($ref);
+  if (match && match[1] && definitions.hasOwnProperty(match[1])) {
+    return definitions[match[1]];
+  }
+  // No matching definition found, that's an error (bogus schema?)
+  throw new Error(`Could not find a definition for ${$ref}.`);
+}
+
+export function retrieveSchema(schema, definitions={}) {
+  // No $ref attribute found, returning the original schema.
+  if (!schema.hasOwnProperty("$ref")) {
+    return schema;
+  }
+  // Retrieve the referenced schema definition.
+  return findSchemaDefinition(schema.$ref, definitions);
+}


Test patch:
diff --git a/test/Form_test.js b/test/Form_test.js
index d32ce9f75..6bdb986f1 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -66,6 +66,146 @@ describe("Form", () => {
     });
   });
 
+  describe("Schema definitions", () => {
+    it("should use a single schema definition reference", () => {
+      const schema = {
+        definitions: {
+          testdef: {type: "string"}
+        },
+        $ref: "#/definitions/testdef"
+      };
+
+      const {node} = createComponent({schema});
+
+      expect(node.querySelectorAll("input[type=text]"))
+        .to.have.length.of(1);
+    });
+
+    it("should handle multiple schema definition references", () => {
+      const schema = {
+        definitions: {
+          testdef: {type: "string"}
+        },
+        type: "object",
+        properties: {
+          foo: {$ref: "#/definitions/testdef"},
+          bar: {$ref: "#/definitions/testdef"},
+        }
+      };
+
+      const {node} = createComponent({schema});
+
+      expect(node.querySelectorAll("input[type=text]"))
+        .to.have.length.of(2);
+    });
+
+    it("should handle deeply referenced schema definitions", () => {
+      const schema = {
+        definitions: {
+          testdef: {type: "string"}
+        },
+        type: "object",
+        properties: {
+          foo: {
+            type: "object",
+            properties: {
+              bar: {$ref: "#/definitions/testdef"},
+            }
+          }
+        }
+      };
+
+      const {node} = createComponent({schema});
+
+      expect(node.querySelectorAll("input[type=text]"))
+        .to.have.length.of(1);
+    });
+
+    it("should handle referenced definitions for array items", () => {
+      const schema = {
+        definitions: {
+          testdef: {type: "string"}
+        },
+        type: "object",
+        properties: {
+          foo: {
+            type: "array",
+            items: {$ref: "#/definitions/testdef"}
+          }
+        }
+      };
+
+      const {node} = createComponent({schema, formData: {
+        foo: ["blah"]
+      }});
+
+      expect(node.querySelectorAll("input[type=text]"))
+        .to.have.length.of(1);
+    });
+
+    it("should raise for non-existent definitions referenced", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          foo: {$ref: "#/definitions/nonexistent"},
+        }
+      };
+
+      expect(() => createComponent({schema}))
+        .to.Throw(Error, /#\/definitions\/nonexistent/);
+    });
+
+    it("should propagate referenced definition defaults", () => {
+      const schema = {
+        definitions: {
+          testdef: {type: "string", default: "hello"}
+        },
+        $ref: "#/definitions/testdef"
+      };
+
+      const {node} = createComponent({schema});
+
+      expect(node.querySelector("input[type=text]").value)
+        .eql("hello");
+    });
+
+    it("should propagate nested referenced definition defaults", () => {
+      const schema = {
+        definitions: {
+          testdef: {type: "string", default: "hello"}
+        },
+        type: "object",
+        properties: {
+          foo: {$ref: "#/definitions/testdef"}
+        }
+      };
+
+      const {node} = createComponent({schema});
+
+      expect(node.querySelector("input[type=text]").value)
+        .eql("hello");
+    });
+
+    it("should propagate referenced definition defaults for array items", () => {
+      const schema = {
+        definitions: {
+          testdef: {type: "string", default: "hello"}
+        },
+        type: "array",
+        items: {
+          $ref: "#/definitions/testdef"
+        }
+      };
+
+      const {node} = createComponent({schema});
+
+      Simulate.click(node.querySelector(".array-item-add button"));
+
+      expect(node.querySelector("input[type=text]").value)
+        .eql("hello");
+    });
+  });
+
   describe("Defaults array items default propagation", () => {
     const schema = {
       type: "object",
diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js
index cb49c5098..cf4f7017a 100644
--- a/test/ObjectField_test.js
+++ b/test/ObjectField_test.js
@@ -146,5 +146,54 @@ describe("ObjectField", () => {
       expect(node.querySelector(".config-error").textContent)
         .to.match(/does not match object properties list/);
     });
+
+    it("should order referenced schema definitions", () => {
+      const refSchema = {
+        definitions: {
+          testdef: {type: "string"}
+        },
+        type: "object",
+        properties: {
+          foo: {$ref: "#/definitions/testdef"},
+          bar: {$ref: "#/definitions/testdef"}
+        }
+      };
+
+      const {node} = createComponent({schema: refSchema, uiSchema: {
+        "ui:order": ["bar", "foo"]
+      }});
+      const labels = [].map.call(
+        node.querySelectorAll(".field > label"), l => l.textContent);
+
+      expect(labels).eql(["bar", "foo"]);
+    });
+
+    it("should order referenced object schema definition properties", () => {
+      const refSchema = {
+        definitions: {
+          testdef: {
+            type: "object",
+            properties: {
+              foo: {type: "string"},
+              bar: {type: "string"},
+            }
+          }
+        },
+        type: "object",
+        properties: {
+          root: {$ref: "#/definitions/testdef"},
+        }
+      };
+
+      const {node} = createComponent({schema: refSchema, uiSchema: {
+        root: {
+          "ui:order": ["bar", "foo"]
+        }
+      }});
+      const labels = [].map.call(
+        node.querySelectorAll(".field > label"), l => l.textContent);
+
+      expect(labels).eql(["bar", "foo"]);
+    });
   });
 });
diff --git a/test/utils_test.js b/test/utils_test.js
index a75f2ad27..19afbf91d 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -4,7 +4,8 @@ import {
   asNumber,
   getDefaultFormState,
   isMultiSelect,
-  mergeObjects
+  mergeObjects,
+  retrieveSchema
 } from "../src/utils";
 
 
@@ -251,4 +252,21 @@ describe("utils", () => {
       expect(mergeObjects(obj1, obj2)).eql(expected);
     });
   });
+
+  describe("retrieveSchema()", () => {
+    it("should 'resolve' a schema which contains definitions", () => {
+      const schema = { $ref: "#/definitions/address" };
+      const address_definition = {
+        type: "object",
+        properties: {
+          street_address: { type: "string" },
+          city: { type: "string" },
+          state: { type: "string" }
+        },
+        required: [ "street_address", "city", "state" ]
+      };
+      const definitions = { address: address_definition };
+      expect(retrieveSchema(schema, definitions)).eql(address_definition);
+    });
+  });
 });