Repository: mozilla-services/react-jsonschema-form
Pull request #368 (2016-11-01 16:21:49)

Add ui:order wildcard feature and improve error messages
### Reasons for making this change

It can be tedious to maintain a `ui:order` list for large schemas if you only want to change the order of two or three fields. (I know that, strictly, the order of object properties is undefined, but in practice it always yields a constant order.) This feature lets you insert a wildcard (`"*"`) for all fields that you don't reference explicitly in `ui:order`, like this:

```js
const uiSchema = {
  "ui:order": ["bar", "*"]
};
```

This also helps during development so you don't have to always update your `uiSchema` when you update your `schema`. And it is useful when you dynamically insert properties in a schema and you want those dynamic properties to appear in a specific position.

While implementing this feature, I was able to improve the error messages for invalid `ui:order` values, specifying the exact field that causes the error. This really helps during debugging.

### 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'm adding a new feature**
  - [x] I've updated the playground with an example use of the feature


Patch:
diff --git a/.eslintrc b/.eslintrc
index da6f88f79..f24ae972b 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -15,7 +15,8 @@
         "comma-dangle": [0],
         "no-unused-vars": [2, {"vars": "all", "args": "none"}],
         "no-console": [0],
-        "object-curly-spacing": [1, "never"]
+        "object-curly-spacing": [1, "never"],
+        "keyword-spacing": ["error"]
     },
     "env": {
         "es6": true,
diff --git a/README.md b/README.md
index 88a9883dd..883828c3a 100644
--- a/README.md
+++ b/README.md
@@ -388,7 +388,7 @@ This allows you to programmatically trigger the browser's file selector which ca
 
 ### Object fields ordering
 
-The `uiSchema` object spec also allows you to define in which order a given object field properties should be rendered using the `ui:order` property:
+Since the order of object properties in Javascript and JSON is not guaranteed, the `uiSchema` object spec allows you to define the order in which properties are rendered using the `ui:order` property:
 
 ```jsx
 const schema = {
@@ -408,6 +408,15 @@ render((
         uiSchema={uiSchema} />
 ), document.getElementById("app"));
 ```
+
+If a guarenteed fixed order is only important for some fields, you can insert a wildcard `"*"` item in your `ui:order` definition. All fields that are not referenced explicitly anywhere in the list will be rendered at that point:
+
+```js
+const uiSchema = {
+  "ui:order": ["bar", "*"]
+};
+```
+
 ### Array items ordering
 
 Array items are orderable by default, and react-jsonschema-form renders move up/down buttons alongside them. The `uiSchema` object spec allows you to disable ordering:
@@ -415,7 +424,7 @@ Array items are orderable by default, and react-jsonschema-form renders move up/
 ```jsx
 const schema = {
   type: "array",
-  properties: {
+  items: {
     type: "string"
   }
 };
diff --git a/playground/samples/ordering.js b/playground/samples/ordering.js
index 179db688b..75ef995a2 100644
--- a/playground/samples/ordering.js
+++ b/playground/samples/ordering.js
@@ -27,7 +27,7 @@ module.exports = {
     }
   },
   uiSchema: {
-    "ui:order": ["firstName", "lastName", "age", "bio", "password"],
+    "ui:order": ["firstName", "lastName", "*", "password"],
     age: {
       "ui:widget": "updown"
     },
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index 9aa4d1e65..17e3d506b 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -101,7 +101,7 @@ class ObjectField extends Component {
     try {
       const properties = Object.keys(schema.properties);
       orderedProperties = orderProperties(properties, uiSchema["ui:order"]);
-    } catch(err) {
+    } catch (err) {
       return (
         <div>
           <p className="config-error" style={{color: "red"}}>
diff --git a/src/utils.js b/src/utils.js
index 30c4b3e90..9dae8da80 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -223,6 +223,9 @@ export function mergeObjects(obj1, obj2, concatArrays = false) {
 }
 
 export function asNumber(value) {
+  if (value === "") {
+    return undefined;
+  }
   if (/\.$/.test(value)) {
     // "3." can't really be considered a number even if it parses in js. The
     // user is most likely entering a float.
@@ -249,16 +252,35 @@ export function orderProperties(properties, order) {
   if (!Array.isArray(order)) {
     return properties;
   }
-  if (order.length !== properties.length) {
-    throw new Error(
-      "uiSchema order list length should match object properties length");
+
+  const arrayToHash = arr => arr.reduce((prev, curr) => {
+    prev[curr] = true;
+    return prev;
+  }, {});
+  const errorPropList = arr => arr.length > 1 ?
+    `properties '${arr.join("', '")}'` :
+    `property '${arr[0]}'`;
+  const propertyHash = arrayToHash(properties);
+  const orderHash = arrayToHash(order);
+  const extraneous = order.filter(prop => prop !== "*" && !propertyHash[prop]);
+  if (extraneous.length) {
+    throw new Error(`uiSchema order list contains extraneous ${errorPropList(extraneous)}`);
+  }
+  const rest = properties.filter(prop => !orderHash[prop]);
+  const restIndex = order.indexOf("*");
+  if (restIndex === -1) {
+    if (rest.length) {
+      throw new Error(`uiSchema order list does not contain ${errorPropList(rest)}`);
+    }
+    return order;
   }
-  const fingerprint = (arr) => [].slice.call(arr).sort().toString();
-  if (fingerprint(order) !== fingerprint(properties)) {
-    throw new Error(
-      "uiSchema order list does not match object properties list");
+  if (restIndex !== order.lastIndexOf("*")) {
+    throw new Error("uiSchema order list contains more than one wildcard item");
   }
-  return order;
+
+  const complete = [...order];
+  complete.splice(restIndex, 1, ...rest);
+  return complete;
 }
 
 export function isMultiSelect(schema) {
@@ -500,7 +522,7 @@ export function dataURItoBlob(dataURI) {
   // Built the Uint8Array Blob parameter from the base64 string.
   const binary = atob(splitted[1]);
   const array = [];
-  for(let i = 0; i < binary.length; i++) {
+  for (let i = 0; i < binary.length; i++) {
     array.push(binary.charCodeAt(i));
   }
   // Create the blob object


Test patch:
diff --git a/test/ArrayField_test.js b/test/ArrayField_test.js
index 636d5b11b..808e655df 100644
--- a/test/ArrayField_test.js
+++ b/test/ArrayField_test.js
@@ -189,7 +189,7 @@ describe("ArrayField", () => {
 
       try {
         Simulate.submit(node);
-      } catch(e) {
+      } catch (e) {
         // Silencing error thrown as failure is expected here
       }
 
diff --git a/test/FormContext_test.js b/test/FormContext_test.js
index 5de449c92..10608ada3 100644
--- a/test/FormContext_test.js
+++ b/test/FormContext_test.js
@@ -60,7 +60,7 @@ describe("FormContext", () => {
 
   it("should be passed to TemplateField", () => {
     function CustomTemplateField({formContext}) {
-      return <div id={formContext.foo} />;
+      return <div id={formContext.foo}/>;
     }
 
     const {node} = createFormComponent({
diff --git a/test/Form_test.js b/test/Form_test.js
index 414b1c79a..be0c0ce02 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -634,7 +634,7 @@ describe("Form", () => {
           function submit(node) {
             try {
               Simulate.submit(node);
-            } catch(err) {
+            } catch (err) {
               // Validation is expected to fail and call console.error, which is
               // stubbed to actually throw in createSandbox().
             }
diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js
index 4593a2043..b1ed77b23 100644
--- a/test/ObjectField_test.js
+++ b/test/ObjectField_test.js
@@ -146,36 +146,57 @@ describe("ObjectField", () => {
       type: "object",
       properties: {
         foo: {type: "string"},
-        bar: {type: "string"}
+        bar: {type: "string"},
+        baz: {type: "string"},
+        qux: {type: "string"}
       }
     };
 
     it("should use provided order", () => {
       const {node} = createFormComponent({schema, uiSchema: {
-        "ui:order": ["bar", "foo"]
+        "ui:order": ["baz", "qux", "bar", "foo"]
       }});
       const labels = [].map.call(
         node.querySelectorAll(".field > label"), l => l.textContent);
 
-      expect(labels).eql(["bar", "foo"]);
+      expect(labels).eql(["baz", "qux", "bar", "foo"]);
     });
 
-    it("should throw when order list length mismatches", () => {
+    it("should insert unordered properties at wildcard position", () => {
       const {node} = createFormComponent({schema, uiSchema: {
-        "ui:order": ["bar", "foo", "baz?"]
+        "ui:order": ["baz", "*", "foo"]
+      }});
+      const labels = [].map.call(
+        node.querySelectorAll(".field > label"), l => l.textContent);
+
+      expect(labels).eql(["baz", "bar", "qux", "foo"]);
+    });
+
+    it("should throw when order list contains an extraneous property", () => {
+      const {node} = createFormComponent({schema, uiSchema: {
+        "ui:order": ["baz", "qux", "bar", "wut?", "foo", "huh?"]
       }});
 
       expect(node.querySelector(".config-error").textContent)
-        .to.match(/should match object properties length/);
+        .to.match(/contains extraneous properties 'wut\?', 'huh\?'/);
     });
 
-    it("should throw when order and properties lists differs", () => {
+    it("should throw when order list misses an existing property", () => {
       const {node} = createFormComponent({schema, uiSchema: {
-        "ui:order": ["bar", "wut?"]
+        "ui:order": ["baz", "bar"]
       }});
 
       expect(node.querySelector(".config-error").textContent)
-        .to.match(/does not match object properties list/);
+        .to.match(/does not contain properties 'foo', 'qux'/);
+    });
+
+    it("should throw when more than one wildcard is present", () => {
+      const {node} = createFormComponent({schema, uiSchema: {
+        "ui:order": ["baz", "*", "bar", "*"]
+      }});
+
+      expect(node.querySelector(".config-error").textContent)
+        .to.match(/contains more than one wildcard/);
     });
 
     it("should order referenced schema definitions", () => {
@@ -228,6 +249,14 @@ describe("ObjectField", () => {
     });
 
     it("should render the widget with the expected id", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          foo: {type: "string"},
+          bar: {type: "string"}
+        }
+      };
+
       const {node} = createFormComponent({schema, uiSchema: {
         "ui:order": ["bar", "foo"]
       }});
diff --git a/test/utils_test.js b/test/utils_test.js
index 3ab24bdca..912b964c3 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -255,6 +255,10 @@ describe("utils", () => {
     it("should allow numbers with a 0 in the first decimal place", () => {
       expect(asNumber("3.07")).eql(3.07);
     });
+
+    it("should return undefined if the input is empty", () => {
+      expect(asNumber("")).eql(undefined);
+    });
   });
 
   describe("isMultiSelect()", () => {