Repository: mozilla-services/react-jsonschema-form
Pull request #660 (2017-08-09 10:52:31)

Remove status from Form state (BUG FIX)
### Reasons for making this change

This fixes a bug that causes the error list to flicker when formData is edited. 

**To reproduce:** go to the "Errors" tab on the playground, select an input on the Form and start smashing the keyboard (you don't really even have to smash the keyboard, you can see the effect on any single form data change). The error list will flicker.

First I thought the bug was caused by some recent commits regarding the Error List, but actually the flickering is caused by the `status` state property, which will flip between `initial` and `editing` during typing.

The `status` property has been there from the very first commit, but I can't find any real use for it other than causing this bug. I don't understand why we wouldn't show the error list when the Form is edited. Even if there was some kind of buffering that would hide the error list only when the formData is updated in intervals less time than some milliseconds, it would affect the page scrolling position before and after the user stops typing. The only side effect that removing `status` causes that I can think of is that when  `onSubmit` calls `this.props.onSubmit(this.state)`, the `state` won't contain `status: "submitted"` - which seems kind of pointless to me :)

### Checklist

* [ ] **I'm updating documentation**
  - [ ] I've checked the rendering of the Markdown text I've added
  - [ ] If I'm adding a new section, I've updated the Table of Content
* [x] **I'm adding or updating code**
  - [ ] I've added and/or updated tests
  - [ ] I've updated docs if needed
  - [ ] I've run `npm run cs-format` on my branch to conform my code to [prettier](https://github.com/prettier/prettier) coding style
* [ ] **I'm adding a new feature**
  - [ ] I've updated the playground with an example use of the feature



Patch:
diff --git a/README.md b/README.md
index 7f1558ddf..f472af4ec 100644
--- a/README.md
+++ b/README.md
@@ -213,6 +213,10 @@ If you plan on being notified everytime the form data are updated, you can pass
 
 Sometimes you may want to trigger events or modify external state when a field has been touched, so you can pass an `onBlur` handler, which will receive the id of the input that was blurred and the field value.
 
+#### Form field focus events
+
+Sometimes you may want to trigger events or modify external state when a field has been focused, so you can pass an `onFocus` handler, which will receive the id of the input that is focused and the field value.
+
 ## Form customization
 
 ### The `uiSchema` object
@@ -954,6 +958,7 @@ The following props are passed to custom widget components:
 - `readonly`: `true` if the widget is read-only;
 - `onChange`: The value change event handler; call it with the new value everytime it changes;
 - `onBlur`: The input blur event handler; call it with the the widget id and value;
