gl_field_errors.js.es6 4.95 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
((global) => {
  /*
   * This class overrides the browser's validation error bubbles, displaying custom
   * error messages for invalid fields instead. To begin validating any form, add the
   * class `show-gl-field-errors` to the form element, and ensure error messages are
   * declared in each inputs' title attribute.
   *
   * Example:
   *
   * <form class='show-gl-field-errors'>
   *  <input type='text' name='username' title='Username is required.'/>
   *</form>
   *
    * */

16
  const errorMessageClass = 'gl-field-error';
17 18
  const inputErrorClass = 'gl-field-error-outline';

19
  class GlFieldError {
20
    constructor({ input, formErrors }) {
21 22
      this.inputElement = $(input);
      this.inputDomElement = this.inputElement.get(0);
23
      this.form = formErrors;
24 25 26 27 28 29 30 31 32
      this.errorMessage = this.inputElement.attr('title') || 'This field is required.';
      this.fieldErrorElement = $(`<p class='${errorMessageClass} hide'>${ this.errorMessage }</p>`);

      this.state = {
        valid: false,
        empty: true
      };

      this.initFieldValidation();
33 34
    }

35 36
    initFieldValidation() {
      // hidden when injected into DOM
37
      this.inputElement.after(this.fieldErrorElement);
38
      this.inputElement.off('invalid').on('invalid', this.handleInvalidSubmit.bind(this));
39 40 41 42 43 44 45 46 47 48 49 50 51
      this.scopedSiblings = this.safelySelectSiblings();
    }

    safelySelectSiblings() {
      // Apply `ignoreSelector` in markup to siblings whose visibility should not be toggled with input validity
      const ignoreSelector = '.validation-ignore';
      const unignoredSiblings = this.inputElement.siblings(`p:not(${ignoreSelector})`);
      const parentContainer = this.inputElement.parent('.form-group');

      // Only select siblings when they're scoped within a form-group with one input
      const safelyScoped = parentContainer.length && parentContainer.find('input').length === 1;

      return safelyScoped ? unignoredSiblings : this.fieldErrorElement;
52 53
    }

54
    renderValidity() {
55
      this.renderClear();
56 57

      if (this.state.valid) {
58
        return this.renderValid();
59 60 61
      }

      if (this.state.empty) {
62
        return this.renderEmpty();
63 64 65
      }

      if (!this.state.valid) {
66
        return this.renderInvalid();
67
      }
68

69 70
    }

71
    handleInvalidSubmit(event) {
72
      event.preventDefault();
73
      const currentValue = this.accessCurrentValue();
74
      this.state.valid = false;
75
      this.state.empty = currentValue === '';
76 77

      this.renderValidity();
78
      this.form.focusOnFirstInvalid.apply(this.form);
79
      // For UX, wait til after first invalid submission to check each keyup
80
      this.inputElement.off('keyup.field_validator')
Bryce Johnson's avatar
Bryce Johnson committed
81
        .on('keyup.field_validator', this.updateValidity.bind(this));
82 83 84

    }

85 86 87 88 89
    /* Get or set current input value */
    accessCurrentValue(newVal) {
      return newVal ? this.inputElement.val(newVal) : this.inputElement.val();
    }

90 91 92
    getInputValidity() {
      return this.inputDomElement.validity.valid;
    }
93

94 95
    updateValidity() {
      const inputVal = this.accessCurrentValue();
96
      this.state.empty = !inputVal.length;
97
      this.state.valid = this.getInputValidity();
98
      this.renderValidity();
99 100
    }

101 102
    renderValid() {
      return this.renderClear();
103 104
    }

105 106
    renderEmpty() {
      return this.renderInvalid();
107 108
    }

109
    renderInvalid() {
110
      this.inputElement.addClass(inputErrorClass);
111
      this.scopedSiblings.hide();
112
      return this.fieldErrorElement.show();
113 114
    }

115 116
    renderClear() {
      const inputVal = this.accessCurrentValue();
117
      if (!inputVal.split(' ').length) {
118
        const trimmedInput = inputVal.trim();
119
        this.accessCurrentValue(trimmedInput);
120 121
      }
      this.inputElement.removeClass(inputErrorClass);
122
      this.scopedSiblings.hide();
123
      this.fieldErrorElement.hide();
124
    }
125
  }
126

127 128
  const customValidationFlag = 'no-gl-field-errors';

129 130 131
  class GlFieldErrors {
    constructor(form) {
      this.form = $(form);
132 133 134 135
      this.state = {
        inputs: [],
        valid: false
      };
136 137
      this.initValidators();
    }
138

139 140
    initValidators () {
      // select all non-hidden inputs in form
141 142 143
      this.state.inputs = this.form.find(':input:not([type=hidden])').toArray()
        .filter((input) => !input.classList.contains(customValidationFlag))
        .map((input) => new GlFieldError({ input, formErrors: this }));
144 145 146

      this.form.on('submit', this.catchInvalidFormSubmit);
    }
147

148 149 150 151 152 153 154 155
    /* Neccessary to prevent intercept and override invalid form submit
     * because Safari & iOS quietly allow form submission when form is invalid
     * and prevents disabling of invalid submit button by application.js */

    catchInvalidFormSubmit (event) {
      if (!event.currentTarget.checkValidity()) {
        event.preventDefault();
        event.stopPropagation();
156 157 158 159
      }
    }

    focusOnFirstInvalid () {
160 161
      const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0];
      firstInvalid.inputElement.focus();
162 163 164 165 166 167
    }
  }

  global.GlFieldErrors = GlFieldErrors;

})(window.gl || (window.gl = {}));