Repository: mozilla-services/react-jsonschema-form
Pull request #42 (2016-03-03 10:32:08)

Add multiselect support for array fields
See #41 


Patch:
diff --git a/README.md b/README.md
index 60cf79e9a..bcd5318d4 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,12 @@ render((
 ), document.getElementById("app"));
 ```
 
+## Multiple choices list
+
+The default behavior for array fields is a list of text inputs with add/remove buttons. If you want a multiple choices list, you have to provide an `enum` list to the `items` property of the array field and set `uniqueItems` property to `true`.
+
+See the "Arrays" section of the demo app and [this issue](https://github.com/mozilla-services/react-jsonschema-form/issues/41) for more information.
+
 ## Custom styles
 
 The UISchema object accepts a `classNames` property for each field of the schema:
diff --git a/playground/samples/arrays.js b/playground/samples/arrays.js
index 48a0ff760..4ac4e11ac 100644
--- a/playground/samples/arrays.js
+++ b/playground/samples/arrays.js
@@ -1,12 +1,29 @@
 module.exports = {
   schema: {
-    type: "array",
-    title: "A list of strings",
-    items: {
-      type: "string",
-      default: "bazinga"
+    type: "object",
+    properties: {
+      listOfStrings: {
+        type: "array",
+        title: "A list of strings",
+        items: {
+          type: "string",
+          default: "bazinga"
+        }
+      },
+      multipleChoicesList: {
+        type: "array",
+        title: "A multiple choices list",
+        items: {
+          type: "string",
+          enum: ["foo", "bar", "fuzz"],
+        },
+        uniqueItems: true
+      }
     }
   },
   uiSchema: {},
-  formData: ["foo", "bar"]
+  formData: {
+    listOfStrings: ["foo", "bar"],
+    multipleChoicesList: ["foo", "bar"]
+  }
 };
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index 168704ea3..5d23dcab4 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -1,6 +1,7 @@
 import React, { Component, PropTypes } from "react";
 
-import { getDefaultFormState } from "../../utils";
+import { getDefaultFormState, isMultiSelect } from "../../utils";
+import SelectWidget from "./../widgets/SelectWidget";
 
 
 class ArrayField extends Component {
@@ -60,11 +61,29 @@ class ArrayField extends Component {
     }, {validate: false});
   }
 
+  onSelectChange(value) {
+    this.asyncSetState({items: value}, {validate: false});
+  }
+
   render() {
     const {schema, uiSchema, name} = this.props;
     const title = schema.title || name;
     const {items} = this.state;
     const SchemaField = this.props.registry.SchemaField;
+    if (isMultiSelect(schema)) {
+      return (
+        <SelectWidget
+          multiple
+          onChange={this.onSelectChange.bind(this)}
+          options={schema.items.enum}
+          schema={schema}
+          title={title}
+          defaultValue={schema.default}
+          value={items}
+        />
+      );
+    }
+
     return (
       <fieldset
         className={`field field-array field-array-of-${schema.items.type}`}>
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index 568fbb622..c82dbf570 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -1,5 +1,6 @@
 import React, { PropTypes } from "react";
 
+import { isMultiSelect } from "../../utils";
 import ArrayField from "./ArrayField";
 import BooleanField from "./BooleanField";
 import NumberField from "./NumberField";
@@ -29,10 +30,11 @@ function getLabel(label, required) {
   return label;
 }
 
-function getContent({type, label, required, children}) {
-  if (["object", "array"].indexOf(type) !==-1) {
+function getContent({type, label, required, children, displayLabel}) {
+  if (!displayLabel) {
     return children;
   }
+
   return (
     <label>
       {getLabel(label, required)}
@@ -55,6 +57,7 @@ if (process.env.NODE_ENV !== "production") {
     type: PropTypes.string.isRequired,
     label: PropTypes.string,
     required: PropTypes.bool,
+    isEnum: PropTypes.bool,
     children: React.PropTypes.node.isRequired,
     classNames: React.PropTypes.string,
   };
@@ -67,11 +70,20 @@ Wrapper.defaultProps = {
 function SchemaField(props) {
   const {schema, uiSchema, name, required} = props;
   const FieldComponent = COMPONENT_TYPES[schema.type] || UnsupportedField;
+  let displayLabel = true;
+  if (schema.type === "array") {
+    displayLabel = isMultiSelect(schema);
+  }
+  if (schema.type === "object") {
+    displayLabel = false;
+  }
+
   return (
     <Wrapper
       label={schema.title || name}
       required={required}
       type={schema.type}
+      displayLabel={displayLabel}
       classNames={uiSchema.classNames}>
       <FieldComponent {...props} />
     </Wrapper>
diff --git a/src/components/widgets/SelectWidget.js b/src/components/widgets/SelectWidget.js
index 032490333..717ec4904 100644
--- a/src/components/widgets/SelectWidget.js
+++ b/src/components/widgets/SelectWidget.js
@@ -23,15 +23,25 @@ function SelectWidget({
   value,
   defaultValue,
   required,
+  multiple,
   onChange
 }) {
   return (
     <select
+      multiple={multiple}
       title={placeholder}
       value={value}
       defaultValue={defaultValue}
       onChange={(event) => {
-        onChange(processValue(schema.type, event.target.value));
+        let newValue;
+        if (multiple) {
+          newValue = [].filter.call(
+            event.target.options, o => o.selected).map(o => o.value);
+        } else {
+          newValue = event.target.value;
+        }
+
+        onChange(processValue(schema.type, newValue));
       }}>{
       options.map((option, i) => {
         return <option key={i} value={option}>{String(option)}</option>;
@@ -48,6 +58,7 @@ if (process.env.NODE_ENV !== "production") {
     value: PropTypes.any,
     defaultValue: PropTypes.any,
     required: PropTypes.bool,
+    multiple: PropTypes.bool,
     onChange: PropTypes.func,
   };
 }
diff --git a/src/utils.js b/src/utils.js
index f41d90e3d..498cfbeaa 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -96,3 +96,7 @@ export function orderProperties(properties, order) {
   }
   return order;
 }
+
+export function isMultiSelect(schema) {
+  return Array.isArray(schema.items.enum) && schema.uniqueItems;
+}


Test patch:
diff --git a/test/index_test.js b/test/index_test.js
index 7b45e8aa9..d6791c871 100644
--- a/test/index_test.js
+++ b/test/index_test.js
@@ -443,67 +443,134 @@ describe("Form", () => {
   });
 
   describe("ArrayField", () => {
-    const schema = {
-      type: "array",
-      title: "my list",
-      items: {type: "string"}
-    };
+    describe("List of inputs", () => {
+      const schema = {
+        type: "array",
+        title: "my list",
+        items: {type: "string"}
+      };
 
-    it("should render a fieldset", () => {
-      const {node} = createComponent({schema});
+      it("should render a fieldset", () => {
+        const {node} = createComponent({schema});
 
-      expect(node.querySelectorAll("fieldset"))
-        .to.have.length.of(1);
-    });
+        expect(node.querySelectorAll("fieldset"))
+          .to.have.length.of(1);
+      });
 
-    it("should render a fieldset legend", () => {
-      const {node} = createComponent({schema});
+      it("should render a fieldset legend", () => {
+        const {node} = createComponent({schema});
 
-      expect(node.querySelector("fieldset > legend").textContent)
-        .eql("my list");
-    });
+        expect(node.querySelector("fieldset > legend").textContent)
+          .eql("my list");
+      });
 
-    it("should contain no field in the list by default", () => {
-      const {node} = createComponent({schema});
+      it("should contain no field in the list by default", () => {
+        const {node} = createComponent({schema});
 
-      expect(node.querySelectorAll(".field-string"))
-        .to.have.length.of(0);
-    });
+        expect(node.querySelectorAll(".field-string"))
+          .to.have.length.of(0);
+      });
 
-    it("should have an add button", () => {
-      const {node} = createComponent({schema});
+      it("should have an add button", () => {
+        const {node} = createComponent({schema});
 
-      expect(node.querySelector(".array-item-add button"))
-        .not.eql(null);
-    });
+        expect(node.querySelector(".array-item-add button"))
+          .not.eql(null);
+      });
 
-    it("should add a new field when clicking the add button", () => {
-      const {node} = createComponent({schema});
+      it("should add a new field when clicking the add button", () => {
+        const {node} = createComponent({schema});
 
-      Simulate.click(node.querySelector(".array-item-add button"));
+        Simulate.click(node.querySelector(".array-item-add button"));
 
-      expect(node.querySelectorAll(".field-string"))
-        .to.have.length.of(1);
-    });
+        expect(node.querySelectorAll(".field-string"))
+          .to.have.length.of(1);
+      });
+
+      it("should fill an array field with data", () => {
+        const {node} = createComponent({schema, formData: ["foo", "bar"]});
+        const inputs = node.querySelectorAll(".field-string input[type=text]");
 
-    it("should fill an array field with data", () => {
-      const {node} = createComponent({schema, formData: ["foo", "bar"]});
-      const inputs = node.querySelectorAll(".field-string input[type=text]");
+        expect(inputs).to.have.length.of(2);
+        expect(inputs[0].value).eql("foo");
+        expect(inputs[1].value).eql("bar");
+      });
+
+      it("should remove a field from the list", () => {
+        const {node} = createComponent({schema, formData: ["foo", "bar"]});
+        const dropBtns = node.querySelectorAll(".array-item-remove button");
 
-      expect(inputs).to.have.length.of(2);
-      expect(inputs[0].value).eql("foo");
-      expect(inputs[1].value).eql("bar");
+        Simulate.click(dropBtns[0]);
+
+        const inputs = node.querySelectorAll(".field-string input[type=text]");
+        expect(inputs).to.have.length.of(1);
+        expect(inputs[0].value).eql("bar");
+      });
     });
 
-    it("should remove a field from the list", () => {
-      const {node} = createComponent({schema, formData: ["foo", "bar"]});
-      const dropBtns = node.querySelectorAll(".array-item-remove button");
+    describe("Multiple choices list", () => {
+      const schema = {
+        type: "array",
+        title: "My field",
+        items: {
+          enum: ["foo", "bar", "fuzz"],
+          type: "string"
+        },
+        uniqueItems: true,
+        value: ["foo", "fuzz"]
+      };
 
-      Simulate.click(dropBtns[0]);
+      it("should render a select widget", () => {
+        const {node} = createComponent({schema});
+
+        expect(node.querySelectorAll("select"))
+          .to.have.length.of(1);
+      });
+
+      it("should render a select widget with a label", () => {
+        const {node} = createComponent({schema});
+
+        expect(node.querySelector(".field label > span").textContent)
+          .eql("My field");
+      });
 
-      const inputs = node.querySelectorAll(".field-string input[type=text]");
-      expect(inputs).to.have.length.of(1);
-      expect(inputs[0].value).eql("bar");
+      it("should render a select widget with multiple attribute", () => {
+        const {node} = createComponent({schema});
+
+        expect(node.querySelector(".field select").getAttribute("multiple"))
+          .not.to.be.null;
+      });
+
+      it("should render options", () => {
+        const {node} = createComponent({schema});
+
+        expect(node.querySelectorAll("select option"))
+          .to.have.length.of(3);
+      });
+
+      it("should handle a change event", () => {
+        const {comp, node} = createComponent({schema});
+
+        Simulate.change(node.querySelector(".field select"), {
+          target: {options: [
+            {selected: true, value: "foo"},
+            {selected: true, value: "bar"},
+            {selected: false, value: "fuzz"},
+          ]}
+        });
+
+        expect(comp.state.formData).eql(["foo", "bar"]);
+      });
+
+      it("should fill field with data", () => {
+        const {node} = createComponent({schema, formData: ["foo", "bar"]});
+
+        const options = node.querySelectorAll(".field select option");
+        expect(options).to.have.length.of(3);
+        expect(options[0].getAttribute("selected")).not.to.be.null;  // foo
+        expect(options[1].getAttribute("selected")).not.to.be.null;  // bar
+        expect(options[2].getAttribute("selected")).to.be.null;  // fuzz
+      });
     });
   });
 
diff --git a/test/utils_test.js b/test/utils_test.js
index 84a169431..827b0cf65 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -1,6 +1,6 @@
 import { expect } from "chai";
 
-import { getDefaultFormState } from "../src/utils";
+import { getDefaultFormState, isMultiSelect } from "../src/utils";
 
 
 describe("utils", () => {
@@ -80,4 +80,21 @@ describe("utils", () => {
       });
     });
   });
+
+  describe("isMultiSelect()", () => {
+    it("should be true if schema items enum is an array and uniqueItems is true", () => {
+      let schema = {items: {enum: ["foo", "bar"]}, uniqueItems: true};
+      expect(isMultiSelect(schema)).to.be.true;
+    });
+
+    it("should be false if uniqueItems is false", () => {
+      const schema = {items: {enum: ["foo", "bar"]}, uniqueItems: false};
+      expect(isMultiSelect(schema)).to.be.false;
+    });
+
+    it("should be false if schema items enum is not an array", () => {
+      const schema = {items: {}, uniqueItems: true};
+      expect(isMultiSelect(schema)).to.be.false;
+    });
+  });
 });