From 3df0ab724e31b8742cc43465f699d1b2c9106649 Mon Sep 17 00:00:00 2001 From: fbessou Date: Mon, 12 Jun 2017 18:41:04 +0200 Subject: [PATCH] Support oneOf and anyOf as alternative of enum/enumNames (#581) * Add "alternatives" sample using oneOf/anyOf schemas * Retrieve items schema in isMultiSelect and isFilesArray * Support oneOf/anyOf constants in multiselect widgets * Add tests for isMultiSelect with support of oneOf and anyOf * Add support to oneOf/anyOf with constant schemas in StringField * Add tests for isConstant and toConstant --- playground/samples/alternatives.js | 64 ++++++++++++++++ playground/samples/index.js | 2 + src/components/fields/ArrayField.js | 11 +-- src/components/fields/SchemaField.js | 4 +- src/components/fields/StringField.js | 3 +- src/utils.js | 73 +++++++++++++++---- test/utils_test.js | 105 +++++++++++++++++++++++---- 7 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 playground/samples/alternatives.js diff --git a/playground/samples/alternatives.js b/playground/samples/alternatives.js new file mode 100644 index 0000000000..3aebe1f64b --- /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 1527696cc1..68fa8d22d8 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 df43053e69..872aa54be1 100644 --- a/src/components/fields/ArrayField.js +++ b/src/components/fields/ArrayField.js @@ -280,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(); diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js index f4f69dd5c8..471324c22f 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 1b87ce6754..9e9ed61604 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 91245fbd5b..a14299d57a 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 = {}) { diff --git a/test/utils_test.js b/test/utils_test.js index aaa7c3dbad..619b4fe863 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: {