Repository: mozilla-services/react-jsonschema-form (js)

Issue:
Option to turn off enum fields defaulting to first item
### Prerequisites

- [x] I have read the [documentation](https://github.com/mozilla-services/react-jsonschema-form/blob/master/README.md#readme);
- [ ] In the case of a bug report, I understand that providing a [SSCCE](http://sscce.org/) example is tremendously useful to the maintainers.

### Description

We have string fields that use enums and are required fields. On the form, we'd like to not have them automatically default to the first item in the enum, so that the required validation is triggered if a user skips the field. Ideally we could use the `default` property in the schema for that, but in our case the default should really be `undefined`. So it would be good to have the ability to skip the enum specific default behavior and use the normal string field behavior.

This could be a Form component prop or an option in the uiSchema for a field, I think. I don't see a way to do it in the normal schema. At the form level would work for me.

I can submit a PR for this if you're open to it.


Pull request description:
Allow empty option for enum fields
### Reasons for making this change

This removes the logic that defaults enum fields to the first item and updates the date and select widgets to use an empty default option when a field is undefined.

This ended up being more difficult to get right at the widget level than I thought and I've essentially skipped making this change affect `multiple` arrays. This needs some careful checking for the cases that aren't on the simpler string field using a select widget path.

Closes #449.

### 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/README.md b/README.md
index f4cb9bfc2..60f185243 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ A [live playground](https://mozilla-services.github.io/react-jsonschema-form/) i
      - [Custom validation](#custom-validation)
      - [Custom error messages](#custom-error-messages)
      - [Error List Display](#error-list-display)
+     - [The case of empty strings](#the-case-of-empty-strings)
   - [Styling your forms](#styling-your-forms)
   - [Schema definitions and references](#schema-definitions-and-references)
   - [JSON Schema supporting status](#json-schema-supporting-status)
@@ -654,6 +655,15 @@ const uiSchema = {
 
 ![](http://i.imgur.com/MbHypKg.png)
 
+Fields using `enum` can also use `ui:placeholder`. The value will be used as the text for the empty option in the select widget.
+
+```jsx
+const schema = {type: "string", enum: ["First", "Second"]};
+const uiSchema = {
+  "ui:placeholder": "Choose an option"
+};
+```
+
 ### Form attributes
 
 Form component supports the following html attributes:
@@ -1224,6 +1234,12 @@ render((
 ), document.getElementById("app"));
 ```
 
+### The case of empty strings
+
+When a text input is empty, the field in form data is set to `undefined`. String fields that use `enum` and a `select` widget work similarly and will have an empty option at the top of the options list that when selected will result in the field being `undefined`. 
+
+One consequence of this is that if you have an empty string in your `enum` array, selecting that option in the `select` input will cause the field to be set to `undefined`, not an empty string.
+
 ## Styling your forms
 
 This library renders form fields and widgets leveraging the [Bootstrap](http://getbootstrap.com/) semantics. That means your forms will be beautiful by default if you're loading its stylesheet in your page.
diff --git a/playground/samples/large.js b/playground/samples/large.js
index 8e5150d6c..de6240e95 100644
--- a/playground/samples/large.js
+++ b/playground/samples/large.js
@@ -30,6 +30,10 @@ module.exports = {
       choice10: {$ref: "#/definitions/largeEnum"},
     }
   },
-  uiSchema: {},
+  uiSchema: {
+    choice1: {
+      "ui:placeholder": "Choose one"
+    }
+  },
   formData: {}
 };
diff --git a/src/components/widgets/AltDateWidget.js b/src/components/widgets/AltDateWidget.js
index ddbcfa90c..b977f7c9b 100644
--- a/src/components/widgets/AltDateWidget.js
+++ b/src/components/widgets/AltDateWidget.js
@@ -3,8 +3,8 @@ import React, {Component, PropTypes} from "react";
 import {shouldRender, parseDateString, toDateString, pad} from "../../utils";
 
 
-function rangeOptions(type, start, stop) {
-  let options = [{value: -1, label: type}];
+function rangeOptions(start, stop) {
+  let options = [];
   for (let i=start; i<= stop; i++) {
     options.push({value: i, label: pad(i, 2)});
   }
@@ -24,7 +24,8 @@ function DateElement(props) {
       schema={{type: "integer"}}
       id={id}
       className="form-control"
-      options={{enumOptions: rangeOptions(type, range[0], range[1])}}
+      options={{enumOptions: rangeOptions(range[0], range[1])}}
+      placeholder={type}
       value={value}
       disabled={disabled}
       readonly={readonly}
@@ -56,7 +57,7 @@ class AltDateWidget extends Component {
   }
 
   onChange = (property, value) => {
-    this.setState({[property]: value}, () => {
+    this.setState({[property]: typeof value === "undefined" ? -1 : value}, () => {
       // Only propagate to parent state if we have a complete date{time}
       if (readyForChange(this.state)) {
         this.props.onChange(toDateString(this.state, this.props.time));
diff --git a/src/components/widgets/SelectWidget.js b/src/components/widgets/SelectWidget.js
index 41cc8ca66..0595e5dff 100644
--- a/src/components/widgets/SelectWidget.js
+++ b/src/components/widgets/SelectWidget.js
@@ -7,7 +7,9 @@ import {asNumber} from "../../utils";
  * always retrieved as strings.
  */
 function processValue({type, items}, value) {
-  if (type === "array" && items && ["number", "integer"].includes(items.type)) {
+  if (value === "") {
+    return undefined;
+  } else if (type === "array" && items && ["number", "integer"].includes(items.type)) {
     return value.map(asNumber);
   } else if (type === "boolean") {
     return value === "true";
@@ -38,15 +40,17 @@ function SelectWidget({
   multiple,
   autofocus,
   onChange,
-  onBlur
+  onBlur,
+  placeholder
 }) {
   const {enumOptions} = options;
+  const emptyValue = multiple ? [] : "";
   return (
     <select
       id={id}
       multiple={multiple}
       className="form-control"
-      value={value}
+      value={typeof value === "undefined" ? emptyValue : value}
       required={required}
       disabled={disabled}
       readOnly={readonly}
@@ -58,11 +62,12 @@ function SelectWidget({
       onChange={(event) => {
         const newValue = getValue(event, multiple);
         onChange(processValue(schema, newValue));
-      }}>{
-      enumOptions.map(({value, label}, i) => {
+      }}>
+      {!multiple && !schema.default && <option value="">{placeholder}</option>}
+      {enumOptions.map(({value, label}, i) => {
         return <option key={i} value={value}>{label}</option>;
-      })
-    }</select>
+      })}
+    </select>
   );
 }
 
diff --git a/src/utils.js b/src/utils.js
index 96a94db26..cb927c8a8 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -116,9 +116,6 @@ function computeDefaults(schema, parentDefaults, definitions={}) {
   } else if ("default" in schema) {
     // Use schema defaults for this node.
     defaults = schema.default;
-  } else if ("enum" in schema && Array.isArray(schema.enum)) {
-    // For enum with no defined default, select the first entry.
-    defaults = schema.enum[0];
   } else if ("$ref" in schema) {
     // Use referenced schema defaults for this node.
     const refSchema = findSchemaDefinition(schema.$ref, definitions);


Test patch:
diff --git a/test/BooleanField_test.js b/test/BooleanField_test.js
index 6d3be909c..54831dc95 100644
--- a/test/BooleanField_test.js
+++ b/test/BooleanField_test.js
@@ -120,7 +120,7 @@ describe("BooleanField", () => {
 
     const labels = [].map.call(node.querySelectorAll(".field option"),
                                label => label.textContent);
-    expect(labels).eql(["Yes", "No"]);
+    expect(labels).eql(["", "Yes", "No"]);
   });
 
   it("should render the widget with the expected id", () => {
diff --git a/test/Form_test.js b/test/Form_test.js
index 10bb712c7..7aff92cdf 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -381,7 +381,7 @@ describe("Form", () => {
       const {node} = createFormComponent({schema});
 
       expect(node.querySelectorAll("option"))
-        .to.have.length.of(2);
+        .to.have.length.of(3);
     });
   });
 
diff --git a/test/StringField_test.js b/test/StringField_test.js
index 1dac26040..92b94a9de 100644
--- a/test/StringField_test.js
+++ b/test/StringField_test.js
@@ -158,6 +158,31 @@ describe("StringField", () => {
         .eql("foo");
     });
 
+    it("should render empty option", () => {
+      const {node} = createFormComponent({schema: {
+        type: "string",
+        enum: ["foo", "bar"],
+      }});
+
+      expect(node.querySelectorAll(".field option")[0].value)
+        .eql("");
+    });
+
+    it("should render empty option with placeholder text", () => {
+      const {node} = createFormComponent({schema: {
+        type: "string",
+        enum: ["foo", "bar"],
+      }, uiSchema: {
+        "ui:options": {
+          placeholder: "Test"
+        }
+      }});
+
+      console.log(node.querySelectorAll(".field option")[0].innerHTML);
+      expect(node.querySelectorAll(".field option")[0].textContent)
+        .eql("Test");
+    });
+
     it("should assign a default value", () => {
       const {comp} = createFormComponent({schema: {
         type: "string",
@@ -181,6 +206,19 @@ describe("StringField", () => {
       expect(comp.state.formData).eql("foo");
     });
 
+    it("should reflect undefined into form state if empty option selected", () => {
+      const {comp, node} = createFormComponent({schema: {
+        type: "string",
+        enum: ["foo", "bar"],
+      }});
+
+      Simulate.change(node.querySelector("select"), {
+        target: {value: ""}
+      });
+
+      expect(comp.state.formData).to.be.undefined;
+    });
+
     it("should reflect the change into the dom", () => {
       const {node} = createFormComponent({schema: {
         type: "string",
@@ -194,6 +232,19 @@ describe("StringField", () => {
       expect(node.querySelector("select").value).eql("foo");
     });
 
+    it("should reflect undefined value into the dom as empty option", () => {
+      const {node} = createFormComponent({schema: {
+        type: "string",
+        enum: ["foo", "bar"],
+      }});
+
+      Simulate.change(node.querySelector("select"), {
+        target: {value: ""}
+      });
+
+      expect(node.querySelector("select").value).eql("");
+    });
+
     it("should fill field with data", () => {
       const {comp} = createFormComponent({schema: {
         type: "string",
@@ -550,7 +601,7 @@ describe("StringField", () => {
       const monthOptions = node.querySelectorAll("select#root_month option");
       const monthOptionsValues = [].map.call(monthOptions, o => o.value);
       expect(monthOptionsValues).eql([
-        "-1", "1", "2", "3", "4", "5", "6",
+        "", "1", "2", "3", "4", "5", "6",
         "7", "8", "9", "10", "11", "12"]);
     });
 
@@ -734,7 +785,7 @@ describe("StringField", () => {
       const monthOptions = node.querySelectorAll("select#root_month option");
       const monthOptionsValues = [].map.call(monthOptions, o => o.value);
       expect(monthOptionsValues).eql([
-        "-1", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]);
+        "", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]);
     });
 
     it("should render the widgets with the expected options' labels", () => {
diff --git a/test/uiSchema_test.js b/test/uiSchema_test.js
index f248137f6..3d33899ef 100644
--- a/test/uiSchema_test.js
+++ b/test/uiSchema_test.js
@@ -1158,15 +1158,15 @@ describe("uiSchema", () => {
         const {node} = createFormComponent({schema, uiSchema});
 
         expect(node.querySelectorAll("select option"))
-          .to.have.length.of(2);
+          .to.have.length.of(3);
       });
 
       it("should render boolean option labels", () => {
         const {node} = createFormComponent({schema, uiSchema});
 
-        expect(node.querySelectorAll("option")[0].textContent)
-          .eql("yes");
         expect(node.querySelectorAll("option")[1].textContent)
+          .eql("yes");
+        expect(node.querySelectorAll("option")[2].textContent)
           .eql("no");
       });
 
diff --git a/test/utils_test.js b/test/utils_test.js
index 912b964c3..97fbfc07c 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -178,20 +178,6 @@ describe("utils", () => {
           .eql({level1: [1, 2, 3]});
       });
 
-      it("should use first enum value when no default is specified", () => {
-        const schema = {
-          type: "object",
-          properties: {
-            foo: {
-              type: "string",
-              enum: ["a", "b", "c"],
-            }
-          }
-        };
-        expect(getDefaultFormState(schema, {}))
-          .eql({foo: "a"});
-      });
-
       it("should map item defaults to fixed array default", () => {
         const schema = {
           type: "object",