Repository: mozilla-services/react-jsonschema-form
Pull request #373 (2016-11-03 15:44:10)

Removable and addable options for array items
### Reasons for making this change

There are cases where you don't want an add button or a remove button for array items, even though the schema allows it. Just like with the recent `orderable` option. The array items in my current case come from another resource and I fill the array in the background on initial form load; the user should not be able to reorder, add or remove the items via the form. I think this is a common use case.

I refactored the code a little bit to align all variable names (`orderable`, `addable` and `removable` for `uiSchema` settings, and `canMoveUp`, `canMoveDown`, `canDelete` for application logic).

![options](https://cloud.githubusercontent.com/assets/298893/19972421/4d022146-a1e2-11e6-91f9-8c63cf6b6b4c.png)

### 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


maartenth: Updated those 3 things and rebased. :)
n1k0: Woops, I've landed #375 meawhile which makes your patch conflicting again. I can take over the next rebase if you're bored ;)
maartenth: Re-rebased. :)
n1k0: Perfect, thank you so much!

Patch:
diff --git a/README.md b/README.md
index 1c33cb204..e39f24e0a 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,10 @@ A [live playground](https://mozilla-services.github.io/react-jsonschema-form/) i
            - [Multiple files](#multiple-files)
            - [File widget input ref](#file-widget-input-ref)
      - [Object fields ordering](#object-fields-ordering)
-     - [Array items ordering](#array-items-ordering)
+     - [Array item options](#array-item-options)
+        - [Orderable option](#orderable-option)
+        - [Addable option](#addable-option)
+        - [Removable option](#removable-option)
      - [Custom CSS class names](#custom-css-class-names)
      - [Custom labels for enum fields](#custom-labels-for-enum-fields)
      - [Multiple choices list](#multiple-choices-list)
@@ -419,7 +422,9 @@ const uiSchema = {
 };
 ```
 
-### Array items ordering
+### Array item options
+
+#### `orderable` option
 
 Array items are orderable by default, and react-jsonschema-form renders move up/down buttons alongside them. The `uiSchema` object spec allows you to disable ordering:
 
@@ -438,6 +443,30 @@ const uiSchema = {
 };
 ```
 
+#### `addable` option
+
+If either `items` or `additionalItems` contains a schema object, an add button for new items is shown by default. You can turn this off with the `addable` option in `uiSchema`:
+
+```jsx
+const uiSchema = {
+  "ui:options":  {
+    addable: false
+  }
+};
+```
+
+#### `removable` option
+
+A remove button is shown by default for an item if `items` contains a schema object, or the item is an `additionalItems` instance. You can turn this off with the `removable` option in `uiSchema`:
+
+```jsx
+const uiSchema = {
+  "ui:options":  {
+    removable: false
+  }
+};
+```
+
 ### Custom CSS class names
 
 The uiSchema object accepts a `classNames` property for each field of the schema:
diff --git a/playground/samples/arrays.js b/playground/samples/arrays.js
index 30cf64f57..7e4baf79a 100644
--- a/playground/samples/arrays.js
+++ b/playground/samples/arrays.js
@@ -49,6 +49,51 @@ module.exports = {
             default: "lorem ipsum"
           }
         }
+      },
+      unorderable: {
+        title: "Unorderable items",
+        type: "array",
+        items: {
+          type: "string",
+          default: "lorem ipsum"
+        }
+      },
+      unremovable: {
+        title: "Unremovable items",
+        type: "array",
+        items: {
+          type: "string",
+          default: "lorem ipsum"
+        }
+      },
+      noToolbar: {
+        title: "No add, remove and order buttons",
+        type: "array",
+        items: {
+          type: "string",
+          default: "lorem ipsum"
+        }
+      },
+      fixedNoToolbar: {
+        title: "Fixed array without buttons",
+        type: "array",
+        items: [
+          {
+            title: "A number",
+            type: "number",
+            default: 42
+          },
+          {
+            title: "A boolean",
+            type: "boolean",
+            default: false
+          }
+        ],
+        additionalItems: {
+          title: "A string",
+          type: "string",
+          default: "lorem ipsum"
+        }
       }
     }
   },
@@ -64,12 +109,40 @@ module.exports = {
       additionalItems: {
         "ui:widget": "updown"
       }
+    },
+    unorderable: {
+      "ui:options": {
+        orderable: false
+      }
+    },
+    unremovable: {
+      "ui:options": {
+        removable: false
+      }
+    },
+    noToolbar: {
+      "ui:options": {
+        addable: false,
+        orderable: false,
+        removable: false
+      }
+    },
+    fixedNoToolbar: {
+      "ui:options": {
+        addable: false,
+        orderable: false,
+        removable: false
+      }
     }
   },
   formData: {
     listOfStrings: ["foo", "bar"],
     multipleChoicesList: ["foo", "bar"],
     fixedItemsList: ["Some text", true, 123],
-    nestedList: [["lorem", "ipsum"], ["dolor"]]
+    nestedList: [["lorem", "ipsum"], ["dolor"]],
+    unorderable: ["one", "two"],
+    unremovable: ["one", "two"],
+    noToolbar: ["one", "two"],
+    fixedNoToolbar: [42, true, "additional item one", "additional item two"]
   }
 };
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index a779ad8f4..f06558d45 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -180,6 +180,10 @@ class ArrayField extends Component {
     const {definitions, fields} = this.props.registry;
     const {TitleField, DescriptionField} = fields;
     const itemsSchema = retrieveSchema(schema.items, definitions);
+    const {addable} = {
+      addable: true,
+      ...uiSchema["ui:options"]
+    };
 
     return (
       <fieldset
@@ -212,8 +216,9 @@ class ArrayField extends Component {
             });
           })
         }</div>
-        <AddButton
-          onClick={this.onAddClick} disabled={disabled || readonly}/>
+        {addable ? <AddButton
+                     onClick={this.onAddClick}
+                     disabled={disabled || readonly}/> : null}
       </fieldset>
     );
   }
@@ -280,6 +285,11 @@ class ArrayField extends Component {
       retrieveSchema(item, definitions));
     const additionalSchema = allowAdditionalItems(schema) ?
       retrieveSchema(schema.additionalItems, definitions) : null;
+    const {addable} = {
+      addable: true,
+      ...uiSchema["ui:options"]
+    };
+    const canAdd = addable && additionalSchema;
 
     if (!items || items.length < itemSchemas.length) {
       // to make sure at least all fixed items are generated
@@ -311,7 +321,7 @@ class ArrayField extends Component {
 
             return this.renderArrayFieldItem({
               index,
-              removable: additional,
+              canRemove: additional,
               canMoveUp: index >= itemSchemas.length + 1,
               canMoveDown: additional && index < items.length - 1,
               itemSchema,
@@ -324,7 +334,7 @@ class ArrayField extends Component {
           })
         }</div>
         {
-          additionalSchema ? <AddButton
+          canAdd ? <AddButton
                                onClick={this.onAddClick}
                                disabled={disabled || readonly}/> : null
         }
@@ -334,7 +344,7 @@ class ArrayField extends Component {
 
   renderArrayFieldItem({
     index,
-    removable=true,
+    canRemove=true,
     canMoveUp=true,
     canMoveDown=true,
     itemSchema,
@@ -346,19 +356,22 @@ class ArrayField extends Component {
   }) {
     const {SchemaField} = this.props.registry.fields;
     const {disabled, readonly, uiSchema} = this.props;
-
-    const {orderable} = {orderable: true, ...uiSchema["ui:options"]};
-
-    const _canMoveUp = orderable && canMoveUp;
-    const _canMoveDown = orderable && canMoveDown;
-
-    const hasToolbar = removable || _canMoveUp || _canMoveDown;
-
+    const {orderable, removable} = {
+      orderable: true,
+      removable: true,
+      ...uiSchema["ui:options"]
+    };
+    const has = {
+      moveUp: orderable && canMoveUp,
+      moveDown: orderable && canMoveDown,
+      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={hasToolbar ? "col-xs-9" : "col-xs-12"}>
+        <div className={has.toolbar ? "col-xs-9" : "col-xs-12"}>
           <SchemaField
             schema={itemSchema}
             uiSchema={itemUiSchema}
@@ -373,24 +386,24 @@ class ArrayField extends Component {
             autofocus={autofocus}/>
         </div>
         {
-          hasToolbar ?
+          has.toolbar ?
             <div className="col-xs-3 array-item-toolbox">
               <div className="btn-group" style={{display: "flex", justifyContent: "space-around"}}>
-                {_canMoveUp || _canMoveDown ?
+                {has.moveUp || has.moveDown ?
                   <IconBtn icon="arrow-up" className="array-item-move-up"
                           tabIndex="-1"
                           style={btnStyle}
-                          disabled={disabled || readonly || !_canMoveUp}
+                          disabled={disabled || readonly || !has.moveUp}
                           onClick={this.onReorderClick(index, index - 1)}/>
                   : null}
-                {_canMoveUp || _canMoveDown ?
+                {has.moveUp || has.moveDown ?
                   <IconBtn icon="arrow-down" className="array-item-move-down"
                           tabIndex="-1"
                           style={btnStyle}
-                          disabled={disabled || readonly || !_canMoveDown}
+                          disabled={disabled || readonly || !has.moveDown}
                           onClick={this.onReorderClick(index, index + 1)}/>
                   : null}
-                {removable ?
+                {has.remove ?
                   <IconBtn type="danger" icon="remove" className="array-item-remove"
                           tabIndex="-1"
                           style={btnStyle}


Test patch:
diff --git a/test/ArrayField_test.js b/test/ArrayField_test.js
index 808e655df..112678e80 100644
--- a/test/ArrayField_test.js
+++ b/test/ArrayField_test.js
@@ -84,6 +84,12 @@ describe("ArrayField", () => {
         .not.eql(null);
     });
 
+    it("should not have an add button if addable is false", () => {
+      const {node} = createFormComponent({schema, uiSchema: {"ui:options": {addable: false}}});
+
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
     it("should add a new field when clicking the add button", () => {
       const {node} = createFormComponent({schema});
 
@@ -157,7 +163,7 @@ describe("ArrayField", () => {
       expect(moveDownBtns[1].disabled).eql(true);
     });
 
-    it("should not show move up/down buttons is orderable is false", () => {
+    it("should not show move up/down buttons if orderable is false", () => {
       const {node} = createFormComponent({schema, formData: ["foo", "bar"], uiSchema: {"ui:options": {orderable: false}}});
       const moveUpBtns = node.querySelector(".array-item-move-up");
       const moveDownBtns = node.querySelector(".array-item-move-down");
@@ -177,6 +183,13 @@ describe("ArrayField", () => {
       expect(inputs[0].value).eql("bar");
     });
 
+    it("should not show remove button if removable is false", () => {
+      const {node} = createFormComponent({schema, formData: ["foo", "bar"], uiSchema: {"ui:options": {removable: false}}});
+      const dropBtn = node.querySelector(".array-item-remove");
+
+      expect(dropBtn).to.be.null;
+    });
+
     it("should force revalidation when a field is removed", () => {
       // refs #195
       const {node} = createFormComponent({
@@ -585,6 +598,21 @@ describe("ArrayField", () => {
       expect(addInput.value).eql("bar");
     });
 
+    it("should have an add button if additionalItems is an object", () => {
+      const {node} = createFormComponent({schema: schemaAdditional});
+      expect(node.querySelector(".array-item-add button")).not.to.be.null;
+    });
+
+    it("should not have an add button if additionalItems is not set", () => {
+      const {node} = createFormComponent({schema});
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
+    it("should not have an add button if addable is false", () => {
+      const {node} = createFormComponent({schema, uiSchema: {"ui:options": {addable: false}}});
+      expect(node.querySelector(".array-item-add button")).to.be.null;
+    });
+
     describe("operations for additional items", () => {
       const {comp, node} = createFormComponent({
         schema: schemaAdditional,