diff --git a/README.md b/README.md index c504811bd9..02a865bf1d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ A [live playground](https://mozilla-services.github.io/react-jsonschema-form/) i - [Form attributes](#form-attributes) - [Advanced customization](#advanced-customization) - [Field template](#field-template) + - [Array field template](#array-field-template) - [Custom widgets and fields](#custom-widgets-and-fields) - [Custom widget components](#custom-widget-components) - [Custom component registration](#custom-component-registration) @@ -700,6 +701,58 @@ The following props are passed to a custom field template component: > Note: you can only define a single field template for a form. If you need many, it's probably time to look at [custom fields](#custom-field-components) instead. +### Array Field Template + +Similarly to the `FieldTemplate` you can use an `ArrayFieldTemplate` to customize how your +arrays are rendered. This allows you to customize your array, and each element in the array. + +```jsx +function ArrayFieldTemplate(props) { + return ( +
+ {props.items.map(element => element.children)} + {props.canAdd && } +
+ ); +} + +render(( +
, +), document.getElementById("app")); +``` + +Please see [customArray.js](https://github.com/mozilla-services/react-jsonschema-form/blob/master/playground/samples/customArray.js) for a better example. + +The following props are passed to each `ArrayFieldTemplate`: + +- `DescriptionField`: The generated `DescriptionField` (if you wanted to utilize it) +- `TitleField`: The generated `TitleField` (if you wanted to utilize it). +- `canAdd`: A boolean value stating whether new elements can be added to the array. +- `className`: The className string. +- `disabled`: A boolean value stating if the array is disabled. +- `idSchema`: Object +- `items`: An array of objects representing the items in the array. Each of the items represent a child with properties described below. +- `onAddClick: (event) => (event) => void`: Returns a function that adds a new item to the array. +- `readonly`: A boolean value stating if the array is readonly. +- `required`: A boolean value stating if the array is required. +- `schema`: The schema object for this array. +- `title`: A string value containing the title for the array. + +The following props are part of each element in `items`: + +- `children`: The html for the item's content. +- `className`: The className string. +- `disabled`: A boolean value stating if the array item is disabled. +- `hasMoveDown`: A boolean value stating whether the array item can be moved down. +- `hasMoveUp`: A boolean value stating whether the array item can be moved up. +- `hasRemove`: A boolean value stating whether the array item can be removed. +- `hasToolbar`: A boolean value stating whether the array item has a toolbar. +- `index`: A number stating the index the array item occurs in `items`. +- `onDropIndexClick: (index) => (event) => void`: Returns a function that removes the item at `index`. +- `onReorderClick: (index, newIndex) => (event) => void`: Returns a function that swaps the items at `index` with `newIndex`. +- `readonly`: A boolean value stating if the array item is readonly. + ### Custom widgets and fields The API allows to specify your own custom *widget* and *field* components: diff --git a/playground/app.js b/playground/app.js index c792554327..649ebd6b77 100644 --- a/playground/app.js +++ b/playground/app.js @@ -283,9 +283,11 @@ class App extends Component { } load = (data) => { + // Reset the ArrayFieldTemplate whenever you load new data + const { ArrayFieldTemplate } = data; // force resetting form component instance this.setState({form: false}, - _ => this.setState({...data, form: true})); + _ => this.setState({...data, form: true, ArrayFieldTemplate})); }; onSchemaEdited = (schema) => this.setState({schema}); @@ -314,7 +316,8 @@ class App extends Component { liveValidate, validate, theme, - editor + editor, + ArrayFieldTemplate } = this.state; return ( @@ -352,6 +355,7 @@ class App extends Component {
{!this.state.form ? null : + + {props.items && props.items.map(element => ( +
+
{element.children}
+ {element.hasMoveDown && + } + {element.hasMoveUp && + } + +
+
+ ))} + + {props.canAdd && +
+

+ +

+
} + +
+ ); +} + +module.exports = { + schema: { + title: "Custom array of strings", + type: "array", + items: { + type: "string" + } + }, + formData: ["react", "jsonschema", "form"], + ArrayFieldTemplate +}; diff --git a/playground/samples/index.js b/playground/samples/index.js index 5f6d280dcc..8eba6cc825 100644 --- a/playground/samples/index.js +++ b/playground/samples/index.js @@ -12,6 +12,7 @@ import date from "./date"; import validation from "./validation"; import files from "./files"; import single from "./single"; +import customArray from "./customArray"; export const samples = { Simple: simple, @@ -27,5 +28,6 @@ export const samples = { "Date & time": date, Validation: validation, Files: files, - Single: single + Single: single, + "Custom Array": customArray }; diff --git a/src/components/Form.js b/src/components/Form.js index 32819cc75b..89b83cd4fb 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -119,6 +119,7 @@ export default class Form extends Component { return { fields: {...fields, ...this.props.fields}, widgets: {...widgets, ...this.props.widgets}, + ArrayFieldTemplate: this.props.ArrayFieldTemplate, FieldTemplate: this.props.FieldTemplate, definitions: this.props.schema.definitions || {}, formContext: this.props.formContext || {}, @@ -184,6 +185,7 @@ if (process.env.NODE_ENV !== "production") { PropTypes.object, ])), fields: PropTypes.objectOf(PropTypes.func), + ArrayFieldTemplate: PropTypes.func, FieldTemplate: PropTypes.func, onChange: PropTypes.func, onError: PropTypes.func, diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js index 6239274bec..987724d776 100644 --- a/src/components/fields/ArrayField.js +++ b/src/components/fields/ArrayField.js @@ -43,6 +43,111 @@ function IconBtn(props) { ); } +// Used in the two templates +function DefaultArrayItem(props) { + const btnStyle = {flex: 1, paddingLeft: 6, paddingRight: 6, fontWeight: "bold"}; + return ( +
+ +
+ {props.children} +
+ + {props.hasToolbar ? +
+
+ + {props.hasMoveUp || props.hasMoveDown ? + + : null} + + {props.hasMoveUp || props.hasMoveDown ? + + : null} + + {props.hasRemove ? + + : null} +
+
+ : null} + +
+ ); +} + +function DefaultFixedArrayFieldTemplate(props) { + return ( +
+ + + + {props.schema.description ? ( +
+ {props.schema.description} +
+ ) : null} + +
+ {props.items && props.items.map(DefaultArrayItem)} +
+ + {props.canAdd ? : null} +
+ ); +} + +function DefaultNormalArrayFieldTemplate(props) { + return ( +
+ + + + {props.schema.description ? ( + + ) : null} + +
+ {props.items && props.items.map(p => DefaultArrayItem(p))} +
+ + {props.canAdd ? : null} +
+ ); +} + class ArrayField extends Component { static defaultProps = { uiSchema: {}, @@ -108,7 +213,9 @@ class ArrayField extends Component { onDropIndexClick = (index) => { return (event) => { - event.preventDefault(); + if (event) { + event.preventDefault(); + } this.asyncSetState({ items: this.state.items.filter((_, i) => i !== index) }, {validate: true}); // refs #195 @@ -117,8 +224,10 @@ class ArrayField extends Component { onReorderClick = (index, newIndex) => { return (event) => { - event.preventDefault(); - event.target.blur(); + if (event) { + event.preventDefault(); + event.target.blur(); + } const {items} = this.state; this.asyncSetState({ items: items.map((item, i) => { @@ -173,50 +282,48 @@ class ArrayField extends Component { disabled, readonly, autofocus, + registry } = this.props; const title = (schema.title === undefined) ? name : schema.title; - const {items} = this.state; - const {definitions, fields} = this.props.registry; + const {items = []} = this.state; + const {ArrayFieldTemplate, definitions, fields} = registry; const {TitleField, DescriptionField} = fields; const itemsSchema = retrieveSchema(schema.items, definitions); const {addable=true} = getUiOptions(uiSchema); - return ( -
- - {schema.description ? - : null} -
{ - items.map((item, index) => { - const itemErrorSchema = errorSchema ? errorSchema[index] : undefined; - const itemIdPrefix = idSchema.$id + "_" + index; - const itemIdSchema = toIdSchema(itemsSchema, itemIdPrefix, definitions); - return this.renderArrayFieldItem({ - index, - canMoveUp: index > 0, - canMoveDown: index < items.length - 1, - itemSchema: itemsSchema, - itemIdSchema, - itemErrorSchema, - itemData: items[index], - itemUiSchema: uiSchema.items, - autofocus: autofocus && index === 0 - }); - }) - }
- {addable ? : null} -
- ); + const arrayProps = { + canAdd: addable, + items: items.map((item, index) => { + const itemErrorSchema = errorSchema ? errorSchema[index] : undefined; + const itemIdPrefix = idSchema.$id + "_" + index; + const itemIdSchema = toIdSchema(itemsSchema, itemIdPrefix, definitions); + return this.renderArrayFieldItem({ + index, + canMoveUp: index > 0, + canMoveDown: index < items.length - 1, + itemSchema: itemsSchema, + itemIdSchema, + itemErrorSchema, + itemData: items[index], + itemUiSchema: uiSchema.items, + autofocus: autofocus && index === 0 + }); + }), + className: `field field-array field-array-of-${itemsSchema.type}`, + DescriptionField, + disabled, + idSchema, + onAddClick: this.onAddClick, + readonly, + required, + schema, + title, + TitleField + }; + + // Check if a custom render function was passed in + const renderFunction = ArrayFieldTemplate || DefaultNormalArrayFieldTemplate; + return renderFunction(arrayProps); } renderMultiSelect() { @@ -274,10 +381,11 @@ class ArrayField extends Component { disabled, readonly, autofocus, + registry } = this.props; const title = schema.title || name; let {items} = this.state; - const {definitions, fields} = this.props.registry; + const {ArrayFieldTemplate, definitions, fields} = registry; const {TitleField} = fields; const itemSchemas = schema.items.map(item => retrieveSchema(item, definitions)); @@ -292,49 +400,48 @@ class ArrayField extends Component { items = items.concat(new Array(itemSchemas.length - items.length)); } - return ( -
- - {schema.description ? -
{schema.description}
: null} -
{ - items.map((item, index) => { - const additional = index >= itemSchemas.length; - const itemSchema = additional ? - additionalSchema : itemSchemas[index]; - const itemIdPrefix = idSchema.$id + "_" + index; - const itemIdSchema = toIdSchema(itemSchema, itemIdPrefix, definitions); - const itemUiSchema = additional ? - uiSchema.additionalItems || {} : - Array.isArray(uiSchema.items) ? - uiSchema.items[index] : uiSchema.items || {}; - const itemErrorSchema = errorSchema ? errorSchema[index] : undefined; - - return this.renderArrayFieldItem({ - index, - canRemove: additional, - canMoveUp: index >= itemSchemas.length + 1, - canMoveDown: additional && index < items.length - 1, - itemSchema, - itemData: item, - itemUiSchema, - itemIdSchema, - itemErrorSchema, - autofocus: autofocus && index === 0 - }); - }) - }
- { - canAdd ? : null - } -
- ); + // These are the props passed into the render function + const arrayProps = { + canAdd, + className: "field field-array field-array-fixed-items", + disabled, + idSchema, + items: items.map((item, index) => { + const additional = index >= itemSchemas.length; + const itemSchema = additional ? + additionalSchema : itemSchemas[index]; + const itemIdPrefix = idSchema.$id + "_" + index; + const itemIdSchema = toIdSchema(itemSchema, itemIdPrefix, definitions); + const itemUiSchema = additional ? + uiSchema.additionalItems || {} : + Array.isArray(uiSchema.items) ? + uiSchema.items[index] : uiSchema.items || {}; + const itemErrorSchema = errorSchema ? errorSchema[index] : undefined; + + return this.renderArrayFieldItem({ + index, + canRemove: additional, + canMoveUp: index >= itemSchemas.length + 1, + canMoveDown: additional && index < items.length - 1, + itemSchema, + itemData: item, + itemUiSchema, + itemIdSchema, + itemErrorSchema, + autofocus: autofocus && index === 0 + }); + }), + onAddClick: this.onAddClick, + readonly, + required, + schema, + title, + TitleField + }; + + // Check if a custom template template was passed in + const renderFunction = ArrayFieldTemplate || DefaultFixedArrayFieldTemplate; + return renderFunction(arrayProps); } renderArrayFieldItem({ @@ -362,55 +469,33 @@ class ArrayField extends Component { remove: removable && canRemove }; has.toolbar = Object.keys(has).some(key => has[key]); - const btnStyle = {flex: 1, paddingLeft: 6, paddingRight: 6, fontWeight: "bold"}; - return ( -
-
- -
- { - has.toolbar ? -
-
- {has.moveUp || has.moveDown ? - - : null} - {has.moveUp || has.moveDown ? - - : null} - {has.remove ? - - : null} -
-
- : null - } -
- ); + return { + children: ( + + ), + className: "array-item", + disabled, + hasToolbar: has.toolbar, + hasMoveUp: has.moveUp, + hasMoveDown: has.moveDown, + hasRemove: has.remove, + index, + onDropIndexClick: this.onDropIndexClick, + onReorderClick: this.onReorderClick, + readonly + }; } } diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js index 266e183ad6..14aab87add 100644 --- a/src/components/fields/SchemaField.js +++ b/src/components/fields/SchemaField.js @@ -233,6 +233,7 @@ if (process.env.NODE_ENV !== "production") { ])).isRequired, fields: PropTypes.objectOf(PropTypes.func).isRequired, definitions: PropTypes.object.isRequired, + ArrayFieldTemplate: PropTypes.func, FieldTemplate: PropTypes.func, formContext: PropTypes.object.isRequired, }) diff --git a/test/ArrayFieldTemplate_test.js b/test/ArrayFieldTemplate_test.js new file mode 100644 index 0000000000..2fa744ffdf --- /dev/null +++ b/test/ArrayFieldTemplate_test.js @@ -0,0 +1,142 @@ +import React from "react"; + +import {expect} from "chai"; +import {createFormComponent, createSandbox} from "./test_utils"; + +describe("ArrayFieldTemplate", () => { + let sandbox; + + beforeEach(() => { + sandbox = createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("Custom ArrayFieldTemplate of string array", () => { + + function ArrayFieldTemplate(props) { + return ( +
+ {props.canAdd && } + {props.items.map(element => { + return ( +
+ {element.hasMoveUp && + } + {element.hasMoveDown && + } + + {element.children} +
+ ); + })} +
+ ); + } + + const formData = ["one", "two", "three"]; + + describe("not fixed items", () => { + const schema = { + type: "array", + title: "my list", + description: "my description", + items: {type: "string"} + }; + + let node; + beforeEach(() => { + node = createFormComponent({ + ArrayFieldTemplate, + formData, + schema + }).node; + }); + + it("should render one root element for the array", () => { + expect(node.querySelectorAll(".custom-array")) + .to.have.length.of(1); + }); + + it("should render one add button", () => { + expect(node.querySelectorAll(".custom-array-add")) + .to.have.length.of(1); + }); + + it("should render one child for each array item", () => { + expect(node.querySelectorAll(".custom-array-item")) + .to.have.length.of(formData.length); + }); + + it("should render text input for each array item", () => { + expect(node.querySelectorAll(".custom-array-item .field input[type=text]")) + .to.have.length.of(formData.length); + }); + + it("should render move up button for all but one array items", () => { + expect(node.querySelectorAll(".custom-array-item-move-up")) + .to.have.length.of(formData.length - 1); + }); + + it("should render move down button for all but one array items", () => { + expect(node.querySelectorAll(".custom-array-item-move-down")) + .to.have.length.of(formData.length - 1); + }); + }); + + describe("fixed items", () => { + const schema = { + type: "array", + title: "my list", + description: "my description", + items: [ + {type: "string"}, + {type: "string"}, + {type: "string"} + ] + }; + + let node; + beforeEach(() => { + node = createFormComponent({ + ArrayFieldTemplate, + formData, + schema + }).node; + }); + + it("should render one root element for the array", () => { + expect(node.querySelectorAll(".custom-array")) + .to.have.length.of(1); + }); + + it("should not render an add button", () => { + expect(node.querySelectorAll(".custom-array-add")) + .to.have.length.of(0); + }); + + it("should render one child for each array item", () => { + expect(node.querySelectorAll(".custom-array-item")) + .to.have.length.of(formData.length); + }); + + it("should render text input for each array item", () => { + expect(node.querySelectorAll(".custom-array-item .field input[type=text]")) + .to.have.length.of(formData.length); + }); + + it("should not render any move up buttons", () => { + expect(node.querySelectorAll(".custom-array-item-move-up")) + .to.have.length.of(0); + }); + + it("should not render any move down buttons", () => { + expect(node.querySelectorAll(".custom-array-item-move-down")) + .to.have.length.of(0); + }); + }); + + }); +});