Repository: mozilla-services/react-jsonschema-form
Pull request #29 (2016-01-09 10:44:17)

Added object properties ordering to uiSchema.
## 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 `order` property:

```jsx
const schema = {
  type: "object",
  properties: {
    foo: {type: "string"},
    bar: {type: "string"}
  }
};

const uiSchema = {
  order: ["bar", "foo"]
};

render((
  <Form schema={schema}
        uiSchema={uiSchema} />
), document.getElementById("app"));
```


Patch:
diff --git a/README.md b/README.md
index d8cd28e51..1fa83157f 100644
--- a/README.md
+++ b/README.md
@@ -75,7 +75,7 @@ That should give something like this (if you use the default stylesheet):
 
 ### Alternative widgets
 
-JSONSchema is limited for describing how a given data type should be rendered as an input component, that's why this lib introduces the concept of *UI schema*. A UI schema is basically an object literal describing which UI widget should be used to render a certain field
+JSONSchema is limited for describing how a given data type should be rendered as an input component, that's why this lib introduces the concept of *UI schema*. A UI schema is basically an object literal describing which UI widget should be used to render a certain field.
 
 Example:
 
@@ -115,6 +115,28 @@ Here's a list of supported alternative widgets for different JSONSchema data typ
 
 > Note: for numbers, `min`, `max` and `step` input attributes values will be handled according to JSONSchema's `minimum`, `maximium` and `multipleOf` values when they're defined.
 
+## 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:
+
+```jsx
+const schema = {
+  type: "object",
+  properties: {
+    foo: {type: "string"},
+    bar: {type: "string"}
+  }
+};
+
+const uiSchema = {
+  "ui:order": ["bar", "foo"]
+};
+
+render((
+  <Form schema={schema} uiSchema={uiSchema} />
+), document.getElementById("app"));
+```
+
 ## Custom styles
 
 The UISchema object accepts a `classNames` property for each field of the schema:
