Repository: mozilla-services/react-jsonschema-form
Pull request #7 (2015-12-23 15:00:12)

Fixed defaults initial propagation.
r=? @Natim 


Patch:
diff --git a/fixtures/Form/deeply-nested-schema.js b/fixtures/Form/deeply-nested-schema.js
new file mode 100644
index 000000000..66c361342
--- /dev/null
+++ b/fixtures/Form/deeply-nested-schema.js
@@ -0,0 +1,27 @@
+module.exports = {
+  schema: {
+    type: "object",
+    title: "lvl 1 obj",
+    properties: {
+      object: {
+        type: "object",
+        title: "lvl 2 obj",
+        properties: {
+          array: {
+            type: "array",
+            items: {
+              type: "object",
+              title: "lvl 3 obj",
+              properties: {
+                bool: {
+                  type: "boolean",
+                  default: true
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+};
diff --git a/fixtures/Form/nested-default.js b/fixtures/Form/nested-default.js
index cac7a9056..eca767b84 100644
--- a/fixtures/Form/nested-default.js
+++ b/fixtures/Form/nested-default.js
@@ -28,7 +28,8 @@ module.exports = {
             done: {
               type: "boolean",
               title: "Done?",
-              description: "Is that task done already?"
+              description: "Is that task done already?",
+              default: true,
             },
             title: {
               type: "string",
diff --git a/fixtures/Form/nested-uiSchema-default.js b/fixtures/Form/nested-uiSchema-default.js
new file mode 100644
index 000000000..77d6f737d
--- /dev/null
+++ b/fixtures/Form/nested-uiSchema-default.js
@@ -0,0 +1,63 @@
+module.exports = {
+  schema: {
+    title: "Todo Tasks",
+    description: "Tasks collection.",
+    type: "object",
+    additionalProperties: false,
+    required: [
+      "title", "tasks"
+    ],
+    properties: {
+      title: {
+        type: "string",
+        title: "Tasks list title",
+      },
+      tasks: {
+        type: "array",
+        title: "Tasks list",
+        items: {
+          type: "object",
+          properties: {
+            type: {
+              type: "string",
+              title: "Category",
+              enum: ["coding", "sleeping"]
+            },
+            done: {
+              type: "boolean",
+              title: "Done?",
+              description: "Is that task done already?",
+              default: false
+            },
+            title: {
+              type: "string",
+              title: "Title",
+              description: "The task title.",
+              minLength: 1
+            }
+          }
+        }
+      }
+    }
+  },
+  uiSchema: {
+    title: {
+      widget: "textarea",
+    },
+    tasks: {
+      items: {
+        type: {
+          widget: "radio"
+        },
+        done: {
+          widget: "select",
+        },
+        title: {
+          widget: "textarea"
+        }
+      }
+    }
+  },
+  onSubmit: console.log.bind(console, "submit"),
+  onError: console.log.bind(console, "errors")
+};
diff --git a/package.json b/package.json
index 6fd3ff80c..6d6a92f49 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
     "eslint-plugin-react": "^3.6.3",
     "express": "^4.13.3",
     "gh-pages": "^0.4.0",
+    "html": "0.0.10",
     "jsdom": "^7.2.1",
     "mocha": "^2.3.0",
     "react-addons-test-utils": "^0.14.3",
diff --git a/src/components/Form.js b/src/components/Form.js
index 77faef933..08a73d212 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -1,6 +1,7 @@
 import React, { Component, PropTypes } from "react";
 import { Validator } from "jsonschema";
 
+import { getDefaultFormState } from "../utils";
 import SchemaField from "./fields/SchemaField";
 import ErrorList from "./ErrorList";
 
@@ -13,7 +14,7 @@ export default class Form extends Component {
   constructor(props) {
     super(props);
     const edit = !!props.formData;
-    const formData = props.formData || props.schema.default || null;
+    const formData = props.formData || getDefaultFormState(props.schema) || null;
     this.state = {
       status: "initial",
       formData,
@@ -28,18 +29,18 @@ export default class Form extends Component {
   }
 
   renderErrors() {
-    const {edit, status, errors} = this.state;
-    if (edit && status !== "editing" && errors.length) {
+    const {status, errors} = this.state;
+    if (status !== "editing" && errors.length) {
       return <ErrorList errors={errors} />;
     }
     return null;
   }
 
-  onChange(formData) {
+  onChange(formData, options={validate: true}) {
     this.setState({
       status: "editing",
       formData,
-      errors: this.validate(formData)
+      errors: options.validate ? this.validate(formData) : this.state.errors
     }, _ => {
       if (this.props.onChange) {
         this.props.onChange(this.state);
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index 0392116cd..c01c79049 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -1,6 +1,6 @@
 import React, { Component, PropTypes } from "react";
 
-import { defaultTypeValue } from "../../utils";
+import { getDefaultFormState } from "../../utils";
 import SchemaField from "./SchemaField";
 
 
@@ -12,7 +12,7 @@ class ArrayField extends Component {
   constructor(props) {
     super(props);
     const formData = Array.isArray(props.formData) ? props.formData : null;
-    this.state = {items: formData || props.schema.default || []};
+    this.state = {items: formData || getDefaultFormState(props.schema) || []};
   }
 
   get itemTitle() {
@@ -20,34 +20,29 @@ class ArrayField extends Component {
     return schema.items.title || schema.items.description || "Item";
   }
 
-  defaultItem(itemsSchema) {
-    if (itemsSchema.default) {
-      return itemsSchema.default;
-    }
-    return defaultTypeValue(itemsSchema.type);
-  }
-
   isItemRequired(itemsSchema) {
     return itemsSchema.type === "string" && itemsSchema.minLength > 0;
   }
 
-  asyncSetState(state) {
+  asyncSetState(state, options) {
     // ensure state is propagated to parent component when it's actually set
-    this.setState(state, _ => this.props.onChange(this.state.items));
+    this.setState(state, _ => this.props.onChange(this.state.items, options));
   }
 
   onAddClick(event) {
     event.preventDefault();
-    this.setState({
-      items: this.state.items.concat(this.defaultItem(this.props.schema.items))
-    });
+    const {items} = this.state;
+    const {schema} = this.props;
+    this.asyncSetState({
+      items: items.concat(getDefaultFormState(schema.items))
+    }, {validate: false});
   }
 
   onDropClick(index, event) {
     event.preventDefault();
-    this.setState({
+    this.asyncSetState({
       items: this.state.items.filter((_, i) => i !== index)
-    });
+    }, {validate: false});
   }
 
   onChange(index, value) {
@@ -55,7 +50,7 @@ class ArrayField extends Component {
       items: this.state.items.map((item, i) => {
         return index === i ? value : item;
       })
-    });
+    }, {validate: false});
   }
 
   render() {
@@ -68,17 +63,19 @@ class ArrayField extends Component {
         {schema.description ? <div>{schema.description}</div> : null}
         <div className="array-item-list">{
           items.map((item, index) => {
-            return <div key={index}>
-              <SchemaField
-                schema={schema.items}
-                uiSchema={uiSchema.items}
-                formData={items[index]}
-                required={this.isItemRequired(schema.items)}
-                onChange={this.onChange.bind(this, index)} />
-              <p className="array-item-remove">
-                <button type="button"
-                  onClick={this.onDropClick.bind(this, index)}>-</button></p>
-            </div>;
+            return (
+              <div key={index}>
+                <SchemaField
+                  schema={schema.items}
+                  uiSchema={uiSchema.items}
+                  formData={items[index]}
+                  required={this.isItemRequired(schema.items)}
+                  onChange={this.onChange.bind(this, index)} />
+                <p className="array-item-remove">
+                  <button type="button"
+                    onClick={this.onDropClick.bind(this, index)}>-</button></p>
+              </div>
+            );
           })
         }</div>
         <p className="array-item-add">
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index 2ef09b780..9526686d9 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -1,5 +1,6 @@
 import React, { Component, PropTypes } from "react";
 
+import { getDefaultFormState } from "../../utils";
 import SchemaField from "./SchemaField";
 
 
@@ -10,7 +11,7 @@ class ObjectField extends Component {
 
   constructor(props) {
     super(props);
-    this.state = props.formData || props.schema.default || {};
+    this.state = props.formData || getDefaultFormState(props.schema) || {};
   }
 
   isRequired(name) {
diff --git a/src/utils.js b/src/utils.js
index 32fbf7f59..52d2565ac 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -2,6 +2,7 @@ import RadioWidget from "./components/widgets/RadioWidget";
 import SelectWidget from "./components/widgets/SelectWidget";
 import TextareaWidget from "./components/widgets/TextareaWidget";
 
+
 export function defaultTypeValue(type) {
   switch (type) {
   case "array":     return [];
@@ -26,3 +27,19 @@ export function getAlternativeWidget(name) {
   default: throw new Error(`No alternative widget for "${name}"`);
   }
 }
+
+export function getDefaultFormState(schema) {
+  if (typeof schema !== "object") {
+    throw new Error("Invalid schema: " + schema);
+  }
+  if ("default" in schema) {
+    return schema.default;
+  }
+  if (schema.type === "object") {
+    return Object.keys(schema.properties).reduce((acc, key) => {
+      acc[key] = getDefaultFormState(schema.properties[key]);
+      return acc;
+    }, {});
+  }
+  return defaultTypeValue(schema.type);
+}


Test patch:
diff --git a/test/index_test.js b/test/index_test.js
index 772f5fdd7..bf90bd191 100644
--- a/test/index_test.js
+++ b/test/index_test.js
@@ -15,7 +15,7 @@ function createComponent(props) {
 }
 
 function d(node) {
-  console.log(node.outerHTML);
+  console.log(require("html").prettyPrint(node.outerHTML, {indent_size: 2}));
 }
 
 describe("Form", () => {
@@ -600,6 +600,51 @@ describe("Form", () => {
     });
   });
 
+  describe("Defaults array items default propagation", () => {
+    const schema = {
+      type: "object",
+      title: "lvl 1 obj",
+      properties: {
+        object: {
+          type: "object",
+          title: "lvl 2 obj",
+          properties: {
+            array: {
+              type: "array",
+              items: {
+                type: "object",
+                title: "lvl 3 obj",
+                properties: {
+                  bool: {
+                    type: "boolean",
+                    default: true
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    };
+
+    it("should propagate deeply nested defaults to form state", () => {
+      const {comp, node} = createComponent({schema});
+
+      Simulate.click(node.querySelector(".array-item-add button"));
+      Simulate.submit(node);
+
+      expect(comp.state.formData).eql({
+        object: {
+          array: [
+            {
+              bool: true
+            }
+          ]
+        }
+      });
+    });
+  });
+
   describe("Validation", () => {
     describe("Required fields", () => {
       const schema = {
diff --git a/test/utils_test.js b/test/utils_test.js
new file mode 100644
index 000000000..84a169431
--- /dev/null
+++ b/test/utils_test.js
@@ -0,0 +1,83 @@
+import { expect } from "chai";
+
+import { getDefaultFormState } from "../src/utils";
+
+
+describe("utils", () => {
+  describe("getDefaultFormState()", () => {
+    describe("root default", () => {
+      it("should map root schema default to form state, if any", () => {
+        expect(getDefaultFormState({
+          type: "string",
+          default: "foo",
+        })).to.eql("foo");
+      });
+    });
+
+    describe("nested default", () => {
+      it("should map schema object prop default to form state", () => {
+        expect(getDefaultFormState({
+          type: "object",
+          properties: {
+            string: {
+              type: "string",
+              default: "foo",
+            }
+          }
+        })).to.eql({string: "foo"});
+      });
+
+      it("should recursively map schema object default to form state", () => {
+        expect(getDefaultFormState({
+          type: "object",
+          properties: {
+            object: {
+              type: "object",
+              properties: {
+                string: {
+                  type: "string",
+                  default: "foo",
+                }
+              }
+            }
+          }
+        })).to.eql({object: {string: "foo"}});
+      });
+
+      it("should map schema array default to form state", () => {
+        expect(getDefaultFormState({
+          type: "object",
+          properties: {
+            array: {
+              type: "array",
+              default: ["foo", "bar"],
+              items: {
+                type: "string"
+              }
+            }
+          }
+        })).to.eql({array: ["foo", "bar"]});
+      });
+
+      it("should recursively map schema array default to form state", () => {
+        expect(getDefaultFormState({
+          type: "object",
+          properties: {
+            object: {
+              type: "object",
+              properties: {
+                array: {
+                  type: "array",
+                  default: ["foo", "bar"],
+                  items: {
+                    type: "string"
+                  }
+                }
+              }
+            }
+          }
+        })).to.eql({object: {array: ["foo", "bar"]}});
+      });
+    });
+  });
+});