+- `onFocus`: The input focus event handler; call it with the the widget id and value;
 - `options`: A map of options passed as a prop to the component (see [Custom widget options](#custom-widget-options)).
 - `formContext`: The `formContext` object that you passed to Form.
 
diff --git a/playground/app.js b/playground/app.js
index 4757b35fb..ba83885ef 100644
--- a/playground/app.js
+++ b/playground/app.js
@@ -477,6 +477,8 @@ class App extends Component {
               validate={validate}
               onBlur={(id, value) =>
                 console.log(`Touched ${id} with value ${value}`)}
+              onFocus={(id, value) =>
+                console.log(`Focused ${id} with value ${value}`)}
               transformErrors={transformErrors}
               onError={log("errors")}>
               <div className="row">
diff --git a/playground/samples/arrays.js b/playground/samples/arrays.js
index c4142a3eb..e11313189 100644
--- a/playground/samples/arrays.js
+++ b/playground/samples/arrays.js
@@ -57,6 +57,16 @@ module.exports = {
           $ref: "#/definitions/Thing",
         },
       },
+      defaultsAndMinItems: {
+        type: "array",
+        title: "List and item level defaults",
+        minItems: 5,
+        default: ["carp", "trout", "bream"],
+        items: {
+          type: "string",
+          default: "unidentified",
+        },
+      },
       nestedList: {
         type: "array",
         title: "Nested list",
diff --git a/src/components/Form.js b/src/components/Form.js
index 1d0e9a39b..97a83a57e 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -51,7 +51,6 @@ export default class Form extends Component {
       definitions
     );
     return {
-      status: "initial",
       schema,
       uiSchema,
       idSchema,
@@ -77,10 +76,10 @@ export default class Form extends Component {
   }
 
   renderErrors() {
-    const { status, errors, errorSchema, schema, uiSchema } = this.state;
+    const { errors, errorSchema, schema, uiSchema } = this.state;
     const { ErrorList, showErrorList, formContext } = this.props;
 
-    if (status !== "editing" && errors.length && showErrorList != false) {
+    if (errors.length && showErrorList != false) {
       return (
         <ErrorList
           errors={errors}
@@ -97,7 +96,7 @@ export default class Form extends Component {
   onChange = (formData, options = { validate: false }) => {
     const mustValidate =
       !this.props.noValidate && (this.props.liveValidate || options.validate);
-    let state = { status: "editing", formData };
+    let state = { formData };
     if (mustValidate) {
       const { errors, errorSchema } = this.validate(formData);
       state = { ...state, errors, errorSchema };
@@ -115,9 +114,14 @@ export default class Form extends Component {
     }
   };
 
+  onFocus = (...args) => {
+    if (this.props.onFocus) {
+      this.props.onFocus(...args);
+    }
+  };
+
   onSubmit = event => {
     event.preventDefault();
-    this.setState({ status: "submitted" });
 
     if (!this.props.noValidate) {
       const { errors, errorSchema } = this.validate(this.state.formData);
@@ -134,9 +138,9 @@ export default class Form extends Component {
     }
 
     if (this.props.onSubmit) {
-      this.props.onSubmit(this.state);
+      this.props.onSubmit({ ...this.state, status: "submitted" });
     }
-    this.setState({ status: "initial", errors: [], errorSchema: {} });
+    this.setState({ errors: [], errorSchema: {} });
   };
 
   getRegistry() {
@@ -195,6 +199,7 @@ export default class Form extends Component {
           formData={formData}
           onChange={this.onChange}
           onBlur={this.onBlur}
+          onFocus={this.onFocus}
           registry={registry}
           safeRenderCompletion={safeRenderCompletion}
         />
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index bbc9d6d90..b6bb28473 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -304,6 +304,7 @@ class ArrayField extends Component {
       registry = getDefaultRegistry(),
       formContext,
       onBlur,
+      onFocus,
     } = this.props;
     const title = schema.title === undefined ? name : schema.title;
     const { ArrayFieldTemplate, definitions, fields } = registry;
@@ -326,6 +327,7 @@ class ArrayField extends Component {
           itemUiSchema: uiSchema.items,
           autofocus: autofocus && index === 0,
           onBlur,
+          onFocus,
         });
       }),
       className: `field field-array field-array-of-${itemsSchema.type}`,
@@ -357,6 +359,7 @@ class ArrayField extends Component {
       readonly,
       autofocus,
       onBlur,
+      onFocus,
       registry = getDefaultRegistry(),
     } = this.props;
     const items = this.props.formData;
@@ -374,6 +377,7 @@ class ArrayField extends Component {
         multiple
         onChange={this.onSelectChange}
         onBlur={onBlur}
+        onFocus={onFocus}
         options={options}
         schema={schema}
         value={items}
@@ -395,6 +399,7 @@ class ArrayField extends Component {
       readonly,
       autofocus,
       onBlur,
+      onFocus,
       registry = getDefaultRegistry(),
     } = this.props;
     const title = schema.title || name;
@@ -409,6 +414,7 @@ class ArrayField extends Component {
         multiple
         onChange={this.onSelectChange}
         onBlur={onBlur}
+        onFocus={onFocus}
         schema={schema}
         title={title}
         value={items}
@@ -433,6 +439,7 @@ class ArrayField extends Component {
       autofocus,
       registry = getDefaultRegistry(),
       onBlur,
+      onFocus,
     } = this.props;
     const title = schema.title || name;
     let items = this.props.formData;
@@ -481,6 +488,7 @@ class ArrayField extends Component {
           itemErrorSchema,
           autofocus: autofocus && index === 0,
           onBlur,
+          onFocus,
         });
       }),
       onAddClick: this.onAddClick,
@@ -510,6 +518,7 @@ class ArrayField extends Component {
       itemErrorSchema,
       autofocus,
       onBlur,
+      onFocus,
     } = props;
     const {
       disabled,
@@ -541,6 +550,7 @@ class ArrayField extends Component {
           required={this.isItemRequired(itemSchema)}
           onChange={this.onChangeForIndex(index)}
           onBlur={onBlur}
+          onFocus={onFocus}
           registry={this.props.registry}
           disabled={this.props.disabled}
           readonly={this.props.readonly}
@@ -592,6 +602,7 @@ if (process.env.NODE_ENV !== "production") {
     errorSchema: PropTypes.object,
     onChange: PropTypes.func.isRequired,
     onBlur: PropTypes.func,
+    onFocus: PropTypes.func,
     formData: PropTypes.array,
     required: PropTypes.bool,
     disabled: PropTypes.bool,
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index 9f1efee25..9a7d3ac8c 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -43,6 +43,7 @@ class ObjectField extends Component {
       disabled,
       readonly,
       onBlur,
+      onFocus,
       registry = getDefaultRegistry(),
     } = this.props;
     const { definitions, fields, formContext } = registry;
@@ -94,6 +95,7 @@ class ObjectField extends Component {
               formData={formData[name]}
               onChange={this.onPropertyChange(name)}
               onBlur={onBlur}
+              onFocus={onFocus}
               registry={registry}
               disabled={disabled}
               readonly={readonly}
diff --git a/src/components/fields/StringField.js b/src/components/fields/StringField.js
index 9e9ed6160..9ecee6eb9 100644
--- a/src/components/fields/StringField.js
+++ b/src/components/fields/StringField.js
@@ -22,6 +22,7 @@ function StringField(props) {
     autofocus,
     onChange,
     onBlur,
+    onFocus,
     registry = getDefaultRegistry(),
   } = props;
   const { title, format } = schema;
@@ -42,6 +43,7 @@ function StringField(props) {
       value={formData}
       onChange={onChange}
       onBlur={onBlur}
+      onFocus={onFocus}
       required={required}
       disabled={disabled}
       readonly={readonly}
@@ -60,6 +62,7 @@ if (process.env.NODE_ENV !== "production") {
     idSchema: PropTypes.object,
     onChange: PropTypes.func.isRequired,
     onBlur: PropTypes.func,
+    onFocus: PropTypes.func,
     formData: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
     registry: PropTypes.shape({
       widgets: PropTypes.objectOf(
diff --git a/src/components/widgets/BaseInput.js b/src/components/widgets/BaseInput.js
index 58f06d30f..108b250d1 100644
--- a/src/components/widgets/BaseInput.js
+++ b/src/components/widgets/BaseInput.js
@@ -10,6 +10,7 @@ function BaseInput(props) {
     disabled,
     autofocus,
     onBlur,
+    onFocus,
     options,
     schema,
     formContext,
@@ -31,6 +32,7 @@ function BaseInput(props) {
       {...inputProps}
       onChange={_onChange}
       onBlur={onBlur && (event => onBlur(inputProps.id, event.target.value))}
+      onFocus={onFocus && (event => onFocus(inputProps.id, event.target.value))}
     />
   );
 }
@@ -54,6 +56,7 @@ if (process.env.NODE_ENV !== "production") {
     autofocus: PropTypes.bool,
     onChange: PropTypes.func,
     onBlur: PropTypes.func,
+    onFocus: PropTypes.func,
   };
 }
 
diff --git a/src/components/widgets/SelectWidget.js b/src/components/widgets/SelectWidget.js
index df3328b38..867641355 100644
--- a/src/components/widgets/SelectWidget.js
+++ b/src/components/widgets/SelectWidget.js
@@ -48,6 +48,7 @@ function SelectWidget(props) {
     autofocus,
     onChange,
     onBlur,
+    onFocus,
     placeholder,
   } = props;
   const { enumOptions } = options;
@@ -68,6 +69,13 @@ function SelectWidget(props) {
           onBlur(id, processValue(schema, newValue));
         })
       }
+      onFocus={
+        onFocus &&
+        (event => {
+          const newValue = getValue(event, multiple);
+          onFocus(id, processValue(schema, newValue));
+        })
+      }
       onChange={event => {
         const newValue = getValue(event, multiple);
         onChange(processValue(schema, newValue));
@@ -107,6 +115,7 @@ if (process.env.NODE_ENV !== "production") {
     autofocus: PropTypes.bool,
     onChange: PropTypes.func,
     onBlur: PropTypes.func,
+    onFocus: PropTypes.func,
   };
 }
 
diff --git a/src/components/widgets/TextareaWidget.js b/src/components/widgets/TextareaWidget.js
index c2250d20d..85979a418 100644
--- a/src/components/widgets/TextareaWidget.js
+++ b/src/components/widgets/TextareaWidget.js
@@ -13,6 +13,7 @@ function TextareaWidget(props) {
     autofocus,
     onChange,
     onBlur,
+    onFocus,
   } = props;
   const _onChange = ({ target: { value } }) => {
     return onChange(value === "" ? options.emptyValue : value);
@@ -29,6 +30,7 @@ function TextareaWidget(props) {
       autoFocus={autofocus}
       rows={options.rows}
       onBlur={onBlur && (event => onBlur(id, event.target.value))}
+      onFocus={onFocus && (event => onFocus(id, event.target.value))}
       onChange={_onChange}
     />
   );
@@ -54,6 +56,7 @@ if (process.env.NODE_ENV !== "production") {
     autofocus: PropTypes.bool,
     onChange: PropTypes.func,
     onBlur: PropTypes.func,
+    onFocus: PropTypes.func,
   };
 }
 
diff --git a/src/utils.js b/src/utils.js
index a14299d57..0c24acf71 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -140,9 +140,23 @@ function computeDefaults(schema, parentDefaults, definitions = {}) {
 
     case "array":
       if (schema.minItems) {
-        return new Array(schema.minItems).fill(
-          computeDefaults(schema.items, defaults, definitions)
-        );
+        if (!isMultiSelect(schema, definitions)) {
+          const defaultsLength = defaults ? defaults.length : 0;
+          if (schema.minItems > defaultsLength) {
+            const defaultEntries = defaults || [];
+            // populate the array with the defaults
+            const fillerEntries = new Array(
+              schema.minItems - defaultsLength
+            ).fill(
+              computeDefaults(schema.items, schema.items.defaults, definitions)
+            );
+            // then fill up the rest with either the item default or empty, up to minItems
+
+            return defaultEntries.concat(fillerEntries);
+          }
+        } else {
+          return [];
+        }
       }
   }
   return defaults;


Test patch:
diff --git a/test/ArrayField_test.js b/test/ArrayField_test.js
index b37c914a6..73166cd14 100644
--- a/test/ArrayField_test.js
+++ b/test/ArrayField_test.js
@@ -410,6 +410,110 @@ describe("ArrayField", () => {
       expect(inputs[1].value).eql("Default name");
     });
 
+    it("should render an input for each default value, even when this is greater than minItems", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          turtles: {
+            type: "array",
+            minItems: 2,
+            default: ["Raphael", "Michaelangelo", "Donatello", "Leonardo"],
+            items: {
+              type: "string",
+            },
+          },
+        },
+      };
+      const { node } = createFormComponent({ schema: schema });
+      const inputs = node.querySelectorAll("input[type=text]");
+      expect(inputs.length).to.eql(4);
+      expect(inputs[0].value).to.eql("Raphael");
+      expect(inputs[1].value).to.eql("Michaelangelo");
+      expect(inputs[2].value).to.eql("Donatello");
+      expect(inputs[3].value).to.eql("Leonardo");
+    });
+
+    it("should render enough input to match minItems, populating the first with default values, and the rest empty", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          turtles: {
+            type: "array",
+            minItems: 4,
+            default: ["Raphael", "Michaelangelo"],
+            items: {
+              type: "string",
+            },
+          },
+        },
+      };
+      const { node } = createFormComponent({ schema });
+      const inputs = node.querySelectorAll("input[type=text]");
+      expect(inputs.length).to.eql(4);
+      expect(inputs[0].value).to.eql("Raphael");
+      expect(inputs[1].value).to.eql("Michaelangelo");
+      expect(inputs[2].value).to.eql("");
+      expect(inputs[3].value).to.eql("");
+    });
+
+    it("should render enough input to match minItems, populating the first with default values, and the rest with the item default", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          turtles: {
+            type: "array",
+            minItems: 4,
+            default: ["Raphael", "Michaelangelo"],
+            items: {
+              type: "string",
+              default: "Unknown",
+            },
+          },
+        },
+      };
+      const { node } = createFormComponent({ schema });
+      const inputs = node.querySelectorAll("input[type=text]");
+      expect(inputs.length).to.eql(4);
+      expect(inputs[0].value).to.eql("Raphael");
+      expect(inputs[1].value).to.eql("Michaelangelo");
+      expect(inputs[2].value).to.eql("Unknown");
+      expect(inputs[3].value).to.eql("Unknown");
+    });
+
+    it("should not add minItems extra formData entries when schema item is a multiselect", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          multipleChoicesList: {
+            type: "array",
+            minItems: 3,
+            uniqueItems: true,
+            items: {
+              type: "string",
+              enum: ["Aramis", "Athos", "Porthos", "d'Artagnan"],
+            },
+          },
+        },
+      };
+      const uiSchema = {
+        multipleChoicesList: {
+          "ui:widget": "checkboxes",
+        },
+      };
+      const form = createFormComponent({
+        schema: schema,
+        uiSchema: uiSchema,
+        formData: {},
+        liveValidate: true,
+      }).comp;
+
+      expect(form.state.formData).to.have.property("multipleChoicesList");
+      expect(form.state.formData.multipleChoicesList).to.be.empty;
+      expect(form.state.errors.length).to.equal(1);
+      expect(form.state.errors[0].name).to.equal("minItems");
+      expect(form.state.errors[0].argument).to.equal(3);
+    });
+
     it("should honor given formData, even when it does not meet ths minItems-requirement", () => {
       const complexSchema = {
         type: "object",
@@ -514,6 +618,24 @@ describe("ArrayField", () => {
         expect(onBlur.calledWith(select.id, ["foo", "bar"])).to.be.true;
       });
 
+      it("should handle a focus event", () => {
+        const onFocus = sandbox.spy();
+        const { node } = createFormComponent({ schema, onFocus });
+
+        const select = node.querySelector(".field select");
+        Simulate.focus(select, {
+          target: {
+            options: [
+              { selected: true, value: "foo" },
+              { selected: true, value: "bar" },
+              { selected: false, value: "fuzz" },
+            ],
+          },
+        });
+
+        expect(onFocus.calledWith(select.id, ["foo", "bar"])).to.be.true;
+      });
+
       it("should fill field with data", () => {
         const { node } = createFormComponent({
           schema,
diff --git a/test/Form_test.js b/test/Form_test.js
index e98937246..9c8aeec61 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -523,7 +523,7 @@ describe("Form", () => {
 
       Simulate.submit(node);
 
-      sinon.assert.calledWithExactly(onSubmit, comp.state);
+      sinon.assert.calledWithMatch(onSubmit, comp.state);
     });
 
     it("should not call provided submit handler on validation errors", () => {
@@ -606,6 +606,31 @@ describe("Form", () => {
     });
   });
 
+  describe("Focus handler", () => {
+    it("should call provided focus handler on form input focus event", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          foo: {
+            type: "string",
+          },
+        },
+      };
+      const formData = {
+        foo: "",
+      };
+      const onFocus = sandbox.spy();
+      const { node } = createFormComponent({ schema, formData, onFocus });
+
+      const input = node.querySelector("[type=text]");
+      Simulate.focus(input, {
+        target: { value: "new" },
+      });
+
+      sinon.assert.calledWithMatch(onFocus, input.id, "new");
+    });
+  });
+
   describe("Error handler", () => {
     it("should call provided error handler on validation errors", () => {
       const schema = {
diff --git a/test/NumberField_test.js b/test/NumberField_test.js
index 0695b85cd..b5eee0fe3 100644
--- a/test/NumberField_test.js
+++ b/test/NumberField_test.js
@@ -97,6 +97,24 @@ describe("NumberField", () => {
 
       expect(onBlur.calledWith(input.id, 2));
     });
+
+    it("should handle a focus event", () => {
+      const onFocus = sandbox.spy();
+      const { node } = createFormComponent({
+        schema: {
+          type: "number",
+        },
+        onFocus,
+      });
+
+      const input = node.querySelector("input");
+      Simulate.focus(input, {
+        target: { value: "2" },
+      });
+
+      expect(onFocus.calledWith(input.id, 2));
+    });
+
     it("should fill field with data", () => {
       const { node } = createFormComponent({
         schema: {
diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js
index 4dc3cae02..bce38d914 100644
--- a/test/ObjectField_test.js
+++ b/test/ObjectField_test.js
@@ -161,6 +161,18 @@ describe("ObjectField", () => {
       expect(onBlur.calledWith(input.id, "changed")).to.be.true;
     });
 
+    it("should handle object fields with focus events", () => {
+      const onFocus = sandbox.spy();
+      const { node } = createFormComponent({ schema, onFocus });
+
+      const input = node.querySelector("input[type=text]");
+      Simulate.focus(input, {
+        target: { value: "changed" },
+      });
+
+      expect(onFocus.calledWith(input.id, "changed")).to.be.true;
+    });
+
     it("should render the widget with the expected id", () => {
       const { node } = createFormComponent({ schema });
 
diff --git a/test/StringField_test.js b/test/StringField_test.js
index 4c8281062..1446f89e8 100644
--- a/test/StringField_test.js
+++ b/test/StringField_test.js
@@ -100,6 +100,22 @@ describe("StringField", () => {
       expect(onBlur.calledWith(input.id, "yo")).to.be.true;
     });
 
+    it("should handle a focus event", () => {
+      const onFocus = sandbox.spy();
+      const { node } = createFormComponent({
+        schema: {
+          type: "string",
+        },
+        onFocus,
+      });
+      const input = node.querySelector("input");
+      Simulate.focus(input, {
+        target: { value: "yo" },
+      });
+
+      expect(onFocus.calledWith(input.id, "yo")).to.be.true;
+    });
+
     it("should handle an empty string change event", () => {
       const { comp, node } = createFormComponent({
         schema: { type: "string" },
diff --git a/test/performance_test.js b/test/performance_test.js
index 1e04667e5..64e36b5dd 100644
--- a/test/performance_test.js
+++ b/test/performance_test.js
@@ -105,6 +105,7 @@ describe("Rendering performance optimizations", () => {
   describe("SchemaField", () => {
     const onChange = () => {};
     const onBlur = () => {};
+    const onFocus = () => {};
     const registry = getDefaultRegistry();
     const uiSchema = {};
     const schema = {
@@ -123,6 +124,7 @@ describe("Rendering performance optimizations", () => {
         onChange,
         idSchema,
         onBlur,
+        onFocus,
       };
 
       const { comp } = createComponent(SchemaField, props);