Repository: mozilla-services/react-jsonschema-form
Pull request #85 (2016-03-23 13:24:12)

Refs #23: Per-field contextualized errors.
Refs #23, WiP, do not merge.

This patch renders errors at the field level.

![](http://i.imgur.com/V2nmw8B.png)

Feedback=? @magopian  


Patch:
diff --git a/playground/samples/errors.js b/playground/samples/errors.js
new file mode 100644
index 000000000..e6b7dd76e
--- /dev/null
+++ b/playground/samples/errors.js
@@ -0,0 +1,37 @@
+module.exports = {
+  schema: {
+    title: "Contextualized errors",
+    type: "object",
+    properties: {
+      firstName: {
+        type: "string",
+        title: "First name",
+        minLength: 8,
+        pattern: "\\d+"
+      },
+      skills: {
+        type: "array",
+        items: {
+          type: "string",
+          minLength: 5
+        }
+      },
+      multipleChoicesList: {
+        type: "array",
+        title: "Pick max two items",
+        uniqueItems: true,
+        maxItems: 2,
+        items: {
+          type: "string",
+          enum: ["foo", "bar", "fuzz"]
+        }
+      }
+    }
+  },
+  uiSchema: {},
+  formData: {
+    firstName: "Chuck",
+    skills: ["karate", "budo", "aikido"],
+    multipleChoicesList: ["foo", "bar", "fuzz"]
+  }
+};
diff --git a/playground/samples/index.js b/playground/samples/index.js
index e00a798ec..5dc0bfd0e 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -6,6 +6,7 @@ import widgets from "./widgets";
 import ordering from "./ordering";
 import references from "./references";
 import custom from "./custom";
+import errors from "./errors";
 
 export const samples = {
   Simple: simple,
@@ -16,4 +17,5 @@ export const samples = {
   Ordering: ordering,
   References: references,
   Custom: custom,
+  Errors: errors,
 };
diff --git a/playground/styles.css b/playground/styles.css
index ecc845188..f392df22c 100644
--- a/playground/styles.css
+++ b/playground/styles.css
@@ -32,3 +32,16 @@
   width: 160px;
   margin-left: 4px;
 }
+
+.rjsf .field-error input[type=text],
+.rjsf .field-error select {
+  background-color: pink;
+}
+
+.rjsf .field-error .error-detail {
+  color: red;
+}
+
+.rjsf .errors {
+  color: red;
+}
diff --git a/src/components/Form.js b/src/components/Form.js
index a625d1068..5fc7f39d8 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -3,9 +3,10 @@ import { Validator } from "jsonschema";
 
 import SchemaField from "./fields/SchemaField";
 import TitleField from "./fields/TitleField";
-import { getDefaultFormState, shouldRender } from "../utils";
+import { getDefaultFormState, shouldRender, toErrorSchema } from "../utils";
 import ErrorList from "./ErrorList";
 
+
 export default class Form extends Component {
   static defaultProps = {
     uiSchema: {}
@@ -28,12 +29,9 @@ export default class Form extends Component {
     const edit = !!props.formData;
     const {definitions} = schema;
     const formData = getDefaultFormState(schema, props.formData, definitions) || null;
-    return {
-      status: "initial",
-      formData,
-      edit,
-      errors: edit ? this.validate(formData, schema) : []
-    };
+    const errors = edit ? this.validate(formData, schema) : [];
+    const errorSchema = toErrorSchema(errors);
+    return {status: "initial", formData, edit, errors, errorSchema};
   }
 
   shouldComponentUpdate(nextProps, nextState) {
@@ -102,7 +100,7 @@ export default class Form extends Component {
 
   render() {
     const {children, schema, uiSchema} = this.props;
-    const {formData} = this.state;
+    const {formData, errorSchema} = this.state;
     const registry = this.getRegistry();
     const _SchemaField = registry.fields.SchemaField;
     return (
@@ -111,6 +109,7 @@ export default class Form extends Component {
         <_SchemaField
           schema={schema}
           uiSchema={uiSchema}
+          errorSchema={errorSchema}
           formData={formData}
           onChange={this._onChange}
           registry={registry}/>
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index 436100fe4..fd5b464ee 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -87,7 +87,7 @@ class ArrayField extends Component {
   }
 
   render() {
-    const {schema, uiSchema, name} = this.props;
+    const {schema, uiSchema, errorSchema, name} = this.props;
     const title = schema.title || name;
     const {items} = this.state;
     const {fields, definitions} = this.props.registry;
@@ -115,12 +115,14 @@ class ArrayField extends Component {
           <div className="field-description">{schema.description}</div> : null}
         <div className="array-item-list">{
           items.map((item, index) => {
+            const itemErrorSchema = errorSchema ? errorSchema[index] : undefined;
             return (
               <div key={index}>
                 <SchemaField
                   schema={itemsSchema}
                   uiSchema={uiSchema.items}
                   formData={items[index]}
+                  errorSchema={itemErrorSchema}
                   required={this.isItemRequired(itemsSchema)}
                   onChange={this._onChangeForIndex(index)}
                   registry={this.props.registry}/>
@@ -143,6 +145,7 @@ if (process.env.NODE_ENV !== "production") {
   ArrayField.propTypes = {
     schema: PropTypes.object.isRequired,
     uiSchema: PropTypes.object,
+    errorSchema: PropTypes.object,
     onChange: PropTypes.func.isRequired,
     formData: PropTypes.array,
     registry: PropTypes.shape({
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index a557be2d8..1ee41dc5d 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -10,7 +10,8 @@ import {
 
 class ObjectField extends Component {
   static defaultProps = {
-    uiSchema: {}
+    uiSchema: {},
+    errorSchema: {},
   }
 
   constructor(props) {
@@ -50,7 +51,7 @@ class ObjectField extends Component {
   }
 
   render() {
-    const {uiSchema, name} = this.props;
+    const {uiSchema, errorSchema, name} = this.props;
     const {definitions, fields} = this.props.registry;
     const {SchemaField, TitleField} = fields;
     const schema = retrieveSchema(this.props.schema, definitions);
@@ -83,6 +84,7 @@ class ObjectField extends Component {
               required={this.isRequired(name)}
               schema={schema.properties[name]}
               uiSchema={uiSchema[name]}
+              errorSchema={errorSchema[name]}
               formData={this.state[name]}
               onChange={this._onPropertyChange(name)}
               registry={this.props.registry} />
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index 8a507aaf2..861d51b64 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -53,11 +53,30 @@ function getContent({type, label, required, children, displayLabel}) {
   );
 }
 
+function ErrorList({errors}) {
+  return (
+    <div className="error-detail">{
+      (errors || []).map((error, index) => {
+        return <li key={index}>{error}</li>;
+      })
+    }</div>
+  );
+}
+
 function Wrapper(props) {
-  const {type, classNames} = props;
+  const {type, classNames, errorSchema} = props;
+  const {errors} = errorSchema;
+  const isError = errors && errors.length > 0;
+  const classList = [
+    "field",
+    `field-${type}`,
+    isError ? "field-error" : "",
+    classNames,
+  ].join(" ").trim();
   return (
-    <div className={`field field-${type} ${classNames}`}>
+    <div className={classList}>
       {getContent(props)}
+      {isError ? <ErrorList errors={errors} /> : <div/>}
     </div>
   );
 }
@@ -78,7 +97,7 @@ Wrapper.defaultProps = {
 };
 
 function SchemaField(props) {
-  const {uiSchema, name, required, registry} = props;
+  const {uiSchema, errorSchema, name, required, registry} = props;
   const {definitions, fields} = registry;
   const schema = retrieveSchema(props.schema, definitions);
   const FieldComponent = getFieldComponent(schema, uiSchema, fields);
@@ -98,6 +117,7 @@ function SchemaField(props) {
   return (
     <Wrapper
       label={schema.title || name}
+      errorSchema={errorSchema}
       required={required}
       type={schema.type}
       displayLabel={displayLabel}
@@ -108,13 +128,16 @@ function SchemaField(props) {
 }
 
 SchemaField.defaultProps = {
-  uiSchema: {}
+  uiSchema: {},
+  errorSchema: {},
 };
 
 if (process.env.NODE_ENV !== "production") {
   SchemaField.propTypes = {
     schema: PropTypes.object.isRequired,
     uiSchema: PropTypes.object,
+    formData: PropTypes.any,
+    errorSchema: PropTypes.object,
     registry: PropTypes.shape({
       widgets: PropTypes.objectOf(PropTypes.func).isRequired,
       fields: PropTypes.objectOf(PropTypes.func).isRequired,
diff --git a/src/utils.js b/src/utils.js
index 0ad104278..89812c2cc 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -8,6 +8,8 @@ import SelectWidget from "./components/widgets/SelectWidget";
 import TextareaWidget from "./components/widgets/TextareaWidget";
 
 
+const RE_ERROR_ARRAY_PATH = /(.*)\[(\d+)\]$/;
+
 const altWidgetMap = {
   boolean: {
     radio: RadioWidget,
@@ -192,3 +194,55 @@ export function retrieveSchema(schema, definitions={}) {
 export function shouldRender(comp, nextProps, nextState) {
   return !deeper(comp.props, nextProps) || !deeper(comp.state, nextState);
 }
+
+function errorPropertyToPath(property) {
+  // Parse array indices, eg. "instance.level1.level2[2].level3"
+  // => ["instance", "level1", "level2", 2, "level3"]
+  return property.split(".").reduce((path, node) => {
+    const match = RE_ERROR_ARRAY_PATH.exec(node);
+    if (match) {
+      path = path.concat([match[1], parseInt(match[2], 10)]);
+    } else {
+      path.push(node);
+    }
+    return path;
+  }, []);
+}
+
+export function toErrorSchema(errors) {
+  // Transforms a jsonschema validation errors list:
+  // [
+  //   {property: "instance.level1.level2[2].level3", message: "err a"},
+  //   {property: "instance.level1.level2[2].level3", message: "err b"},
+  //   {property: "instance.level1.level2[4].level3", message: "err b"},
+  // ]
+  // Into an error tree:
+  // {
+  //   level1: {
+  //     level2: {
+  //       2: {level3: {errors: ["err a", "err b"]}},
+  //       4: {level3: {errors: ["err b"]}},
+  //     }
+  //   }
+  // };
+  if (!errors.length) {
+    return {};
+  }
+  return errors.reduce((errorSchema, error) => {
+    const {property, message} = error;
+    const path = errorPropertyToPath(property);
+    let parent = errorSchema;
+    for (const segment of path.slice(1)) {
+      if (!(segment in parent)) {
+        parent[segment] = {};
+      }
+      parent = parent[segment];
+    }
+    if (Array.isArray(parent.errors)) {
+      parent.errors = parent.errors.concat(message);
+    } else {
+      parent.errors = [message];
+    }
+    return errorSchema;
+  }, {});
+}


Test patch:
diff --git a/test/Form_test.js b/test/Form_test.js
index 15e322c11..960f23dbc 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -8,7 +8,7 @@ import { Simulate, renderIntoDocument } from "react-addons-test-utils";
 import { findDOMNode } from "react-dom";
 
 import Form from "../src";
-import { createFormComponent } from "./test_utils";
+import { createFormComponent, d } from "./test_utils";
 
 describe("Form", () => {
   var sandbox;
@@ -493,4 +493,238 @@ describe("Form", () => {
       });
     });
   });
+
+  describe("Error contextualization", () => {
+    describe("root level", () => {
+      const schema = {
+        type: "string",
+        minLength: 8
+      };
+
+      it("should reflect the contextualized error in state", () => {
+        const {comp} = createFormComponent({schema, formData: "short"});
+
+        expect(comp.state.errorSchema).eql({
+          errors: ["does not meet minimum length of 8"]
+        });
+      });
+
+      it("should denote the error in the field", () => {
+        const {node} = createFormComponent({schema, formData: "short"});
+
+        expect(node.querySelectorAll(".field-error"))
+          .to.have.length.of(1);
+        expect(node.querySelector(".field-string .error-detail").textContent)
+          .eql("does not meet minimum length of 8");
+      });
+    });
+
+    describe("root level with multiple errors", () => {
+      const schema = {
+        type: "string",
+        minLength: 8,
+        pattern: "\d+"
+      };
+
+      it("should reflect the contextualized error in state", () => {
+        const {comp} = createFormComponent({schema, formData: "short"});
+        expect(comp.state.errorSchema).eql({
+          errors: [
+            "does not meet minimum length of 8",
+            `does not match pattern "\d+"`
+          ]
+        });
+      });
+
+      it("should denote the error in the field", () => {
+        const {node} = createFormComponent({schema, formData: "short"});
+
+        const liNodes = node.querySelectorAll(".field-string .error-detail li");
+        const errors = [].map.call(liNodes, li => li.textContent);
+
+        expect(errors).eql([
+          "does not meet minimum length of 8",
+          `does not match pattern "\d+"`
+        ]);
+      });
+    });
+
+    describe("nested field level", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          level1: {
+            type: "object",
+            properties: {
+              level2: {
+                type: "string",
+                minLength: 8
+              }
+            }
+          }
+        }
+      };
+
+      it("should reflect the contextualized error in state", () => {
+        const {comp} = createFormComponent({schema, formData: {
+          level1: {
+            level2: "short"
+          }
+        }});
+
+        expect(comp.state.errorSchema).eql({
+          level1: {
+            level2: {
+              errors: ["does not meet minimum length of 8"]
+            }
+          }
+        });
+      });
+
+      it("should denote the error in the field", () => {
+        const {node} = createFormComponent({schema, formData: {
+          level1: {
+            level2: "short"
+          }
+        }});
+        const errorDetail = node.querySelector(
+          ".field-object .field-string .error-detail");
+
+        expect(node.querySelectorAll(".field-error"))
+          .to.have.length.of(1);
+        expect(errorDetail.textContent)
+          .eql("does not meet minimum length of 8");
+      });
+    });
+
+    describe("array indices", () => {
+      const schema = {
+        type: "array",
+        items: {
+          type: "string",
+          minLength: 4
+        }
+      };
+
+      it("should contextualize the error for array indices", () => {
+        const {comp} = createFormComponent({schema, formData: [
+          "good", "bad", "good"
+        ]});
+
+        expect(comp.state.errorSchema)
+          .eql({
+            1: {errors: ["does not meet minimum length of 4"]}
+          });
+      });
+
+      it("should denote the error in the item field in error", () => {
+        const {node} = createFormComponent({schema, formData: [
+          "good", "bad", "good"
+        ]});
+        const fieldNodes = node.querySelectorAll(".field-string");
+
+        const liNodes = fieldNodes[1]
+          .querySelectorAll(".field-string .error-detail li");
+        const errors = [].map.call(liNodes, li => li.textContent);
+
+        expect(fieldNodes[1].classList.contains("field-error")).eql(true);
+        expect(errors)
+          .eql(["does not meet minimum length of 4"]);
+      });
+
+      it("should not denote errors on non impacted fields", () => {
+        const {node} = createFormComponent({schema, formData: [
+          "good", "bad", "good"
+        ]});
+        const fieldNodes = node.querySelectorAll(".field-string");
+
+        expect(fieldNodes[0].classList.contains("field-error")).eql(false);
+        expect(fieldNodes[2].classList.contains("field-error")).eql(false);
+      });
+    });
+
+    describe("nested array indices", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          level1: {
+            type: "array",
+            items: {
+              type: "string",
+              minLength: 4
+            }
+          }
+        }
+      };
+
+      it("should contextualize the error for nested array indices", () => {
+        const {comp} = createFormComponent({schema, formData: {
+          level1: ["good", "bad", "good", "bad"]
+        }});
+
+        expect(comp.state.errorSchema).eql({
+          level1: {
+            1: {errors: ["does not meet minimum length of 4"]},
+            3: {errors: ["does not meet minimum length of 4"]},
+          }
+        });
+      });
+
+      it("should denote the error in the nested item field in error", () => {
+        const {node} = createFormComponent({schema, formData: {
+          level1: ["good", "bad", "good"]
+        }});
+
+        const liNodes = node.querySelectorAll(".field-string .error-detail li");
+        const errors = [].map.call(liNodes, li => li.textContent);
+
+        expect(errors)
+          .eql(["does not meet minimum length of 4"]);
+      });
+    });
+  });
+
+  describe("array nested items", () => {
+    const schema = {
+      type: "array",
+      items: {
+        type: "object",
+        properties: {
+          foo: {
+            type: "string",
+            minLength: 4
+          }
+        }
+      }
+    };
+
+    it("should contextualize the error for array nested items", () => {
+      const {comp} = createFormComponent({schema, formData: [
+        {foo: "good"}, {foo: "bad"}, {foo: "good"}
+      ]});
+
+      expect(comp.state.errorSchema).eql({
+        1: {
+          foo: {
+            errors: ["does not meet minimum length of 4"]
+          }
+        }
+      });
+    });
+
+    it("should denote the error in the array nested item", () => {
+      const {node} = createFormComponent({schema, formData: [
+        {foo: "good"}, {foo: "bad"}, {foo: "good"}
+      ]});
+      const fieldNodes = node.querySelectorAll(".field-string");
+
+      const liNodes = fieldNodes[1]
+        .querySelectorAll(".field-string .error-detail li");
+      const errors = [].map.call(liNodes, li => li.textContent);
+
+      expect(fieldNodes[1].classList.contains("field-error")).eql(true);
+      expect(errors)
+        .eql(["does not meet minimum length of 4"]);
+    });
+  });
 });