Repository: mozilla-services/react-jsonschema-form
Pull request #1100 (2018-12-06 10:02:16)

Infer type from enum if a type is not provided to SelectWidget
Connects to #1098

Change-type: minor
Signed-off-by: Lucian <lucian.buzzo@gmail.com>

### Reasons for making this change

If a type is not provided in a schema containing an enum and the enum contains values that are not a string, then the form will incorrectly display errors, due to the value of a select always being a string.
This change makes a best effort to infer the type from the `enum` field if the `type` field is not present.

### Checklist

* [ ] **I'm updating documentation**
  - [ ] I've checked the rendering of the Markdown text I've added
  - [ ] 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
  - [ ] I've updated docs if needed
  - [x] I've run `npm run cs-format` on my branch to conform my code to [prettier](https://github.com/prettier/prettier) coding style
* [ ] **I'm adding a new feature**
  - [ ] I've updated the playground with an example use of the feature


LucianBuzzo: @edi9999 Could you take a look at this PR please?
edi9999: Looks good to me ! Merging !

Patch:
diff --git a/src/components/fields/StringField.js b/src/components/fields/StringField.js
index c2887e28e..91fcf6911 100644
--- a/src/components/fields/StringField.js
+++ b/src/components/fields/StringField.js
@@ -65,7 +65,11 @@ if (process.env.NODE_ENV !== "production") {
     onChange: PropTypes.func.isRequired,
     onBlur: PropTypes.func,
     onFocus: PropTypes.func,
-    formData: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+    formData: PropTypes.oneOfType([
+      PropTypes.string,
+      PropTypes.number,
+      PropTypes.bool,
+    ]),
     registry: PropTypes.shape({
       widgets: PropTypes.objectOf(
         PropTypes.oneOfType([PropTypes.func, PropTypes.object])
diff --git a/src/components/widgets/SelectWidget.js b/src/components/widgets/SelectWidget.js
index a3da1fe37..a5ef16035 100644
--- a/src/components/widgets/SelectWidget.js
+++ b/src/components/widgets/SelectWidget.js
@@ -1,7 +1,7 @@
 import React from "react";
 import PropTypes from "prop-types";
 
-import { asNumber } from "../../utils";
+import { asNumber, guessType } from "../../utils";
 
 const nums = new Set(["number", "integer"]);
 
@@ -9,7 +9,9 @@ const nums = new Set(["number", "integer"]);
  * This is a silly limitation in the DOM where option change event values are
  * always retrieved as strings.
  */
-function processValue({ type, items }, value) {
+function processValue(schema, value) {
+  // "enum" is a reserved word, so only "type" and "items" can be destructured
+  const { type, items } = schema;
   if (value === "") {
     return undefined;
   } else if (type === "array" && items && nums.has(items.type)) {
@@ -19,6 +21,17 @@ function processValue({ type, items }, value) {
   } else if (type === "number") {
     return asNumber(value);
   }
+
+  // If type is undefined, but an enum is present, try and infer the type from
+  // the enum values
+  if (schema.enum) {
+    if (schema.enum.every(x => guessType(x) === "number")) {
+      return asNumber(value);
+    } else if (schema.enum.every(x => guessType(x) === "boolean")) {
+      return value === "true";
+    }
+  }
+
   return value;
 }
 
diff --git a/src/utils.js b/src/utils.js
index 816db131d..47856324d 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -406,7 +406,7 @@ function findSchemaDefinition($ref, definitions = {}) {
 
 // In the case where we have to implicitly create a schema, it is useful to know what type to use
 //  based on the data we are defining
-const guessType = function guessType(value) {
+export const guessType = function guessType(value) {
   if (Array.isArray(value)) {
     return "array";
   } else if (typeof value === "string") {


Test patch:
diff --git a/test/BooleanField_test.js b/test/BooleanField_test.js
index 95bb58853..eaa8a5a98 100644
--- a/test/BooleanField_test.js
+++ b/test/BooleanField_test.js
@@ -1,6 +1,7 @@
 import React from "react";
 import { expect } from "chai";
 import { Simulate } from "react-addons-test-utils";
+import sinon from "sinon";
 
 import { createFormComponent, createSandbox } from "./test_utils";
 
@@ -227,4 +228,82 @@ describe("BooleanField", () => {
       expect(node.querySelector("#label-")).to.not.be.null;
     });
   });
+
+  describe("SelectWidget", () => {
+    it("should render a field that contains an enum of booleans", () => {
+      const { node } = createFormComponent({
+        schema: {
+          enum: [true, false],
+        },
+      });
+
+      expect(node.querySelectorAll(".field select")).to.have.length.of(1);
+    });
+
+    it("should infer the value from an enum on change", () => {
+      const spy = sinon.spy();
+      const { node } = createFormComponent({
+        schema: {
+          enum: [true, false],
+        },
+        onChange: spy,
+      });
+
+      expect(node.querySelectorAll(".field select")).to.have.length.of(1);
+      const $select = node.querySelector(".field select");
+      expect($select.value).eql("");
+
+      Simulate.change($select, {
+        target: { value: "true" },
+      });
+      expect($select.value).eql("true");
+      expect(spy.lastCall.args[0].formData).eql(true);
+    });
+
+    it("should render a string field with a label", () => {
+      const { node } = createFormComponent({
+        schema: {
+          enum: [true, false],
+          title: "foo",
+        },
+      });
+
+      expect(node.querySelector(".field label").textContent).eql("foo");
+    });
+
+    it("should assign a default value", () => {
+      const { comp } = createFormComponent({
+        schema: {
+          enum: [true, false],
+          default: true,
+        },
+      });
+
+      expect(comp.state.formData).eql(true);
+    });
+
+    it("should handle a change event", () => {
+      const { comp, node } = createFormComponent({
+        schema: {
+          enum: [true, false],
+        },
+      });
+
+      Simulate.change(node.querySelector("select"), {
+        target: { value: "false" },
+      });
+
+      expect(comp.state.formData).eql(false);
+    });
+
+    it("should render the widget with the expected id", () => {
+      const { node } = createFormComponent({
+        schema: {
+          enum: [true, false],
+        },
+      });
+
+      expect(node.querySelector("select").id).eql("root");
+    });
+  });
 });
diff --git a/test/NumberField_test.js b/test/NumberField_test.js
index f1f5fd482..b265e8e64 100644
--- a/test/NumberField_test.js
+++ b/test/NumberField_test.js
@@ -1,6 +1,7 @@
 import React from "react";
 import { expect } from "chai";
 import { Simulate } from "react-addons-test-utils";
+import sinon from "sinon";
 
 import { createFormComponent, createSandbox } from "./test_utils";
 
@@ -208,6 +209,26 @@ describe("NumberField", () => {
       expect(node.querySelectorAll(".field select")).to.have.length.of(1);
     });
 
+    it("should infer the value from an enum on change", () => {
+      const spy = sinon.spy();
+      const { node } = createFormComponent({
+        schema: {
+          enum: [1, 2],
+        },
+        onChange: spy,
+      });
+
+      expect(node.querySelectorAll(".field select")).to.have.length.of(1);
+      const $select = node.querySelector(".field select");
+      expect($select.value).eql("");
+
+      Simulate.change(node.querySelector(".field select"), {
+        target: { value: "1" },
+      });
+      expect($select.value).eql("1");
+      expect(spy.lastCall.args[0].formData).eql(1);
+    });
+
     it("should render a string field with a label", () => {
       const { node } = createFormComponent({
         schema: {
diff --git a/test/utils_test.js b/test/utils_test.js
index deb24f5a3..0d97f0551 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -16,6 +16,7 @@ import {
   shouldRender,
   toDateString,
   toIdSchema,
+  guessType,
 } from "../src/utils";
 
 describe("utils", () => {
@@ -1322,4 +1323,30 @@ describe("utils", () => {
       );
     });
   });
+
+  describe("guessType()", () => {
+    it("should guess the type of array values", () => {
+      expect(guessType([1, 2, 3])).eql("array");
+    });
+
+    it("should guess the type of string values", () => {
+      expect(guessType("foobar")).eql("string");
+    });
+
+    it("should guess the type of null values", () => {
+      expect(guessType(null)).eql("null");
+    });
+
+    it("should treat undefined values as null values", () => {
+      expect(guessType()).eql("null");
+    });
+
+    it("should guess the type of boolean values", () => {
+      expect(guessType(true)).eql("boolean");
+    });
+
+    it("should guess the type of object values", () => {
+      expect(guessType({})).eql("object");
+    });
+  });
 });