Repository: mozilla-services/react-jsonschema-form
Pull request #78 (2016-03-21 11:18:25)

Ref #77: Custom widget support for all types added to ui:widget.
### 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.

For example, let's create and register a dumb `geo` component handling a *latitude* and a *longitude*:

```jsx
const schema = {
  type: "object",
  required: ["lat", "lon"],
  properties: {
    lat: {type: "number"},
    lon: {type: "number"}
  }
};

// Define a custom component for handling the root position object
class GeoPosition extends React.Component {
  constructor(props) {
    super(props);
    this.state = {...props.formData};
  }

  onChange(name) {
    return (event) => {
      this.setState({
        [name]: parseFloat(event.target.value)
      }, () => this.props.onChange(this.state));
    };
  }

  render() {
    const {lat, lon} = this.state;
    return (
      <div>
        <input type="number" value={lat} onChange={this.onChange("lat")} />
        <input type="number" value={lon} onChange={this.onChange("lon")} />
      </div>
    );
  }
}

// Define the custom field component to use for the root object
const uiSchema = {"ui:field": "geo"};

// Define the custom field components to register; here our "geo"
// custom field component
const fields = {geo: GeoPosition};

// Render the form with all the properties we just defined passed
// as props
render(<Form
  schema={schema}
  uiSchema={uiSchema}
  fields={fields}/>);
```

Note: Registered fields can be reused accross the entire schema.

Feedback? @chmac
r=?@magopian

n1k0: Hmm this patch is wrong as we need to distinguish between fields and widgets; providing a replacement for an object field is definitely not the same as providing an input (widget) for eg. a string.

I might introduce a new `ui:field` to disambiguate the two distinct use cases.
n1k0: This is now ready for early review. r=? @magopian
almet: How do you do the correlation between "geo" and the component used here?
almet: This is great! thanks for doing this, I think it will be a really useful addition to the library. Everything looks good to my non-react expert eyes.
n1k0: > How do you do the correlation between "geo" and the component used here?

With this line from the docs sample:

```js
const uiSchema = {"ui:field": "geo"};
```


