diff --git a/playground/samples/anyOf.js b/playground/samples/anyOf.js new file mode 100644 index 0000000000..47bd662eb9 --- /dev/null +++ b/playground/samples/anyOf.js @@ -0,0 +1,50 @@ +module.exports = { + schema: { + "title": "Any of", + "type": "object", + "properties": { + "List of widgets": { + "type": "array", + "items": { + "anyOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + }, + { + "title": "array", + "type": "array", + "items": { + "anyOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + } + ] + } + } + ] + } + } + } + }, + uiSchema: {}, + formData: { + "List of widgets": [ + 27, + "Batman", + [ + "Bruce", + "Wayne" + ] + ] + } +}; diff --git a/playground/samples/index.js b/playground/samples/index.js index 1527696cc1..6414ae3ba5 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 anyOf from "./anyOf"; export const samples = { Simple: simple, @@ -30,4 +31,5 @@ export const samples = { Files: files, Single: single, "Custom Array": customArray, + "Any of": anyOf }; diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js index b9d4e54b14..28ef75f709 100644 --- a/src/components/fields/ArrayField.js +++ b/src/components/fields/ArrayField.js @@ -50,6 +50,7 @@ function DefaultArrayItem(props) {
+ {props.selectWidget} {props.children}
@@ -161,11 +162,19 @@ class ArrayField extends Component { constructor(props) { super(props); - this.state = this.getStateFromProps(props); + const formData = this.getStateFromProps(props); + let anyOfItems = []; + if (this.getAnyOfItemsSchema()) { + // We need to construct the initial anyOfItems state, by searching for the props anyOf items + // in the available anyOf schema items + anyOfItems = this.getAnyOfItemsFromProps(formData.items, props.schema.items.anyOf); + } + this.state = {formData: formData, anyOfItems: anyOfItems}; } componentWillReceiveProps(nextProps) { - this.setState(this.getStateFromProps(nextProps)); + const newState = Object.assign({}, this.state, {formData: this.getStateFromProps(nextProps)}); + this.setState(newState); } getStateFromProps(props) { @@ -180,6 +189,28 @@ class ArrayField extends Component { return shouldRender(this, nextProps, nextState); } + getAnyOfItemsFromProps(formDataItems, anyOfSchema) { + return formDataItems.map((item) => { + const type = typeof item; + const itemType = (type === "object" && Array.isArray(item)) ? "array" : type; + const schema = this.getAnyOfItemSchema(anyOfSchema, itemType); + + // If this schema is an array, we need to recursively add its contents + if (schema.type === "array") { + this.getAnyOfItemsFromProps(item, schema.items.anyOf); + } + + return schema; + }); + } + + getAnyOfItemSchema(anyOfSchema, type) { + return anyOfSchema.find((schemaElement) => { + const schemaElementType = schemaElement.type === "integer" ? "number" : schemaElement.type; + return schemaElementType === type; + }); + } + get itemTitle() { const {schema} = this.props; return schema.items.title || schema.items.description || "Item"; @@ -191,24 +222,44 @@ class ArrayField extends Component { asyncSetState(state, options={validate: false}) { setState(this, state, () => { - this.props.onChange(this.state.items, options); + this.props.onChange(this.state.formData.items, options); }); } + getAnyOfItemsSchema() { + const {schema} = this.props; + return schema.items.anyOf; + } + onAddClick = (event) => { event.preventDefault(); - const {items} = this.state; + const {items} = this.state.formData; const {schema, registry} = this.props; const {definitions} = registry; let itemSchema = schema.items; + const anyOfItems = this.getAnyOfItemsSchema(); if (isFixedItems(schema) && allowAdditionalItems(schema)) { itemSchema = schema.additionalItems; } - this.asyncSetState({ + + let newAnyOfItems = []; + if (anyOfItems) { + // We pick the first anyOf item by default + itemSchema = anyOfItems[0]; + + newAnyOfItems = [ + ...this.state.anyOfItems, + itemSchema + ]; + } + + const newItems = { items: items.concat([ getDefaultFormState(itemSchema, undefined, definitions) ]) - }); + }; + const newState = Object.assign({}, this.state, {formData: newItems, anyOfItems: newAnyOfItems}); + this.asyncSetState(newState); }; onDropIndexClick = (index) => { @@ -216,9 +267,14 @@ class ArrayField extends Component { if (event) { event.preventDefault(); } - this.asyncSetState({ - items: this.state.items.filter((_, i) => i !== index) - }, {validate: true}); // refs #195 + const {formData: {items}, anyOfItems} = this.state; + const newItems = { + items: items.filter((_, i) => i !== index) + }; + const newAnyOfItems = anyOfItems.filter((_, i) => i !== index); + const newState = Object.assign({}, this.state, + {formData: newItems, anyOfItems: newAnyOfItems}); + this.asyncSetState(newState, {validate: true}); // refs #195 }; }; @@ -228,9 +284,10 @@ class ArrayField extends Component { event.preventDefault(); event.target.blur(); } - const {items} = this.state; - this.asyncSetState({ - items: items.map((item, i) => { + const {formData: {items}, anyOfItems} = this.state; + + const reorder = (items, newIndex) => + items.map((item, i) => { if (i === newIndex) { return items[index]; } else if (i === index) { @@ -238,25 +295,58 @@ class ArrayField extends Component { } else { return item; } - }) - }, {validate: true}); + }); + + const newItems = { + items: reorder(items, newIndex) + }; + const newAnyOfItems = reorder(anyOfItems, newIndex); + + const newState = Object.assign({}, this.state, + {formData: newItems}, {anyOfItems: newAnyOfItems}); + this.asyncSetState(newState, {validate: true}); }; }; onChangeForIndex = (index) => { return (value) => { - this.asyncSetState({ - items: this.state.items.map((item, i) => { + const items = { + items: this.state.formData.items.map((item, i) => { return index === i ? value : item; }) - }); + }; + const newState = Object.assign({}, this.state, {formData: items}); + this.asyncSetState(newState); }; }; onSelectChange = (value) => { - this.asyncSetState({items: value}); + const newState = Object.assign({}, this.state, {formData: {items: value}}); + this.asyncSetState(newState); }; + anyOfOptions(anyOfItems) { + return anyOfItems.map(item => ({value: item.type, label: item.type})); + } + + setWidgetType(index, value) { + const {items} = this.state.formData; + const {registry} = this.props; + const {definitions} = registry; + const anyOfItemsSchema = this.getAnyOfItemsSchema(); + const newItems = items.slice(); + const foundItem = anyOfItemsSchema.find((element) => element.type === value); + newItems[index] = getDefaultFormState(foundItem, undefined, definitions); + + const newAnyOfItems = [...this.state.anyOfItems]; + newAnyOfItems[index] = foundItem; + + const newState = Object.assign({}, this.state, + {formData: {items: newItems}, anyOfItems: newAnyOfItems}); + + this.asyncSetState(newState); + } + render() { const {schema, uiSchema} = this.props; if (isFilesArray(schema, uiSchema)) { @@ -287,17 +377,21 @@ class ArrayField extends Component { onBlur } = this.props; const title = (schema.title === undefined) ? name : schema.title; - const {items = []} = this.state; + const {formData: {items = []}, anyOfItems} = this.state; const {ArrayFieldTemplate, definitions, fields} = registry; const {TitleField, DescriptionField} = fields; - const itemsSchema = retrieveSchema(schema.items, definitions); + let itemsSchema = retrieveSchema(schema.items, definitions); const {addable=true} = getUiOptions(uiSchema); + const anyOfItemsSchema = this.getAnyOfItemsSchema(); const arrayProps = { canAdd: addable, items: items.map((item, index) => { const itemErrorSchema = errorSchema ? errorSchema[index] : undefined; const itemIdPrefix = idSchema.$id + "_" + index; + if (anyOfItemsSchema) { + itemsSchema = anyOfItems[index]; + } const itemIdSchema = toIdSchema(itemsSchema, itemIdPrefix, definitions); return this.renderArrayFieldItem({ index, @@ -309,7 +403,9 @@ class ArrayField extends Component { itemData: items[index], itemUiSchema: uiSchema.items, autofocus: autofocus && index === 0, - onBlur + onBlur, + anyOfItemsSchema: anyOfItemsSchema, + selectWidgetValue: anyOfItems.length > 0 ? anyOfItems[index].type : "" }); }), className: `field field-array field-array-of-${itemsSchema.type}`, @@ -332,7 +428,7 @@ class ArrayField extends Component { renderMultiSelect() { const {schema, idSchema, uiSchema, disabled, readonly, autofocus, onBlur} = this.props; - const {items} = this.state; + const {items} = this.state.formData; const {widgets, definitions, formContext} = this.props.registry; const itemsSchema = retrieveSchema(schema.items, definitions); const enumOptions = optionsList(itemsSchema); @@ -357,7 +453,7 @@ class ArrayField extends Component { renderFiles() { const {schema, uiSchema, idSchema, name, disabled, readonly, autofocus, onBlur} = this.props; const title = schema.title || name; - const {items} = this.state; + const {items} = this.state.formData; const {widgets, formContext} = this.props.registry; const {widget="files", ...options} = getUiOptions(uiSchema); const Widget = getWidget(schema, widget, widgets); @@ -393,7 +489,7 @@ class ArrayField extends Component { onBlur } = this.props; const title = schema.title || name; - let {items} = this.state; + let {items} = this.state.formData; const {ArrayFieldTemplate, definitions, fields} = registry; const {TitleField} = fields; const itemSchemas = schema.items.map(item => @@ -465,9 +561,12 @@ class ArrayField extends Component { itemIdSchema, itemErrorSchema, autofocus, - onBlur + onBlur, + anyOfItemsSchema, + selectWidgetValue }) { const {SchemaField} = this.props.registry.fields; + const {SelectWidget} = this.props.registry.widgets; const {disabled, readonly, uiSchema} = this.props; const {orderable, removable} = { orderable: true, @@ -481,6 +580,16 @@ class ArrayField extends Component { }; has.toolbar = Object.keys(has).some(key => has[key]); + const selectWidget = anyOfItemsSchema ? ( +
+ this.setWidgetType(index, value)}/> +
+ ) : null; return { children: ( ), + selectWidget: selectWidget, className: "array-item", disabled, hasToolbar: has.toolbar, @@ -506,7 +616,9 @@ class ArrayField extends Component { index, onDropIndexClick: this.onDropIndexClick, onReorderClick: this.onReorderClick, - readonly + readonly, + anyOfItemsSchema, + selectWidgetValue }; } } diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js index 14aab87add..a56b3e5203 100644 --- a/src/components/fields/SchemaField.js +++ b/src/components/fields/SchemaField.js @@ -18,7 +18,7 @@ const COMPONENT_TYPES = { string: "StringField", }; -function getFieldComponent(schema, uiSchema, fields) { +function getFieldComponent(schema, name, uiSchema, fields) { const field = uiSchema["ui:field"]; if (typeof field === "function") { return field; @@ -26,7 +26,11 @@ function getFieldComponent(schema, uiSchema, fields) { if (typeof field === "string" && field in fields) { return fields[field]; } - const componentName = COMPONENT_TYPES[schema.type]; + + + // anyOf logic is handled inside the ArrayField component + const type = name === "anyOf" ? "array" : schema.type; + const componentName = COMPONENT_TYPES[type]; return componentName in fields ? fields[componentName] : UnsupportedField; } @@ -132,7 +136,7 @@ function SchemaField(props) { const {uiSchema, errorSchema, idSchema, name, required, registry} = props; const {definitions, fields, formContext, FieldTemplate = DefaultTemplate} = registry; const schema = retrieveSchema(props.schema, definitions); - const FieldComponent = getFieldComponent(schema, uiSchema, fields); + const FieldComponent = getFieldComponent(schema, name, uiSchema, fields); const {DescriptionField} = fields; const disabled = Boolean(props.disabled || uiSchema["ui:disabled"]); const readonly = Boolean(props.readonly || uiSchema["ui:readonly"]); @@ -158,7 +162,6 @@ function SchemaField(props) { } const {__errors, ...fieldErrorSchema} = errorSchema; - const field = ( { expect(node.querySelector("#title-")).to.be.null; }); }); + describe("Any of", () => { + const schema = { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }; + + let node, comp; + + beforeEach(() => { + const form = createFormComponent({schema}); + node = form.node; + comp = form.comp; + Simulate.click(node.querySelector(".array-item-add button")); + }); + + it("should render an add button", () => { + expect(node.querySelector(".array-item-add button")) + .not.eql(null); + }); + + it("should render a select along with the input field", () => { + expect(node.querySelectorAll(".array-item")) + .to.have.length.of(1); + }); + + it("should render two select and input fields", () => { + Simulate.click(node.querySelector(".array-item-add button")); + expect(node.querySelectorAll(".array-item")).to.have.length.of(2); + }); + + it("should change the type of the widget", () => { + Simulate.change(node.querySelector(".array-item select"), { + target: {value: "number"} + }); + expect(node.querySelector(".array-item select").value).eql("number"); + expect(node.querySelectorAll(".array-item .field-number")).to.have.length.of(1); + }); + + it("should be created with correct type", () => { + Simulate.change(node.querySelector("fieldset .field-string input[type=text]"), {target: {value: "asd"}}); + expect(node.querySelector("fieldset .field-string input[type=text]").value).eql("asd"); + expect(comp.state.formData).eql(["asd"]); + }); + + it("should clear the value after type update", () => { + Simulate.change(node.querySelector("fieldset .field-string input[type=text]"), {target: {value: "bar"}}); + Simulate.change(node.querySelector(".array-item select"), { + target: {value: "number"} + }); + + expect(node.querySelector("fieldset .field-number input[type=text]").value).eql(""); + }); + + it("should update types accordingly", () => { + Simulate.click(node.querySelector(".array-item-add button")); + const selects = node.querySelectorAll(".array-item select"); + + Simulate.change(selects[0], { + target: {value: "number"} + }); + + Simulate.change(node.querySelector("fieldset .field-number input[type=text]"), {target: {value: "123"}}); + Simulate.change(node.querySelector("fieldset .field-string input[type=text]"), {target: {value: "123"}}); + expect(comp.state.formData).eql([123, "123"]); + }); + + it("should delete the correct widget", () => { + Simulate.click(node.querySelector(".array-item-add button")); + + const selects = node.querySelectorAll(".array-item select"); + + Simulate.change(selects[0], { + target: {value: "number"} + }); + + const inputs = node.querySelectorAll("input[type=text]"); + Simulate.change(inputs[0], {target: {value: 123}}); + Simulate.change(inputs[1], {target: {value: "abc"}}); + + const dropBtns = node.querySelectorAll(".array-item-remove"); + + Simulate.click(dropBtns[0]); + expect(node.querySelectorAll(".array-item .field-number")).to.have.length.of(0); + expect(comp.state.formData).eql(["abc"]); + }); + + it("should reorder widgets correctly", () => { + Simulate.click(node.querySelector(".array-item-add button")); + + const selects = node.querySelectorAll(".array-item select"); + + Simulate.change(selects[0], { + target: {value: "number"} + }); + + const inputs = node.querySelectorAll("input[type=text]"); + Simulate.change(inputs[0], {target: {value: 123}}); + Simulate.change(inputs[1], {target: {value: "abc"}}); + + const moveDownBtns = node.querySelectorAll(".array-item-move-down"); + Simulate.click(moveDownBtns[0]); + + expect(node.querySelectorAll(".array-item select")[0].value).eql("string"); + expect(comp.state.formData).eql(["abc", 123]); + }); + }); });