diff --git a/playground/app.js b/playground/app.js
index 88ae27239..1a48ea1be 100644
--- a/playground/app.js
+++ b/playground/app.js
@@ -49,10 +49,11 @@ class Editor extends Component {
   render() {
     const {title} = this.props;
     const icon = this.state.valid ? "ok" : "remove";
+    const cls = this.state.valid ? "valid" : "invalid";
     return (
       <fieldset>
         <legend>
-          <span className={`glyphicon glyphicon-${icon}`} />
+          <span className={`${cls} glyphicon glyphicon-${icon}`} />
           {" "}
           {title}
         </legend>
diff --git a/playground/samples/index.js b/playground/samples/index.js
index 9bf1d1d57..5ba3fdab4 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -3,6 +3,7 @@ import nested from "./nested";
 import numbers from "./numbers";
 import simple from "./simple";
 import widgets from "./widgets";
+import ordering from "./ordering";
 
 export const samples = {
   Simple: simple,
@@ -10,4 +11,5 @@ export const samples = {
   Arrays: arrays,
   Numbers: numbers,
   Widgets: widgets,
+  Ordering: ordering,
 };
diff --git a/playground/samples/ordering.js b/playground/samples/ordering.js
new file mode 100644
index 000000000..b070b88a9
--- /dev/null
+++ b/playground/samples/ordering.js
@@ -0,0 +1,48 @@
+module.exports = {
+  schema: {
+    title: "A registration form",
+    type: "object",
+    required: ["firstName", "lastName"],
+    properties: {
+      password: {
+        type: "string",
+        title: "Password"
+      },
+      lastName: {
+        type: "string",
+        title: "Last name",
+      },
+      bio: {
+        type: "string",
+        title: "Bio",
+      },
+      firstName: {
+        type: "string",
+        title: "First name",
+      },
+      age: {
+        type: "integer",
+        title: "Age"
+      },
+    }
+  },
+  uiSchema: {
+    "ui:order": ["firstName", "lastName", "age", "bio", "password"],
+    age: {
+      widget: "updown"
+    },
+    bio: {
+      widget: "textarea"
+    },
+    password: {
+      widget: "password"
+    }
+  },
+  formData: {
+    firstName: "Chuck",
+    lastName: "Norris",
+    age: 75,
+    bio: "Roundhouse kicking asses since 1940",
+    password: "noneed",
+  }
+};
diff --git a/playground/styles.css b/playground/styles.css
index af82d707e..71130f5b4 100644
--- a/playground/styles.css
+++ b/playground/styles.css
@@ -19,3 +19,11 @@
 .rjsf input[type=radio] {
   margin-right: .4em;
 }
+
+.invalid {
+  color: red;
+}
+
+.valid {
+  color: green;
+}
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index 1db56f55b..e14fe3061 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -1,6 +1,6 @@
 import React, { Component, PropTypes } from "react";
 
-import { getDefaultFormState } from "../../utils";
+import { getDefaultFormState, orderProperties } from "../../utils";
 
 
 class ObjectField extends Component {
@@ -40,13 +40,24 @@ class ObjectField extends Component {
     const {schema, uiSchema, name} = this.props;
     const title = name || schema.title;
     const SchemaField = this.props.SchemaField;
+    try {
+      var orderedProperties = orderProperties(
+        Object.keys(schema.properties), uiSchema["ui:order"]);
+    } catch(err) {
+      return (
+        <p className="config-error" style={{color: "red"}}>
+          Invalid {name || "root"} object field configuration:
+          <em>{err.message}</em>.
+        </p>
+      );
+    }
     return (
       <fieldset>
         {title ? <legend>{title}</legend> : null}
         {schema.description ?
           <div className="field-description">{schema.description}</div> : null}
         {
-        Object.keys(schema.properties).map((name, index) => {
+        orderedProperties.map((name, index) => {
           return (
             <SchemaField key={index}
               name={name}
diff --git a/src/utils.js b/src/utils.js
index 94d275920..f41d90e3d 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -80,3 +80,19 @@ export function asNumber(value) {
   const valid = typeof n === "number" && !Number.isNaN(n);
   return valid ? n : value;
 }
+
+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 fingerprint = (arr) => [].slice.call(arr).sort().toString();
+  if (fingerprint(order) !== fingerprint(properties)) {
+    throw new Error(
+      "uiSchema order list does not match object properties list");
+  }
+  return order;
+}


Test patch:
diff --git a/test/index_test.js b/test/index_test.js
index 85f3c6fa3..e00fc0a88 100644
--- a/test/index_test.js
+++ b/test/index_test.js
@@ -61,6 +61,44 @@ describe("Form", () => {
     });
   });
 
+  describe("Object fields ordering", () => {
+    const schema = {
+      type: "object",
+      properties: {
+        foo: {type: "string"},
+        bar: {type: "string"}
+      }
+    };
+
+    it("should use provided order", () => {
+      const {node} = createComponent({schema, uiSchema: {
+        "ui:order": ["bar", "foo"]
+      }});
+      const labels = [].map.call(
+        node.querySelectorAll(".field > label"), l => l.textContent);
+
+      expect(labels).eql(["bar", "foo"]);
+    });
+
+    it("should throw when order list length mismatches", () => {
+      const {node} = createComponent({schema, uiSchema: {
+        "ui:order": ["bar", "foo", "baz?"]
+      }});
+
+      expect(node.querySelector(".config-error").textContent)
+        .to.match(/should match object properties length/);
+    });
+
+    it("should throw when order and properties lists differs", () => {
+      const {node} = createComponent({schema, uiSchema: {
+        "ui:order": ["bar", "wut?"]
+      }});
+
+      expect(node.querySelector(".config-error").textContent)
+        .to.match(/does not match object properties list/);
+    });
+  });
+
   describe("StringField", () => {
     describe("TextWidget", () => {
       it("should render a string field", () => {