Repository: mozilla-services/react-jsonschema-form
Pull request #653 (2017-08-01 13:02:57)

Add ObjectFieldTemplate
### Reasons for making this change

`ObjectFieldTemplate` simplifies customizing how the fields are rendered, eg. creating a bootstrap grid layout or a flexbox layout is easier. Previously we had to override ObjectField to achieve custom layouts - like `FieldTemplate` and `ArrayFieldTemplate`, we let the users worry only about rendering. Admittedly,  there isn't that much logic behind `ObjectField`, but this pull requests adds also consistency and makes it possible to customize titles & descriptions without using `TitleField` and `DescriptionField` (`TitleField` and `DescriptionField` could be even deprecated at some point in order to make the code base more consistent - discussed in #354 also).

EDIT: These issues/pull requests would be solved by this: #623 #268  

### Checklist

* [x] **I'm updating documentation**
  - [x] I've checked the rendering of the Markdown text I've added
  - [x] If I'm adding a new section, I've updated the Table of Content
* [x] **I'm adding or updating code**
  - [x] I've added and/or updated tests
  - [x] I've updated docs 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**
  - [x] I've updated the playground with an example use of the feature


olzraiti: Would this be a more powerful feature, if we didn't pass `properties` to the template, but `renderProperty(propertyName)`? It could add some easy optimization for example if there is a large number of properties, but only few of them would be shown at a time? Would this just add unnecessary complexity?

I was thinking also about allowing the template to handle constructing the `SchemaField` for each property, but this would kind of be against the idea of a template, though I'd personally have some use for this with a component where I don't want to render labels for the properties.  
olzraiti: > My main remaining concern is about whether this goes far enough, and whether the approach it takes can be applied to arrays as well as objects. But I don't see any reason why arrays would be much different, and I'm comfortable merging this and leaving arrays for later.

Hmm, I'm not sure I understand what you mean. The templating approach is taken directly from how `ArrayField` handles `ArrayFieldTemplate` and the implementation is very similar.

