Repository: mozilla-services/react-jsonschema-form
Pull request #581 (2017-05-17 14:15:16)

Support oneOf and anyOf as alternative of enum/enumNames
### Reasons for making this change

As mentioned by @dlax in #532 enumNames keyword is not in the JSON Schema specification (neither draft-04 nor draft-06).
Another way to support choices schemas is to write them with the following structure :
```js
{
  oneOf: [{
    enum: ["foo"],
    title: "Foo"
  }, {
    enum: ["bar"],
    title: "Bar"
  }]
}
```
or with draft-06's new `const` keyword:
```js
{
  oneOf: [{
    const: "foo",
    title: "Foo"
  }, {
    const: "bar",
    title: "Bar"
  }]
}
```
This PR adds basic support for oneOf/anyOf as an alternative to the non standard `enumNames` keywords.

### Checklist

* [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
* [x] **I'm adding a new feature**
  - [x] I've updated the playground with an example use of the feature


fbessou: Currently there isn't any JSDoc comment in the react-jsonschema-form sources. Should I add a simple comment or a JSDoc comment ? 
glasserc: We don't have any code that does anything specific with JSDoc comments and actually most functions are not documented using JSDoc, but adding a JSDoc comment is fine. It doesn't have to be complicated -- the example on validateFormData is fine.

Patch:
diff --git a/playground/samples/alternatives.js b/playground/samples/alternatives.js
new file mode 100644
index 000000000..3aebe1f64
--- /dev/null
+++ b/playground/samples/alternatives.js
@@ -0,0 +1,64 @@
+module.exports = {
+  schema: {
+    definitions: {
+      Color: {
+        title: "Color",
+        type: "string",
+        anyOf: [
+          {
+            type: "string",
+            enum: ["#ff0000"],
+            title: "Red",
+          },
+          {
+            type: "string",
+            enum: ["#00ff00"],
+            title: "Green",
+          },
+          {
+            type: "string",
+            enum: ["#0000ff"],
+            title: "Blue",
+          },
+        ],
+      },
+    },
+    title: "Image editor",
+    type: "object",
+    required: ["currentColor", "colorMask", "blendMode"],
+    properties: {
+      currentColor: {
+        $ref: "#/definitions/Color",
+        title: "Brush color",
+      },
+      colorMask: {
+        type: "array",
+        uniqueItems: true,
+        items: {
+          $ref: "#/definitions/Color",
+        },
+        title: "Color mask",
+      },
+      colorPalette: {
+        type: "array",
+        title: "Color palette",
+        items: {
+          $ref: "#/definitions/Color",
+        },
+      },
+      blendMode: {
+        title: "Blend mode",
+        type: "string",
+        enum: ["screen", "multiply", "overlay"],
+        enumNames: ["Screen", "Multiply", "Overlay"],
+      },
+    },
+  },
+  uiSchema: {},
+  formData: {
+    currentColor: "#00ff00",
+    colorMask: ["#0000ff"],
+    colorPalette: ["#ff0000"],
+    blendMode: "screen",
+  },
+};
diff --git a/playground/samples/index.js b/playground/samples/index.js
index 1527696cc..68fa8d22d 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -13,6 +13,7 @@ import validation from "./validation";
 import files from "./files";
 import single from "./single";
 import customArray from "./customArray";
+import alternatives from "./alternatives";
 
 export const samples = {
   Simple: simple,
@@ -30,4 +31,5 @@ export const samples = {
   Files: files,
   Single: single,
   "Custom Array": customArray,
+  Alternatives: alternatives,
 };
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index 5b910dff9..872aa54be 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -200,6 +200,21 @@ class ArrayField extends Component {
     return itemSchema.type !== "null";
   }
 
+  canAddItem(formItems) {
+    const { schema, uiSchema } = this.props;
+    let { addable } = getUiOptions(uiSchema);
+    if (addable !== false) {
+      // if ui:options.addable was not explicitly set to false, we can add
+      // another item if we have not exceeded maxItems yet
+      if (schema.maxItems !== undefined) {
+        addable = formItems.length < schema.maxItems;
+      } else {
+        addable = true;
+      }
+    }
+    return addable;
+  }
+
   onAddClick = event => {
     event.preventDefault();
     const { schema, formData, registry = getDefaultRegistry() } = this.props;
@@ -265,14 +280,15 @@ class ArrayField extends Component {
   };
 
   render() {
-    const { schema, uiSchema } = this.props;
-    if (isFilesArray(schema, uiSchema)) {
-      return this.renderFiles();
-    }
+    const { schema, uiSchema, registry = getDefaultRegistry() } = this.props;
+    const { definitions } = registry;
     if (isFixedItems(schema)) {
       return this.renderFixedArray();
     }
-    if (isMultiSelect(schema)) {
+    if (isFilesArray(schema, uiSchema, definitions)) {
+      return this.renderFiles();
+    }
+    if (isMultiSelect(schema, definitions)) {
       return this.renderMultiSelect();
     }
     return this.renderNormalArray();
@@ -298,10 +314,8 @@ class ArrayField extends Component {
     const { ArrayFieldTemplate, definitions, fields } = registry;
     const { TitleField, DescriptionField } = fields;
     const itemsSchema = retrieveSchema(schema.items, definitions);
-    const { addable = true } = getUiOptions(uiSchema);
-
     const arrayProps = {
-      canAdd: addable,
+      canAdd: this.canAddItem(formData),
       items: formData.map((item, index) => {
         const itemErrorSchema = errorSchema ? errorSchema[index] : undefined;
         const itemIdPrefix = idSchema.$id + "_" + index;
@@ -434,8 +448,6 @@ class ArrayField extends Component {
     const additionalSchema = allowAdditionalItems(schema)
       ? retrieveSchema(schema.additionalItems, definitions)
       : null;
-    const { addable = true } = getUiOptions(uiSchema);
-    const canAdd = addable && additionalSchema;
 
     if (!items || items.length < itemSchemas.length) {
       // to make sure at least all fixed items are generated
@@ -445,7 +457,7 @@ class ArrayField extends Component {
 
     // These are the props passed into the render function
     const arrayProps = {
-      canAdd,
+      canAdd: this.canAddItem(items) && additionalSchema,
       className: "field field-array field-array-fixed-items",
       disabled,
       idSchema,
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index f4f69dd5c..471324c22 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -161,7 +161,9 @@ function SchemaFieldRender(props) {
   const uiOptions = getUiOptions(uiSchema);
   let { label: displayLabel = true } = uiOptions;
   if (schema.type === "array") {
-    displayLabel = isMultiSelect(schema) || isFilesArray(schema, uiSchema);
+    displayLabel =
+      isMultiSelect(schema, definitions) ||
+      isFilesArray(schema, uiSchema, definitions);
   }
   if (schema.type === "object") {
     displayLabel = false;
diff --git a/src/components/fields/StringField.js b/src/components/fields/StringField.js
index 1b87ce675..9e9ed6160 100644
--- a/src/components/fields/StringField.js
+++ b/src/components/fields/StringField.js
@@ -4,6 +4,7 @@ import PropTypes from "prop-types";
 import {
   getWidget,
   getUiOptions,
+  isSelect,
   optionsList,
   getDefaultRegistry,
 } from "../../utils";
@@ -25,7 +26,7 @@ function StringField(props) {
   } = props;
   const { title, format } = schema;
   const { widgets, formContext } = registry;
-  const enumOptions = Array.isArray(schema.enum) && optionsList(schema);
+  const enumOptions = isSelect(schema) && optionsList(schema);
   const defaultWidget = format || (enumOptions ? "select" : "text");
   const { widget = defaultWidget, placeholder = "", ...options } = getUiOptions(
     uiSchema
diff --git a/src/utils.js b/src/utils.js
index 91245fbd5..a14299d57 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -277,21 +277,55 @@ export function orderProperties(properties, order) {
   return complete;
 }
 
-export function isMultiSelect(schema) {
-  return schema.items
-    ? Array.isArray(schema.items.enum) && schema.uniqueItems
-    : false;
-}
-
-export function isFilesArray(schema, uiSchema) {
+/**
+ * This function checks if the given schema matches a single
+ * constant value.
+ */
+export function isConstant(schema) {
   return (
-    (schema.items &&
-      schema.items.type === "string" &&
-      schema.items.format === "data-url") ||
-    uiSchema["ui:widget"] === "files"
+    (Array.isArray(schema.enum) && schema.enum.length === 1) ||
+    schema.hasOwnProperty("const")
   );
 }
 
+export function toConstant(schema) {
+  if (Array.isArray(schema.enum) && schema.enum.length === 1) {
+    return schema.enum[0];
+  } else if (schema.hasOwnProperty("const")) {
+    return schema.const;
+  } else {
+    throw new Error("schema cannot be inferred as a constant");
+  }
+}
+
+export function isSelect(_schema, definitions = {}) {
+  const schema = retrieveSchema(_schema, definitions);
+  const altSchemas = schema.oneOf || schema.anyOf;
+  if (Array.isArray(schema.enum)) {
+    return true;
+  } else if (Array.isArray(altSchemas)) {
+    return altSchemas.every(altSchemas => isConstant(altSchemas));
+  }
+  return false;
+}
+
+export function isMultiSelect(schema, definitions = {}) {
+  if (!schema.uniqueItems || !schema.items) {
+    return false;
+  }
+  return isSelect(schema.items, definitions);
+}
+
+export function isFilesArray(schema, uiSchema, definitions = {}) {
+  if (uiSchema["ui:widget"] === "files") {
+    return true;
+  } else if (schema.items) {
+    const itemsSchema = retrieveSchema(schema.items, definitions);
+    return itemsSchema.type === "string" && itemsSchema.format === "data-url";
+  }
+  return false;
+}
+
 export function isFixedItems(schema) {
   return (
     Array.isArray(schema.items) &&
@@ -308,10 +342,19 @@ export function allowAdditionalItems(schema) {
 }
 
 export function optionsList(schema) {
-  return schema.enum.map((value, i) => {
-    const label = (schema.enumNames && schema.enumNames[i]) || String(value);
-    return { label, value };
-  });
+  if (schema.enum) {
+    return schema.enum.map((value, i) => {
+      const label = (schema.enumNames && schema.enumNames[i]) || String(value);
+      return { label, value };
+    });
+  } else {
+    const altSchemas = schema.oneOf || schema.anyOf;
+    return altSchemas.map((schema, i) => {
+      const value = toConstant(schema);
+      const label = schema.title || String(value);
+      return { label, value };
+    });
+  }
 }
 
 function findSchemaDefinition($ref, definitions = {}) {


Test patch:
diff --git a/test/ArrayField_test.js b/test/ArrayField_test.js
index 4aa5d6975..db6e4fadd 100644
--- a/test/ArrayField_test.js
+++ b/test/ArrayField_test.js
@@ -116,6 +116,52 @@ describe("ArrayField", () => {
       expect(node.querySelectorAll(".field-string")).to.have.length.of(1);
     });
 
+    it("should not provide an add button if length equals maxItems", () => {
+      const { node } = createFormComponent({
+        schema: { maxItems: 2, ...schema },
+        formData: ["foo", "bar"],
+      });
+
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
+    it("should provide an add button if length is lesser than maxItems", () => {
+      const { node } = createFormComponent({
+        schema: { maxItems: 2, ...schema },
+        formData: ["foo"],
+      });
+
+      expect(node.querySelector(".array-item-add button")).not.eql(null);
+    });
+
+    it("should not provide an add button if addable is expliclty false regardless maxItems value", () => {
+      const { node } = createFormComponent({
+        schema: { maxItems: 2, ...schema },
+        formData: ["foo"],
+        uiSchema: {
+          "ui:options": {
+            addable: false,
+          },
+        },
+      });
+
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
+    it("should ignore addable value if maxItems constraint is not satisfied", () => {
+      const { node } = createFormComponent({
+        schema: { maxItems: 2, ...schema },
+        formData: ["foo", "bar"],
+        uiSchema: {
+          "ui:options": {
+            addable: true,
+          },
+        },
+      });
+
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
     it("should mark a non-null array item widget as required", () => {
       const { node } = createFormComponent({ schema });
 
@@ -792,6 +838,56 @@ describe("ArrayField", () => {
       expect(node.querySelector(".array-item-add button")).to.be.null;
     });
 
+    it("[fixed-noadditional] should not provide an add button regardless maxItems", () => {
+      const { node } = createFormComponent({
+        schema: { maxItems: 3, ...schema },
+      });
+
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
+    it("[fixed] should not provide an add button if length equals maxItems", () => {
+      const { node } = createFormComponent({
+        schema: { maxItems: 2, ...schemaAdditional },
+      });
+
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
+    it("[fixed] should provide an add button if length is lesser than maxItems", () => {
+      const { node } = createFormComponent({
+        schema: { maxItems: 3, ...schemaAdditional },
+      });
+
+      expect(node.querySelector(".array-item-add button")).not.to.be.null;
+    });
+
+    it("[fixed] should not provide an add button if addable is expliclty false regardless maxItems value", () => {
+      const { node } = createFormComponent({
+        schema: { maxItems: 2, ...schema },
+        uiSchema: {
+          "ui:options": {
+            addable: false,
+          },
+        },
+      });
+
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
+    it("[fixed] should ignore addable value if maxItems constraint is not satisfied", () => {
+      const { node } = createFormComponent({
+        schema: { maxItems: 2, ...schema },
+        uiSchema: {
+          "ui:options": {
+            addable: true,
+          },
+        },
+      });
+
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
     describe("operations for additional items", () => {
       const { comp, node } = createFormComponent({
         schema: schemaAdditional,
diff --git a/test/utils_test.js b/test/utils_test.js
index aaa7c3dba..619b4fe86 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -6,6 +6,8 @@ import {
   deepEquals,
   getDefaultFormState,
   isFilesArray,
+  isConstant,
+  toConstant,
   isMultiSelect,
   mergeObjects,
   pad,
@@ -269,24 +271,101 @@ 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;
+  describe("isConstant", () => {
+    it("should return false when neither enum nor const is defined", () => {
+      const schema = {};
+      expect(isConstant(schema)).to.be.false;
     });
 
-    it("should be false if items is undefined", () => {
-      const schema = {};
-      expect(isMultiSelect(schema)).to.be.false;
+    it("should return true when schema enum is an array of one item", () => {
+      const schema = { enum: ["foo"] };
+      expect(isConstant(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 return false when schema enum contains several items", () => {
+      const schema = { enum: ["foo", "bar", "baz"] };
+      expect(isConstant(schema)).to.be.false;
+    });
+
+    it("should return true when schema const is defined", () => {
+      const schema = { const: "foo" };
+      expect(isConstant(schema)).to.be.true;
+    });
+  });
+
+  describe("toConstant()", () => {
+    describe("schema contains an enum array", () => {
+      it("should return its first value when it contains a unique element", () => {
+        const schema = { enum: ["foo"] };
+        expect(toConstant(schema)).eql("foo");
+      });
+
+      it("should return schema const value when it exists", () => {
+        const schema = { const: "bar" };
+        expect(toConstant(schema)).eql("bar");
+      });
+
+      it("should throw when it contains more than one element", () => {
+        const schema = { enum: ["foo", "bar"] };
+        expect(() => {
+          toConstant(schema);
+        }).to.Throw(Error, "cannot be inferred");
+      });
+    });
+  });
+
+  describe("isMultiSelect()", () => {
+    describe("uniqueItems is true", () => {
+      describe("schema items enum is an array", () => {
+        it("should be true", () => {
+          let schema = { items: { enum: ["foo", "bar"] }, uniqueItems: true };
+          expect(isMultiSelect(schema)).to.be.true;
+        });
+      });
+
+      it("should be false if items is undefined", () => {
+        const schema = {};
+        expect(isMultiSelect(schema)).to.be.false;
+      });
+
+      describe("schema items enum is not an array", () => {
+        const constantSchema = { type: "string", enum: ["Foo"] };
+        const notConstantSchema = { type: "string" };
+
+        it("should be false if oneOf/anyOf is not in items schema", () => {
+          const schema = { items: {}, uniqueItems: true };
+          expect(isMultiSelect(schema)).to.be.false;
+        });
+
+        it("should be false if oneOf/anyOf schemas are not all constants", () => {
+          const schema = {
+            items: { oneOf: [constantSchema, notConstantSchema] },
+            uniqueItems: true,
+          };
+          expect(isMultiSelect(schema)).to.be.false;
+        });
+
+        it("should be true if oneOf/anyOf schemas are all constants", () => {
+          const schema = {
+            items: { oneOf: [constantSchema, constantSchema] },
+            uniqueItems: true,
+          };
+          expect(isMultiSelect(schema)).to.be.true;
+        });
+      });
+
+      it("should retrieve reference schema definitions", () => {
+        const schema = {
+          items: { $ref: "#/definitions/FooItem" },
+          uniqueItems: true,
+        };
+        const definitions = { FooItem: { type: "string", enum: ["foo"] } };
+        expect(isMultiSelect(schema, definitions)).to.be.true;
+      });
     });
 
-    it("should be false if schema items enum is not an array", () => {
-      const schema = { items: {}, uniqueItems: true };
+    it("should be false if uniqueItems is false", () => {
+      const schema = { items: { enum: ["foo", "bar"] }, uniqueItems: false };
       expect(isMultiSelect(schema)).to.be.false;
     });
   });
@@ -605,7 +684,7 @@ describe("utils", () => {
       });
     });
 
-    it("should retrieve reference schema definitions", () => {
+    it("should retrieve referenced schema definitions", () => {
       const schema = {
         definitions: {
           testdef: {