Repository: mozilla-services/react-jsonschema-form
Pull request #437 (2017-01-04 20:16:58)

ArrayFieldTemplate
### Reasons for making this change

Hello again! I've decided to create a new PR since I changed how the custom arrays were implemented based on feedback on my last PR (#395). Sorry it took a while!

With this change, you can customize how your arrays are rendered by passing an `ArrayFieldTemplate` object, very similar to the `FieldTemplate`.

I'd love some feedback! My initial thoughts are that I'd rather pass `removeItem`, `moveItemDown`, and `moveItemUp` to each item in the array (so that you don't need to pass the index to the functions). The root array could then store the existing functions that take indices.

This PR is related to #354 and will make the library much more flexible. 

Here's the [playground](https://github.com/dehli/react-jsonschema-form/blob/develop/playground/samples/customArray.js) so you can see what it looks like!

Thanks!

~Edit: I'll get that test passing. Forgot to run that!~

### Checklist

* [x] **I'm updating documentation**
  - [x] I've checked the rendering of the Markdown text I've added
  - [x] If I'm adding a new section, I've updated the Table of Content
* [x] **I'm adding or updating code**
  - [x] I've added and/or updated tests
  - [x] I've updated docs if needed
* [x] **I'm adding a new feature**
  - [x] I've updated the playground with an example use of the feature


olzraiti: Great work! Thanks for the rewrite, this looks really good. 👍 
dehli: Thanks @olzraiti! 😄
olzraiti: There is a bug in the current ArrayField: `formContext` isn't passed to multiselect, file array or a fixed array. I wrote a solution in my private fork, but I'll delay making a pull request since it would conflict with this pull request and I don't want to complicate the landing of this pull request.
dehli: Thanks for the comments! I'll get those changes in, plus those tests :)
dehli: Hey @n1k0, I just addressed the comments you made. Let me know if there's anything else you'd like changed! Thanks!
dehli: Appreciate the feedback @n1k0 👍

Patch:
diff --git a/README.md b/README.md
index c504811bd..02a865bf1 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 (
+    <div>
+      {props.items.map(element => element.children)}
+      {props.canAdd && <button onClick={props.onAddClick}></button>}
+    </div>
+  );
+}
+
+render((
+  <Form schema={schema}
+        ArrayFieldTemplate={ArrayFieldTemplate} />,
+), 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 c79255432..649ebd6b7 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 {
         <div className="col-sm-5">
           {!this.state.form ? null :
             <Form
+              ArrayFieldTemplate={ArrayFieldTemplate}
               liveValidate={liveValidate}
               schema={schema}
               uiSchema={uiSchema}
diff --git a/playground/samples/customArray.js b/playground/samples/customArray.js
new file mode 100644
index 000000000..dfb7645ee
--- /dev/null
+++ b/playground/samples/customArray.js
@@ -0,0 +1,40 @@
+import React, { Component } from "react";
+
+function ArrayFieldTemplate(props) {
+  return (
+    <div className={props.className}>
+
+      {props.items && props.items.map(element => (
+        <div key={element.index}>
+          <div>{element.children}</div>
+          {element.hasMoveDown &&
+            <button onClick={element.onReorderClick(element.index, element.index + 1)}>Down</button>}
+          {element.hasMoveUp &&
+            <button onClick={element.onReorderClick(element.index, element.index - 1)}>Up</button>}
+          <button onClick={element.onDropIndexClick(element.index)}>Delete</button>
+          <hr />
+        </div>
+      ))}
+
+      {props.canAdd &&
+        <div className="row">
+          <p className="col-xs-3 col-xs-offset-9 array-item-add text-right">
+            <button onClick={props.onAddClick} type="button">Custom +</button>
+          </p>
+        </div>}
+
+    </div>
+  );
+}
+
+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 5f6d280dc..8eba6cc82 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 32819cc75..89b83cd4f 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 6239274be..987724d77 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 (
+    <div key={props.index} className={props.className}>
+
+      <div className={props.hasToolbar ? "col-xs-9" : "col-xs-12"}>
+        {props.children}
+      </div>
+
+      {props.hasToolbar ?
+        <div className="col-xs-3 array-item-toolbox">
+          <div className="btn-group" style={{display: "flex", justifyContent: "space-around"}}>
+
+            {props.hasMoveUp || props.hasMoveDown ?
+              <IconBtn icon="arrow-up" className="array-item-move-up"
+                tabIndex="-1"
+                style={btnStyle}
+                disabled={props.disabled || props.readonly || !props.hasMoveUp}
+                onClick={props.onReorderClick(props.index, props.index - 1)}/>
+            : null}
+
+            {props.hasMoveUp || props.hasMoveDown ?
+              <IconBtn icon="arrow-down" className="array-item-move-down"
+                tabIndex="-1"
+                style={btnStyle}
+                disabled={props.disabled || props.readonly || !props.hasMoveDown}
+                onClick={props.onReorderClick(props.index, props.index + 1)}/>
+            : null}
+
+            {props.hasRemove ?
+              <IconBtn type="danger" icon="remove" className="array-item-remove"
+                tabIndex="-1"
+                style={btnStyle}
+                disabled={props.disabled || props.readonly}
+                onClick={props.onDropIndexClick(props.index)}/>
+            : null}
+          </div>
+        </div>
+      : null}
+
+    </div>
+  );
+}
+
+function DefaultFixedArrayFieldTemplate(props) {
+  return (
+    <fieldset className={props.className}>
+
+      <ArrayFieldTitle
+          key={`array-field-title-${props.idSchema.$id}`}
+          TitleField={props.TitleField}
+          idSchema={props.idSchema}
+          title={props.title}
+          required={props.required}/>
+
+      {props.schema.description ? (
+        <div className="field-description" key={`field-description-${props.idSchema.$id}`}>
+          {props.schema.description}
+        </div>
+      ) : null}
+
+      <div className="row array-item-list"
+        key={`array-item-list-${props.idSchema.$id}`}>
+        {props.items && props.items.map(DefaultArrayItem)}
+      </div>
+
+      {props.canAdd ? <AddButton
+                        onClick={props.onAddClick}
+                        disabled={props.disabled || props.readonly}/> : null}
+    </fieldset>
+  );
+}
+
+function DefaultNormalArrayFieldTemplate(props) {
+  return (
+    <fieldset className={props.className}>
+
+      <ArrayFieldTitle
+        key={`array-field-title-${props.idSchema.$id}`}
+        TitleField={props.TitleField}
+        idSchema={props.idSchema}
+        title={props.title}
+        required={props.required}/>
+
+      {props.schema.description ? (
+        <ArrayFieldDescription
+          key={`array-field-description-${props.idSchema.$id}`}
+          DescriptionField={props.DescriptionField}
+          idSchema={props.idSchema}
+          description={props.schema.description}/>
+      ) : null}
+
+      <div className="row array-item-list"
+          key={`array-item-list-${props.idSchema.$id}`}>
+          {props.items && props.items.map(p => DefaultArrayItem(p))}
+      </div>
+
+      {props.canAdd ? <AddButton
+                        onClick={props.onAddClick}
+                        disabled={props.disabled || props.readonly}/> : null}
+    </fieldset>
+  );
+}
+
 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 (
-      <fieldset
-        className={`field field-array field-array-of-${itemsSchema.type}`}>
-        <ArrayFieldTitle
-          TitleField={TitleField}
-          idSchema={idSchema}
-          title={title}
-          required={required}/>
-        {schema.description ?
-          <ArrayFieldDescription
-            DescriptionField={DescriptionField}
-            idSchema={idSchema}
-            description={schema.description}/> : null}
-        <div className="row array-item-list">{
-          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
-            });
-          })
-        }</div>
-        {addable ? <AddButton
-                     onClick={this.onAddClick}
-                     disabled={disabled || readonly}/> : null}
-      </fieldset>
-    );
+    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 (
-      <fieldset className="field field-array field-array-fixed-items">
-        <ArrayFieldTitle
-          TitleField={TitleField}
-          idSchema={idSchema}
-          title={title}
-          required={required}/>
-        {schema.description ?
-          <div className="field-description">{schema.description}</div> : null}
-        <div className="row array-item-list">{
-          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
-            });
-          })
-        }</div>
-        {
-          canAdd ? <AddButton
-                               onClick={this.onAddClick}
-                               disabled={disabled || readonly}/> : null
-        }
-      </fieldset>
-    );
+    // 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 (
-      <div key={index} className="array-item">
-        <div className={has.toolbar ? "col-xs-9" : "col-xs-12"}>
-          <SchemaField
-            schema={itemSchema}
-            uiSchema={itemUiSchema}
-            formData={itemData}
-            errorSchema={itemErrorSchema}
-            idSchema={itemIdSchema}
-            required={this.isItemRequired(itemSchema)}
-            onChange={this.onChangeForIndex(index)}
-            registry={this.props.registry}
-            disabled={this.props.disabled}
-            readonly={this.props.readonly}
-            autofocus={autofocus}/>
-        </div>
-        {
-          has.toolbar ?
-            <div className="col-xs-3 array-item-toolbox">
-              <div className="btn-group" style={{display: "flex", justifyContent: "space-around"}}>
-                {has.moveUp || has.moveDown ?
-                  <IconBtn icon="arrow-up" className="array-item-move-up"
-                          tabIndex="-1"
-                          style={btnStyle}
-                          disabled={disabled || readonly || !has.moveUp}
-                          onClick={this.onReorderClick(index, index - 1)}/>
-                  : null}
-                {has.moveUp || has.moveDown ?
-                  <IconBtn icon="arrow-down" className="array-item-move-down"
-                          tabIndex="-1"
-                          style={btnStyle}
-                          disabled={disabled || readonly || !has.moveDown}
-                          onClick={this.onReorderClick(index, index + 1)}/>
-                  : null}
-                {has.remove ?
-                  <IconBtn type="danger" icon="remove" className="array-item-remove"
-                          tabIndex="-1"
-                          style={btnStyle}
-                          disabled={disabled || readonly}
-                          onClick={this.onDropIndexClick(index)}/>
-                  : null}
-              </div>
-            </div>
-          : null
-        }
-      </div>
-    );
+    return {
+      children: (
+        <SchemaField
+          schema={itemSchema}
+          uiSchema={itemUiSchema}
+          formData={itemData}
+          errorSchema={itemErrorSchema}
+          idSchema={itemIdSchema}
+          required={this.isItemRequired(itemSchema)}
+          onChange={this.onChangeForIndex(index)}
+          registry={this.props.registry}
+          disabled={this.props.disabled}
+          readonly={this.props.readonly}
+          autofocus={autofocus}/>
+      ),
+      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 266e183ad..14aab87ad 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,
     })


Test patch:
diff --git a/test/ArrayFieldTemplate_test.js b/test/ArrayFieldTemplate_test.js
new file mode 100644
index 000000000..2fa744ffd
--- /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 (
+        <div className="custom-array">
+          {props.canAdd && <button className="custom-array-add"></button>}
+          {props.items.map(element => {
+            return (
+              <div className="custom-array-item" key={element.index}>
+                {element.hasMoveUp &&
+                  <button className="custom-array-item-move-up"></button>}
+                {element.hasMoveDown &&
+                  <button className="custom-array-item-move-down"></button>}
+
+                {element.children}
+              </div>
+            );
+          })}
+        </div>
+      );
+    }
+
+    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);
+      });
+    });
+
+  });
+});