Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for anyOf #417

Closed
wants to merge 11 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions playground/samples/anyOf.js
Original file line number Diff line number Diff line change
@@ -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"
]
]
}
};
2 changes: 2 additions & 0 deletions playground/samples/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,4 +31,5 @@ export const samples = {
Files: files,
Single: single,
"Custom Array": customArray,
"Any of": anyOf
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first this bothered me, but I see our capitalization isn't consistent -- Custom Array vs. Date & time.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In any case (😉), leaving "of" as lowercase is correct, even when you're using title case. http://grammar.yourdictionary.com/capitalization/rules-for-capitalization-in-titles.html

};
164 changes: 138 additions & 26 deletions src/components/fields/ArrayField.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function DefaultArrayItem(props) {
<div key={props.index} className={props.className}>

<div className={props.hasToolbar ? "col-xs-9" : "col-xs-12"}>
{props.selectWidget}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems kind of strange for selectWidget to be part of the props, and not part of the template.

{props.children}
</div>

Expand Down Expand Up @@ -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) {
Expand All @@ -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";
Expand All @@ -191,34 +222,59 @@ 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) => {
return (event) => {
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
};
};

Expand All @@ -228,35 +284,69 @@ 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) {
return items[newIndex];
} 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}));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be label: item.title?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot, thanks! I think it should, yes. Maybe item.title || item.type, in case it's not defined...

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me!

}

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work with things like $ref?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried it & it doesn't support $ref

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)) {
Expand Down Expand Up @@ -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,
Expand All @@ -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}`,
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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,
Expand All @@ -481,6 +580,16 @@ class ArrayField extends Component {
};
has.toolbar = Object.keys(has).some(key => has[key]);

const selectWidget = anyOfItemsSchema ? (
<div className="form-group" style={{width: 120}}>
<SelectWidget
schema={{type: "integer", default: selectWidgetValue}}
id="select-widget-id"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same ID shouldn't be used more than once in a DOM.

options={{enumOptions: this.anyOfOptions(anyOfItemsSchema)}}
value={selectWidgetValue}
onChange={(value) => this.setWidgetType(index, value)}/>
</div>
) : null;
return {
children: (
<SchemaField
Expand All @@ -497,6 +606,7 @@ class ArrayField extends Component {
readonly={this.props.readonly}
autofocus={autofocus}/>
),
selectWidget: selectWidget,
className: "array-item",
disabled,
hasToolbar: has.toolbar,
Expand All @@ -506,7 +616,9 @@ class ArrayField extends Component {
index,
onDropIndexClick: this.onDropIndexClick,
onReorderClick: this.onReorderClick,
readonly
readonly,
anyOfItemsSchema,
selectWidgetValue
};
}
}
Expand Down
Loading