Patch:
diff --git a/README.md b/README.md
index ad45ee546..2cb2af3a8 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,8 @@ A [live demo](https://mozilla-services.github.io/react-jsonschema-form/) is host
      - [Multiple choices list](#multiple-choices-list)
      - [Form action buttons](#form-action-buttons)
   - [Advanced customization](#advanced-customization)
-     - [Custom widgets](#custom-widgets)
+     - [Custom widget components](#custom-widget-components)
+     - [Custom field components](#custom-field-components)
      - [Custom SchemaField](#custom-schemafield)
      - [Custom titles](#custom-titles)
   - [Schema definitions and references](#schema-definitions-and-references)
@@ -271,7 +272,12 @@ render(
 
 ## Advanced customization
 
-### Custom widgets
+The API allows to specify your own custom *widgets* and *fields* components:
+
+- A *widget* represents a HTML tag for the user to enter data, eg. `input`, `select`, etc.
+- A *field* usually wraps one or more widgets and most often handles internal field state; think of a field as a form row, including the labels.
+
+### Custom widget components
 
 You can provide your own custom widgets to a uiSchema for the following json data types:
 
@@ -342,6 +348,65 @@ The following props are passed to the widget component:
 - `placeholder`: The placeholder value, if any;
 - `options`: The list of options for `enum` fields;
 
+### 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.
+
+For example, let's create and register a dumb `geo` component handling a *latitude* and a *longitude*:
+
+```jsx
+const schema = {
+  type: "object",
+  required: ["lat", "lon"],
+  properties: {
+    lat: {type: "number"},
+    lon: {type: "number"}
+  }
+};
+
+// Define a custom component for handling the root position object
+class GeoPosition extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {...props.formData};
+  }
+
+  onChange(name) {
+    return (event) => {
+      this.setState({
+        [name]: parseFloat(event.target.value)
+      }, () => this.props.onChange(this.state));
+    };
+  }
+
+  render() {
+    const {lat, lon} = this.state;
+    return (
+      <div>
+        <input type="number" value={lat} onChange={this.onChange("lat")} />
+        <input type="number" value={lon} onChange={this.onChange("lon")} />
+      </div>
+    );
+  }
+}
+
+// Define the custom field component to use for the root object
+const uiSchema = {"ui:field": "geo"};
+
+// Define the custom field components to register; here our "geo"
+// custom field component
+const fields = {geo: GeoPosition};
+
+// Render the form with all the properties we just defined passed
+// as props
+render(<Form
+  schema={schema}
+  uiSchema={uiSchema}
+  fields={fields}/>);
+```
+
+Note: Registered fields can be reused accross the entire schema.
+
 ### Custom SchemaField
 
 **Warning:** This is a powerful feature as you can override the whole form behavior and easily mess it up. Handle with care.
diff --git a/playground/app.js b/playground/app.js
index 2a254069e..040eff75a 100644
--- a/playground/app.js
+++ b/playground/app.js
@@ -28,6 +28,41 @@ const cmOptions = {
   tabSize: 2,
 };
 
+class GeoPosition extends Component {
+  constructor(props) {
+    super(props);
+    this.state = {...props.formData};
+  }
+
+  onChange(name) {
+    return (event) => {
+      this.setState({
+        [name]: parseFloat(event.target.value)
+      }, () => this.props.onChange(this.state));
+    };
+  }
+
+  render() {
+    const {lat, lon} = this.state;
+    return (
+      <div>
+        <p>
+          <label>Latitude
+            <input type="number" value={lat} step="0.00001"
+              onChange={this.onChange("lat")} />
+          </label>
+        </p>
+        <p>
+          <label>Longitude
+            <input type="number" value={lon} step="0.00001"
+              onChange={this.onChange("lon")} />
+          </label>
+        </p>
+      </div>
+    );
+  }
+}
+
 class Editor extends Component {
   constructor(props) {
     super(props);
@@ -176,6 +211,7 @@ class App extends Component {
               formData={this.state.formData}
               onChange={data => this.setState({formData: data.formData})}
               onSubmit={data => this.setState({formData: data.formData})}
+              fields={{geo: GeoPosition}}
               onError={log("errors")} />}
         </div>
       </div>
diff --git a/playground/samples/custom.js b/playground/samples/custom.js
new file mode 100644
index 000000000..f9297ef3f
--- /dev/null
+++ b/playground/samples/custom.js
@@ -0,0 +1,24 @@
+module.exports = {
+  schema: {
+    title: "A localisation form",
+    type: "object",
+    required: ["lat", "lon"],
+    properties: {
+      lat: {
+        type: "number",
+        title: "Latitude",
+      },
+      lon: {
+        type: "number",
+        title: "Longitude",
+      }
+    }
+  },
+  uiSchema: {
+    "ui:field": "geo"
+  },
+  formData: {
+    lat: 0,
+    lon: 0
+  }
+};
diff --git a/playground/samples/index.js b/playground/samples/index.js
index 7765fb2e4..e00a798ec 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -5,6 +5,7 @@ import simple from "./simple";
 import widgets from "./widgets";
 import ordering from "./ordering";
 import references from "./references";
+import custom from "./custom";
 
 export const samples = {
   Simple: simple,
@@ -14,4 +15,5 @@ export const samples = {
   Widgets: widgets,
   Ordering: ordering,
   References: references,
+  Custom: custom,
 };
diff --git a/src/components/Form.js b/src/components/Form.js
index 3ac9256fe..a625d1068 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -85,19 +85,26 @@ export default class Form extends Component {
   }
 
   getRegistry() {
+    // For BC, accept passed SchemaField and TitleField props and pass them to
+    // the "fields" registry one.
+    const _SchemaField = this.props.SchemaField || SchemaField;
+    const _TitleField = this.props.TitleField || TitleField;
+    const fields = Object.assign({
+      SchemaField: _SchemaField,
+      TitleField: _TitleField,
+    }, this.props.fields);
     return {
-      SchemaField: this.props.SchemaField || SchemaField,
-      TitleField: this.props.TitleField || TitleField,
+      fields,
       widgets: this.props.widgets || {},
       definitions: this.props.schema.definitions || {},
     };
   }
 
   render() {
-    const {children, schema, uiSchema, widgets} = this.props;
+    const {children, schema, uiSchema} = this.props;
     const {formData} = this.state;
     const registry = this.getRegistry();
-    const _SchemaField = registry.SchemaField;
+    const _SchemaField = registry.fields.SchemaField;
     return (
       <form className="rjsf" onSubmit={this._onSubmit}>
         {this.renderErrors()}
@@ -105,7 +112,6 @@ export default class Form extends Component {
           schema={schema}
           uiSchema={uiSchema}
           formData={formData}
-          widgets={widgets}
           onChange={this._onChange}
           registry={registry}/>
         { children ? children : <p><button type="submit">Submit</button></p> }
@@ -119,12 +125,11 @@ if (process.env.NODE_ENV !== "production") {
     schema: PropTypes.object.isRequired,
     uiSchema: PropTypes.object,
     formData: PropTypes.any,
-    widgets: PropTypes.object,
+    widgets: PropTypes.objectOf(PropTypes.func),
+    fields: PropTypes.objectOf(PropTypes.func),
     onChange: PropTypes.func,
     onError: PropTypes.func,
     onSubmit: PropTypes.func,
-    SchemaField: PropTypes.func,
-    TitleField: PropTypes.func,
   };
 }
 
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index aa1478c44..436100fe4 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -90,7 +90,8 @@ class ArrayField extends Component {
     const {schema, uiSchema, name} = this.props;
     const title = schema.title || name;
     const {items} = this.state;
-    const {SchemaField, definitions} = this.props.registry;
+    const {fields, definitions} = this.props.registry;
+    const {SchemaField} = fields;
     const itemsSchema = retrieveSchema(schema.items, definitions);
     if (isMultiSelect(schema)) {
       return (
@@ -145,8 +146,8 @@ if (process.env.NODE_ENV !== "production") {
     onChange: PropTypes.func.isRequired,
     formData: PropTypes.array,
     registry: PropTypes.shape({
-      SchemaField: PropTypes.func.isRequired,
-      TitleField: PropTypes.func.isRequired
+      widgets: PropTypes.objectOf(PropTypes.func).isRequired,
+      fields: PropTypes.objectOf(PropTypes.func).isRequired,
     })
   };
 }
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index 6bd94e2a5..a557be2d8 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -51,7 +51,8 @@ class ObjectField extends Component {
 
   render() {
     const {uiSchema, name} = this.props;
-    const {SchemaField, TitleField, definitions} = this.props.registry;
+    const {definitions, fields} = this.props.registry;
+    const {SchemaField, TitleField} = fields;
     const schema = retrieveSchema(this.props.schema, definitions);
     const title = schema.title || name;
     let orderedProperties;
@@ -100,8 +101,8 @@ if (process.env.NODE_ENV !== "production") {
     formData: PropTypes.object,
     required: PropTypes.bool,
     registry: PropTypes.shape({
-      SchemaField: PropTypes.func.isRequired,
-      TitleField: PropTypes.func.isRequired
+      widgets: PropTypes.objectOf(PropTypes.func).isRequired,
+      fields: PropTypes.objectOf(PropTypes.func).isRequired,
     })
   };
 }
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index 999ab559a..8a507aaf2 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -19,6 +19,16 @@ const COMPONENT_TYPES = {
   "string":    StringField,
 };
 
+function getFieldComponent(schema, uiSchema, fields) {
+  const field = uiSchema["ui:field"];
+  if (typeof field === "function") {
+    return field;
+  }
+  if (typeof field === "string" && field in fields) {
+    return fields[field];
+  }
+  return COMPONENT_TYPES[schema.type] || UnsupportedField;
+}
 
 function getLabel(label, required) {
   if (!label) {
@@ -69,9 +79,9 @@ Wrapper.defaultProps = {
 
 function SchemaField(props) {
   const {uiSchema, name, required, registry} = props;
-  const {definitions} = registry;
+  const {definitions, fields} = registry;
   const schema = retrieveSchema(props.schema, definitions);
-  const FieldComponent = COMPONENT_TYPES[schema.type] || UnsupportedField;
+  const FieldComponent = getFieldComponent(schema, uiSchema, fields);
 
   if (Object.keys(schema).length === 0) {
     return <div />;
@@ -105,7 +115,10 @@ if (process.env.NODE_ENV !== "production") {
   SchemaField.propTypes = {
     schema: PropTypes.object.isRequired,
     uiSchema: PropTypes.object,
-    registry: PropTypes.object,
+    registry: PropTypes.shape({
+      widgets: PropTypes.objectOf(PropTypes.func).isRequired,
+      fields: PropTypes.objectOf(PropTypes.func).isRequired,
+    })
   };
 }
 


Test patch:
diff --git a/test/SchemaField_test.js b/test/SchemaField_test.js
index 60f4ebcb6..13d71413a 100644
--- a/test/SchemaField_test.js
+++ b/test/SchemaField_test.js
@@ -4,18 +4,81 @@ import SchemaField from "../src/components/fields/SchemaField";
 
 import { createFormComponent } from "./test_utils";
 
-describe("Custom SchemaField", () => {
-  const CustomSchemaField = function(props) {
-    return (<div id="custom"><SchemaField {...props} /></div>);
-  };
-
-  it("should use the specified custom SchemaType property", () => {
-    const {node} = createFormComponent({
-      schema: {type: "string"},
-      SchemaField: CustomSchemaField
+describe("SchemaField", () => {
+  describe("Custom SchemaField component", () => {
+    const CustomSchemaField = function(props) {
+      return (<div id="custom"><SchemaField {...props} /></div>);
+    };
+
+    it("should use the specified custom SchemaType property", () => {
+      const {node} = createFormComponent({
+        schema: {type: "string"},
+        SchemaField: CustomSchemaField
+      });
+
+      expect(node.querySelectorAll("#custom > .field input[type=text]"))
+        .to.have.length.of(1);
     });
+  });
+
+  describe("ui:field support", () => {
+    class MyObject extends React.Component {
+      constructor(props) {
+        super(props);
+      }
+
+      render() {
+        return <div id="custom" />;
+      }
+    }
 
-    expect(node.querySelectorAll("#custom > .field input[type=text]"))
-      .to.have.length.of(1);
+    const schema = {
+      type: "object",
+      properties: {
+        foo: {type: "string"},
+        bar: {type: "string"}
+      }
+    };
+
+    it("should use provided direct custom component for object", () => {
+      const uiSchema = {"ui:field": MyObject};
+
+      const {node} = createFormComponent({schema, uiSchema});
+
+      expect(node.querySelectorAll("#custom"))
+        .to.have.length.of(1);
+    });
+
+    it("should use registered custom component for object", () => {
+      const uiSchema = {"ui:field": "myobject"};
+      const fields = {"myobject": MyObject};
+
+      const {node} = createFormComponent({schema, uiSchema, fields});
+
+      expect(node.querySelectorAll("#custom"))
+        .to.have.length.of(1);
+    });
+
+    it("should handle referenced schema definitions", () => {
+      const schema = {
+        definitions: {
+          foobar: {
+            type: "object",
+            properties: {
+              foo: {type: "string"},
+              bar: {type: "string"}
+            }
+          }
+        },
+        $ref: "#/definitions/foobar"
+      };
+      const uiSchema = {"ui:field": "myobject"};
+      const fields = {"myobject": MyObject};
+
+      const {node} = createFormComponent({schema, uiSchema, fields});
+
+      expect(node.querySelectorAll("#custom"))
+        .to.have.length.of(1);
+    });
   });
 });
diff --git a/test/performance_test.js b/test/performance_test.js
index 1b577fc9a..83fe6da82 100644
--- a/test/performance_test.js
+++ b/test/performance_test.js
@@ -52,7 +52,11 @@ describe("Rendering performance optimizations", () => {
     const onChange = () => {};
     const schema = {type: "array", items: {type: "string"}};
     const uiSchema = {};
-    const registry = {TitleField, SchemaField, definitions: {}};
+    const registry = {
+      fields: {TitleField, SchemaField},
+      widgets: {},
+      definitions: {}
+    };
 
     it("should not render if next props are equivalent", () => {
       const {comp} = createComponent(ArrayField, {
@@ -87,7 +91,11 @@ describe("Rendering performance optimizations", () => {
 
   describe("ObjectField", () => {
     const onChange = () => {};
-    const registry = {TitleField, SchemaField, definitions: {}};
+    const registry = {
+      fields: {TitleField, SchemaField},
+      widgets: {},
+      definitions: {}
+    };
     const uiSchema = {};
     const schema = {
       type: "object",