Repository: mozilla-services/react-jsonschema-form
Pull request #64 (2016-03-14 12:49:30)

Schema defaults propagation
Supersedes #60.


Patch:
diff --git a/src/components/Form.js b/src/components/Form.js
index 224eaec4a..98fed2f7e 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -22,7 +22,7 @@ export default class Form extends Component {
   getStateFromProps(props) {
     const schema = "schema" in props ? props.schema : this.props.schema;
     const edit = !!props.formData;
-    const formData = props.formData || getDefaultFormState(schema) || null;
+    const formData = getDefaultFormState(schema, props.formData) || null;
     return {
       status: "initial",
       formData,
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index 5d23dcab4..a179d1a93 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -20,7 +20,7 @@ class ArrayField extends Component {
 
   getStateFromProps(props) {
     const formData = Array.isArray(props.formData) ? props.formData : null;
-    return {items: formData || getDefaultFormState(props.schema) || []};
+    return {items: getDefaultFormState(props.schema, formData) || []};
   }
 
   get itemTitle() {
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index f7603f103..7b12f047d 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -18,7 +18,7 @@ class ObjectField extends Component {
   }
 
   getStateFromProps(props) {
-    return props.formData || getDefaultFormState(props.schema) || {};
+    return getDefaultFormState(props.schema, props.formData) || {};
   }
 
   isRequired(name) {
diff --git a/src/utils.js b/src/utils.js
index 320b94674..436b7538a 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -59,20 +59,63 @@ export function getAlternativeWidget(type, widget) {
   return altWidgetMap[type][widget];
 }
 
-export function getDefaultFormState(schema) {
-  if (typeof schema !== "object") {
-    throw new Error("Invalid schema: " + schema);
+function computeDefaults(schema, parentDefaults) {
+  // Compute the defaults recursively: give highest priority to deepest nodes.
+  let defaults = parentDefaults;
+  if (isObject(defaults) && isObject(schema.default)) {
+    // For object defaults, only override parent defaults that are defined in
+    // schema.default.
+    defaults = mergeObjects(defaults, schema.default);
+  } else if ("default" in schema) {
+    // Use schema defaults for this node.
+    defaults = schema.default;
   }
-  if ("default" in schema) {
-    return schema.default;
+  // Not defaults defined for this node, fallback to generic typed ones.
+  if (typeof(defaults) === "undefined") {
+    defaults = defaultTypeValue(schema.type);
   }
+  // We need to recur for object schema inner default values.
   if (schema.type === "object") {
     return Object.keys(schema.properties).reduce((acc, key) => {
-      acc[key] = getDefaultFormState(schema.properties[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]);
       return acc;
     }, {});
   }
-  return defaultTypeValue(schema.type);
+  return defaults;
+}
+
+export function getDefaultFormState(schema, formData) {
+  if (!isObject(schema)) {
+    throw new Error("Invalid schema: " + schema);
+  }
+  const defaults = computeDefaults(schema);
+  if (typeof(formData) === "undefined") { // No form data? Use schema defaults.
+    return defaults;
+  }
+  if (isObject(formData)) { // Override schema defaults with form data.
+    return mergeObjects(defaults, formData);
+  }
+  return formData || defaults;
+}
+
+function isObject(thing) {
+  return typeof(thing) == "object" && thing !== null && !Array.isArray(thing);
+}
+
+export function mergeObjects(obj1, obj2) {
+  // Recursively merge deeply nested objects.
+  var acc = Object.assign({}, obj1); // Prevent mutation of source object.
+  return Object.keys(obj2).reduce((acc, key) =>{
+    const right = obj2[key];
+    if (obj1.hasOwnProperty(key) && isObject(right)) {
+      acc[key] = mergeObjects(obj1[key], right);
+    } else {
+      acc[key] = right;
+    }
+    return acc;
+  }, acc);
 }
 
 export function asNumber(value) {


Test patch:
diff --git a/test/utils_test.js b/test/utils_test.js
index 9b7852bd1..a75f2ad27 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -1,6 +1,11 @@
 import { expect } from "chai";
 
-import { asNumber, getDefaultFormState, isMultiSelect } from "../src/utils";
+import {
+  asNumber,
+  getDefaultFormState,
+  isMultiSelect,
+  mergeObjects
+} from "../src/utils";
 
 
 describe("utils", () => {
@@ -78,6 +83,93 @@ describe("utils", () => {
           }
         })).to.eql({object: {array: ["foo", "bar"]}});
       });
+
+      it("should propagate nested defaults to resulting formData by default", () => {
+        const schema = {
+          type: "object",
+          properties: {
+            object: {
+              type: "object",
+              properties: {
+                array: {
+                  type: "array",
+                  default: ["foo", "bar"],
+                  items: {
+                    type: "string"
+                  }
+                },
+                bool: {
+                  type: "boolean",
+                  default: true
+                }
+              }
+            }
+          }
+        };
+        expect(getDefaultFormState(schema, {}))
+          .eql({object: {array: ["foo", "bar"], bool: true}});
+      });
+
+      it("should keep parent defaults if they don't have a node level default", () => {
+        const schema = {
+          type: "object",
+          properties: {
+            level1: {
+              type: "object",
+              default: {level2: {leaf1: 1, leaf2: 1, leaf3: 1, leaf4: 1}},
+              properties: {
+                level2: {
+                  type: "object",
+                  default: {
+                    // No level2 default for leaf1
+                    leaf2: 2,
+                    leaf3: 2
+                  },
+                  properties: {
+                    leaf1: {type: "number"}, // No level2 default for leaf1
+                    leaf2: {type: "number"}, // No level3 default for leaf2
+                    leaf3: {type: "number", default: 3},
+                    leaf4: {type: "number"} // Defined in formData.
+                  }
+                },
+              }
+            }
+          }
+        };
+        expect(getDefaultFormState(schema, {level1: {level2: {leaf4: 4}}}))
+          .eql({level1: {level2: {leaf1: 1, leaf2: 2, leaf3: 3, leaf4: 4}}});
+      });
+
+      it("should use parent defaults for ArrayFields", () => {
+        const schema = {
+          type: "object",
+          properties: {
+            level1: {
+              type: "array",
+              default: [1, 2, 3],
+              items: {type: "number"}
+            }
+          }
+        };
+        expect(getDefaultFormState(schema, {}))
+          .eql({level1: [1, 2, 3]});
+      });
+
+      it("should use parent defaults for ArrayFields if declared in parent", () => {
+        const schema = {
+          type: "object",
+          default: {level1: [1, 2, 3]},
+          properties: {
+            level1: {
+              type: "array",
+              items: {type: "number"}
+            }
+          }
+        };
+        expect(getDefaultFormState(schema, {}))
+          .eql({level1: [1, 2, 3]});
+      });
+
     });
   });
 
@@ -111,4 +203,52 @@ describe("utils", () => {
       expect(isMultiSelect(schema)).to.be.false;
     });
   });
+
+  describe("mergeObjects()", () => {
+    it("should't mutate the provided objects", () => {
+      const obj1 = {a: 1};
+      mergeObjects(obj1, {b: 2});
+      expect(obj1).eql({a: 1});
+    });
+
+    it("should merge two one-level deep objects", () => {
+      expect(mergeObjects({a: 1}, {b: 2})).eql({a: 1, b: 2});
+    });
+
+    it("should override the first object with the values from the second", () => {
+      expect(mergeObjects({a: 1}, {a: 2})).eql({a: 2});
+    });
+
+    it("should recursively merge deeply nested objects", () => {
+      const obj1 = {
+        a: 1,
+        b: {
+          c: 3,
+          d: [1, 2, 3],
+          e: {f: {g: 1}}
+        },
+        c: 2
+      };
+      const obj2 = {
+        a: 1,
+        b: {
+          d: [3, 2, 1],
+          e: {f: {h: 2}},
+          g: 1
+        },
+        c: 3
+      };
+      const expected = {
+        a: 1,
+        b: {
+          c: 3,
+          d: [3, 2, 1],
+          e: {f: {g: 1, h: 2}},
+          g: 1
+        },
+        c: 3
+      };
+      expect(mergeObjects(obj1, obj2)).eql(expected);
+    });
+  });
 });