Patch:
diff --git a/README.md b/README.md
index 13e7c3cd8..ab4609d79 100644
--- a/README.md
+++ b/README.md
@@ -56,11 +56,13 @@ A [live playground](https://mozilla-services.github.io/react-jsonschema-form/) i
   - [Advanced customization](#advanced-customization)
      - [Field template](#field-template)
      - [Array Field Template](#array-field-template)
+     - [Object Field Template](#object-field-template)
      - [Error List template](#error-list-template)
      - [Custom widgets and fields](#custom-widgets-and-fields)
      - [Custom widget components](#custom-widget-components)
         - [Custom component registration](#custom-component-registration)
         - [Custom widget options](#custom-widget-options)
+        - [Customizing widgets' text input](#customizing-widgets-text-input)
      - [Custom field components](#custom-field-components)
         - [Field props](#field-props)
         - [The registry object](#the-registry-object)
@@ -840,8 +842,8 @@ Please see [customArray.js](https://github.com/mozilla-services/react-jsonschema
 
 The following props are passed to each `ArrayFieldTemplate`:
 
-- `DescriptionField`: The generated `DescriptionField` (if you wanted to utilize it)
-- `TitleField`: The generated `TitleField` (if you wanted to utilize it).
+- `DescriptionField`: The `DescriptionField` from the registry (in case you wanted to utilize it)
+- `TitleField`: The `TitleField` from the registry (in case you wanted to utilize it).
 - `canAdd`: A boolean value stating whether new elements can be added to the array.
 - `className`: The className string.
 - `disabled`: A boolean value stating if the array is disabled.
@@ -870,6 +872,51 @@ The following props are part of each element in `items`:
 - `onReorderClick: (index, newIndex) => (event) => void`: Returns a function that swaps the items at `index` with `newIndex`.
 - `readonly`: A boolean value stating if the array item is readonly.
 
+### Object Field Template
+
+Similarly to the `FieldTemplate` you can use an `ObjectFieldTemplate` to customize how your
+objects are rendered.
+
+```jsx
+function ObjectFieldTemplate(props) {
+  return (
+    <div>
+      {props.title}
+      {props.description}
+      {props.properties.map(element => <div className="property-wrapper">{element.children}</div>)}
+    </div>
+  );
+}
+
+render((
+  <Form schema={schema}
+        ObjectFieldTemplate={ObjectFieldTemplate} />,
+), document.getElementById("app"));
+```
+
+Please see [customObject.js](https://github.com/mozilla-services/react-jsonschema-form/blob/master/playground/samples/customObject.js) for a better example.
+
+The following props are passed to each `ObjectFieldTemplate`:
+
+- `DescriptionField`: The `DescriptionField` from the registry (in case you wanted to utilize it)
+- `TitleField`: The `TitleField` from the registry (in case you wanted to utilize it).
+- `title`: A string value containing the title for the object.
+- `description`: A string value containing the description for the object.
+- `properties`: An array of object representing the properties in the array. Each of the properties represent a child with properties described below.
+- `required`: A boolean value stating if the object is required.
+- `schema`: The schema object for this object.
+- `uiSchema`: The uiSchema object for this object field.
+- `idSchema`: An object containing the id for this object & ids for it's properties.
+- `formData`: The form data for the object.
+- `formContext`: The `formContext` object that you passed to Form.
+
+The following props are part of each element in `properties`:
+
+- `content`: The html for the property's content.
+- `name`: A string representing the property name.
+- `disabled`: A boolean value stating if the object property is disabled.
+- `readonly`: A boolean value stating if the property is readonly.
+
 ### Error List template
 
 To take control over how the form errors are displayed, you can define an *error list template* for your form. This list is the form global error list that appears at the top of your forms.
@@ -1038,6 +1085,10 @@ render((
 
 > Note: Since v0.41.0, the `ui:widget` object API, where a widget and options were specified with `"ui:widget": {component, options}` shape, is deprecated. It will be removed in a future release.
 
+#### Customizing widgets' text input
+
+All the widgets that render a text input use the `BaseInput` component internally. If you need to customize all text inputs without customizing all widgets individially, you can provide a `BaseInput` component in the `widgets` property of `Form` (see [Custom component registration](#custom-component-registration).
+
 ### Custom field components
 
 You can provide your own field components to a uiSchema for basically any json schema data type, by specifying a `ui:field` property.
diff --git a/package.json b/package.json
index 60b1f36f4..f1c8071d4 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
     "publish-to-npm": "npm run build:readme && npm run dist && npm publish",
     "start": "node devServer.js",
     "tdd": "cross-env NODE_ENV=test  mocha --compilers js:babel-register --watch --require ./test/setup-jsdom.js test/**/*_test.js",
-    "test": " cross-env NODE_ENV=test   mocha --compilers js:babel-register --require ./test/setup-jsdom.js test/**/*_test.js"
+    "test": "cross-env NODE_ENV=test   mocha --compilers js:babel-register --require ./test/setup-jsdom.js test/**/*_test.js"
   },
   "prettierOptions": "--jsx-bracket-same-line --trailing-comma es5 --semi",
   "lint-staged": {
@@ -73,7 +73,7 @@
     "jsdom": "^8.3.0",
     "lint-staged": "^3.3.1",
     "mocha": "^2.5.3",
-    "prettier": "^1.6.1",
+    "prettier": "^1.7.2",
     "react": "^15.5.0",
     "react-addons-test-utils": "^15.3.2",
     "react-codemirror": "^0.2.3",
diff --git a/playground/app.js b/playground/app.js
index 144c36ebe..33808637d 100644
--- a/playground/app.js
+++ b/playground/app.js
@@ -365,10 +365,15 @@ class App extends Component {
 
   load = data => {
     // Reset the ArrayFieldTemplate whenever you load new data
-    const { ArrayFieldTemplate } = data;
+    const { ArrayFieldTemplate, ObjectFieldTemplate } = data;
     // force resetting form component instance
     this.setState({ form: false }, _ =>
-      this.setState({ ...data, form: true, ArrayFieldTemplate })
+      this.setState({
+        ...data,
+        form: true,
+        ArrayFieldTemplate,
+        ObjectFieldTemplate,
+      })
     );
   };
 
@@ -412,6 +417,7 @@ class App extends Component {
       theme,
       editor,
       ArrayFieldTemplate,
+      ObjectFieldTemplate,
       transformErrors,
     } = this.state;
 
@@ -466,6 +472,7 @@ class App extends Component {
           {this.state.form && (
             <Form
               ArrayFieldTemplate={ArrayFieldTemplate}
+              ObjectFieldTemplate={ObjectFieldTemplate}
               liveValidate={liveValidate}
               schema={schema}
               uiSchema={uiSchema}
diff --git a/playground/samples/customObject.js b/playground/samples/customObject.js
new file mode 100644
index 000000000..1c85e6ffe
--- /dev/null
+++ b/playground/samples/customObject.js
@@ -0,0 +1,61 @@
+import React from "react";
+
+function ObjectFieldTemplate({ TitleField, properties, title, description }) {
+  return (
+    <div>
+      <TitleField title={title} />
+      <div className="row">
+        {properties.map(prop => (
+          <div className="col-lg-2 col-md-4 col-sm-6 col-xs-12">{prop}</div>
+        ))}
+      </div>
+      {description}
+    </div>
+  );
+}
+
+module.exports = {
+  schema: {
+    title: "A registration form",
+    description:
+      "This is the same as the simple form, but it is rendered as a bootstrap grid. Try shrinking the browser window to see it in action.",
+    type: "object",
+    required: ["firstName", "lastName"],
+    properties: {
+      firstName: {
+        type: "string",
+        title: "First name",
+      },
+      lastName: {
+        type: "string",
+        title: "Last name",
+      },
+      age: {
+        type: "integer",
+        title: "Age",
+      },
+      bio: {
+        type: "string",
+        title: "Bio",
+      },
+      password: {
+        type: "string",
+        title: "Password",
+        minLength: 3,
+      },
+      telephone: {
+        type: "string",
+        title: "Telephone",
+        minLength: 10,
+      },
+    },
+  },
+  formData: {
+    firstName: "Chuck",
+    lastName: "Norris",
+    age: 75,
+    bio: "Roundhouse kicking asses since 1940",
+    password: "noneed",
+  },
+  ObjectFieldTemplate,
+};
diff --git a/playground/samples/index.js b/playground/samples/index.js
index 68fa8d22d..ddc7b18c0 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -13,6 +13,7 @@ import validation from "./validation";
 import files from "./files";
 import single from "./single";
 import customArray from "./customArray";
+import customObject from "./customObject";
 import alternatives from "./alternatives";
 
 export const samples = {
@@ -31,5 +32,6 @@ export const samples = {
   Files: files,
   Single: single,
   "Custom Array": customArray,
+  "Custom Object": customObject,
   Alternatives: alternatives,
 };
diff --git a/src/components/Form.js b/src/components/Form.js
index 59237ba97..14c90c8ae 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -151,6 +151,7 @@ export default class Form extends Component {
       fields: { ...fields, ...this.props.fields },
       widgets: { ...widgets, ...this.props.widgets },
       ArrayFieldTemplate: this.props.ArrayFieldTemplate,
+      ObjectFieldTemplate: this.props.ObjectFieldTemplate,
       FieldTemplate: this.props.FieldTemplate,
       definitions: this.props.schema.definitions || {},
       formContext: this.props.formContext || {},
@@ -227,6 +228,7 @@ if (process.env.NODE_ENV !== "production") {
     ),
     fields: PropTypes.objectOf(PropTypes.func),
     ArrayFieldTemplate: PropTypes.func,
+    ObjectFieldTemplate: PropTypes.func,
     FieldTemplate: PropTypes.func,
     ErrorList: PropTypes.func,
     onChange: PropTypes.func,
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index e16c367cd..bfbb034ad 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -7,6 +7,30 @@ import {
   getDefaultRegistry,
 } from "../../utils";
 
+function DefaultObjectFieldTemplate(props) {
+  const { TitleField, DescriptionField } = props;
+  return (
+    <fieldset>
+      {(props.uiSchema["ui:title"] || props.title) && (
+        <TitleField
+          id={`${props.idSchema.$id}__title`}
+          title={props.title || props.uiSchema["ui:title"]}
+          required={props.required}
+          formContext={props.formContext}
+        />
+      )}
+      {props.description && (
+        <DescriptionField
+          id={`${props.idSchema.$id}__description`}
+          description={props.description}
+          formContext={props.formContext}
+        />
+      )}
+      {props.properties.map(prop => prop.content)}
+    </fieldset>
+  );
+}
+
 class ObjectField extends Component {
   static defaultProps = {
     uiSchema: {},
@@ -50,7 +74,9 @@ class ObjectField extends Component {
     const { SchemaField, TitleField, DescriptionField } = fields;
     const schema = retrieveSchema(this.props.schema, definitions);
     const title = schema.title === undefined ? name : schema.title;
+    const description = uiSchema["ui:description"] || schema.description;
     let orderedProperties;
+
     try {
       const properties = Object.keys(schema.properties);
       orderedProperties = orderProperties(properties, uiSchema["ui:order"]);
@@ -65,27 +91,19 @@ class ObjectField extends Component {
         </div>
       );
     }
-    return (
-      <fieldset>
-        {(uiSchema["ui:title"] || title) && (
-          <TitleField
-            id={`${idSchema.$id}__title`}
-            title={uiSchema["ui:title"] || title}
-            required={required}
-            formContext={formContext}
-          />
-        )}
-        {(uiSchema["ui:description"] || schema.description) && (
-          <DescriptionField
-            id={`${idSchema.$id}__description`}
-            description={uiSchema["ui:description"] || schema.description}
-            formContext={formContext}
-          />
-        )}
-        {orderedProperties.map((name, index) => {
-          return (
+
+    const Template = registry.ObjectFieldTemplate || DefaultObjectFieldTemplate;
+
+    const templateProps = {
+      title: uiSchema["ui:title"] || title,
+      description,
+      TitleField,
+      DescriptionField,
+      properties: orderedProperties.map(name => {
+        return {
+          content: (
             <SchemaField
-              key={index}
+              key={name}
               name={name}
               required={this.isRequired(name)}
               schema={schema.properties[name]}
@@ -100,10 +118,21 @@ class ObjectField extends Component {
               disabled={disabled}
               readonly={readonly}
             />
-          );
-        })}
-      </fieldset>
-    );
+          ),
+          name,
+          readonly,
+          disabled,
+          required,
+        };
+      }),
+      required,
+      idSchema,
+      uiSchema,
+      schema,
+      formData,
+      formContext,
+    };
+    return <Template {...templateProps} />;
   }
 }
 
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index d720033e9..57a87c9ed 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -293,6 +293,7 @@ if (process.env.NODE_ENV !== "production") {
       fields: PropTypes.objectOf(PropTypes.func).isRequired,
       definitions: PropTypes.object.isRequired,
       ArrayFieldTemplate: PropTypes.func,
+      ObjectFieldTemplate: PropTypes.func,
       FieldTemplate: PropTypes.func,
       formContext: PropTypes.object.isRequired,
     }),
diff --git a/src/components/fields/UnsupportedField.js b/src/components/fields/UnsupportedField.js
index 9a0328c89..0418955e4 100644
--- a/src/components/fields/UnsupportedField.js
+++ b/src/components/fields/UnsupportedField.js
@@ -6,11 +6,11 @@ function UnsupportedField({ schema, idSchema, reason }) {
     <div className="unsupported-field">
       <p>
         Unsupported field schema{idSchema &&
-        idSchema.$id && (
-          <span>
-            {" for"} field <code>{idSchema.$id}</code>
-          </span>
-        )}
+          idSchema.$id && (
+            <span>
+              {" for"} field <code>{idSchema.$id}</code>
+            </span>
+          )}
         {reason && <em>: {reason}</em>}.
       </p>
       {schema && <pre>{JSON.stringify(schema, null, 2)}</pre>}
diff --git a/src/components/widgets/ColorWidget.js b/src/components/widgets/ColorWidget.js
index c892283ec..de622b0a6 100644
--- a/src/components/widgets/ColorWidget.js
+++ b/src/components/widgets/ColorWidget.js
@@ -1,10 +1,8 @@
 import React from "react";
 import PropTypes from "prop-types";
 
-import BaseInput from "./BaseInput";
-
 function ColorWidget(props) {
-  const { disabled, readonly } = props;
+  const { disabled, readonly, registry: { widgets: { BaseInput } } } = props;
   return <BaseInput type="color" {...props} disabled={disabled || readonly} />;
 }
 
diff --git a/src/components/widgets/DateTimeWidget.js b/src/components/widgets/DateTimeWidget.js
index a3cc3232e..eb7d1283d 100644
--- a/src/components/widgets/DateTimeWidget.js
+++ b/src/components/widgets/DateTimeWidget.js
@@ -14,7 +14,7 @@ function toJSONDate(dateString) {
 }
 
 function DateTimeWidget(props) {
-  const { value, onChange } = props;
+  const { value, onChange, registry: { widgets: { BaseInput } } } = props;
   return (
     <BaseInput
       type="datetime-local"
diff --git a/src/components/widgets/DateWidget.js b/src/components/widgets/DateWidget.js
index d5e583c9d..54a4ee38f 100644
--- a/src/components/widgets/DateWidget.js
+++ b/src/components/widgets/DateWidget.js
@@ -4,7 +4,7 @@ import PropTypes from "prop-types";
 import BaseInput from "./BaseInput";
 
 function DateWidget(props) {
-  const { onChange } = props;
+  const { onChange, registry: { widgets: { BaseInput } } } = props;
   return (
     <BaseInput
       type="date"
diff --git a/src/components/widgets/EmailWidget.js b/src/components/widgets/EmailWidget.js
index cb72a81f7..bdfe7a7ec 100644
--- a/src/components/widgets/EmailWidget.js
+++ b/src/components/widgets/EmailWidget.js
@@ -1,9 +1,8 @@
 import React from "react";
 import PropTypes from "prop-types";
 
-import BaseInput from "./BaseInput";
-
 function EmailWidget(props) {
+  const { BaseInput } = props.registry.widgets;
   return <BaseInput type="email" {...props} />;
 }
 
diff --git a/src/components/widgets/PasswordWidget.js b/src/components/widgets/PasswordWidget.js
index d41b60d37..397b1adca 100644
--- a/src/components/widgets/PasswordWidget.js
+++ b/src/components/widgets/PasswordWidget.js
@@ -1,9 +1,8 @@
 import React from "react";
 import PropTypes from "prop-types";
 
-import BaseInput from "./BaseInput";
-
 function PasswordWidget(props) {
+  const { BaseInput } = props.registry.widgets;
   return <BaseInput type="password" {...props} />;
 }
 
diff --git a/src/components/widgets/RangeWidget.js b/src/components/widgets/RangeWidget.js
index d91c3721e..6e7e11184 100644
--- a/src/components/widgets/RangeWidget.js
+++ b/src/components/widgets/RangeWidget.js
@@ -2,10 +2,9 @@ import React from "react";
 import PropTypes from "prop-types";
 
 import { rangeSpec } from "../../utils";
-import BaseInput from "./BaseInput";
 
 function RangeWidget(props) {
-  const { schema, value } = props;
+  const { schema, value, registry: { widgets: { BaseInput } } } = props;
   return (
     <div className="field-range-wrapper">
       <BaseInput type="range" {...props} {...rangeSpec(schema)} />
diff --git a/src/components/widgets/TextWidget.js b/src/components/widgets/TextWidget.js
index 182886b53..027e4e26f 100644
--- a/src/components/widgets/TextWidget.js
+++ b/src/components/widgets/TextWidget.js
@@ -1,9 +1,8 @@
 import React from "react";
 import PropTypes from "prop-types";
 
-import BaseInput from "./BaseInput";
-
 function TextWidget(props) {
+  const { BaseInput } = props.registry.widgets;
   return <BaseInput {...props} />;
 }
 
diff --git a/src/components/widgets/URLWidget.js b/src/components/widgets/URLWidget.js
index 0627e18ed..8b3448a86 100644
--- a/src/components/widgets/URLWidget.js
+++ b/src/components/widgets/URLWidget.js
@@ -1,9 +1,8 @@
 import React from "react";
 import PropTypes from "prop-types";
 
-import BaseInput from "./BaseInput";
-
 function URLWidget(props) {
+  const { BaseInput } = props.registry.widgets;
   return <BaseInput type="url" {...props} />;
 }
 
diff --git a/src/components/widgets/index.js b/src/components/widgets/index.js
index 095e78773..4f8818cc0 100644
--- a/src/components/widgets/index.js
+++ b/src/components/widgets/index.js
@@ -1,5 +1,6 @@
 import AltDateWidget from "./AltDateWidget";
 import AltDateTimeWidget from "./AltDateTimeWidget";
+import BaseInput from "./BaseInput";
 import CheckboxWidget from "./CheckboxWidget";
 import CheckboxesWidget from "./CheckboxesWidget";
 import ColorWidget from "./ColorWidget";
@@ -18,6 +19,7 @@ import URLWidget from "./URLWidget";
 import UpDownWidget from "./UpDownWidget";
 
 export default {
+  BaseInput,
   PasswordWidget,
   RadioWidget,
   UpDownWidget,


Test patch:
diff --git a/test/ArrayFieldTemplate_test.js b/test/ArrayFieldTemplate_test.js
index f65d5140d..b6bad381e 100644
--- a/test/ArrayFieldTemplate_test.js
+++ b/test/ArrayFieldTemplate_test.js
@@ -39,7 +39,7 @@ describe("ArrayFieldTemplate", () => {
       );
     }
 
-    describe("Statefull ArrayFieldTemplate", () => {
+    describe("Stateful ArrayFieldTemplate", () => {
       class ArrayFieldTemplate extends PureComponent {
         render() {
           return <div>{this.props.items.map(item => item.element)}</div>;
diff --git a/test/ObjectFieldTemplate_test.js b/test/ObjectFieldTemplate_test.js
new file mode 100644
index 000000000..9a406dc75
--- /dev/null
+++ b/test/ObjectFieldTemplate_test.js
@@ -0,0 +1,77 @@
+import React, { PureComponent } from "react";
+
+import { expect } from "chai";
+import { createFormComponent, createSandbox } from "./test_utils";
+
+describe("ObjectFieldTemplate", () => {
+  let sandbox;
+
+  const formData = { foo: "bar", bar: "foo" };
+
+  beforeEach(() => {
+    sandbox = createSandbox();
+  });
+
+  afterEach(() => {
+    sandbox.restore();
+  });
+
+  class ObjectFieldTemplate extends PureComponent {
+    render() {
+      const {
+        TitleField,
+        DescriptionField,
+        properties,
+        title,
+        description,
+      } = this.props;
+      return (
+        <div className="root">
+          <TitleField title={title} />
+          <DescriptionField description={description} />
+          <div>
+            {properties.map(({ content, index }) => (
+              <div key={index} className="property">
+                {content}
+              </div>
+            ))}
+          </div>
+        </div>
+      );
+    }
+  }
+
+  const TitleField = () => <div className="title-field" />;
+  const DescriptionField = ({ description }) =>
+    description ? <div className="description-field" /> : null;
+
+  const { node } = createFormComponent({
+    schema: {
+      type: "object",
+      properties: { foo: { type: "string" }, bar: { type: "string" } },
+    },
+    uiSchema: { "ui:description": "foobar" },
+    formData,
+    ObjectFieldTemplate,
+    fields: {
+      TitleField,
+      DescriptionField,
+    },
+  });
+
+  it("should render one root element", () => {
+    expect(node.querySelectorAll(".root")).to.have.length.of(1);
+  });
+
+  it("should render one title", () => {
+    expect(node.querySelectorAll(".title-field")).to.have.length.of(1);
+  });
+
+  it("should render one description", () => {
+    expect(node.querySelectorAll(".description-field")).to.have.length.of(1);
+  });
+
+  it("should render two property containers", () => {
+    expect(node.querySelectorAll(".property")).to.have.length.of(2);
+  });
+});