moneyadviceservice/dough

View on GitHub
assets/js/components/Validation.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * @description Client side validation. Mirrors HTML5 validation API as much as possible.
 * Supported types are:
 * - `required`
 * - `minlength`
 * - `pattern`
 * - `min|max` number range checking
 *
 * @module Validation
 * @returns {class} Validation
 */
define(['jquery', 'DoughBaseComponent'], function($, DoughBaseComponent) {
  'use strict';

  var defaultConfig = {
        fieldSelector: 'input, textarea, select',
        attributeEmpty: 'data-dough-validation-empty',
        attributeInvalid: 'data-dough-validation-invalid',
        rowInvalidClass: 'is-errored',
        validationSummaryClass: 'validation-summary',
        validationSummaryListAttribute: 'data-dough-validation-summary-list',
        validationSummaryHiddenClass: 'validation-summary--hidden',
        validationSummaryErrorClass: 'validation-summary__error',
        inlineErrorClass: 'js-inline-error',
        showValidationSummary: true,
        showValidationSummaryLinks: true,
        showInlineValidation: true,
        uiEvents: {
          'blur input, select, textarea': '_handleBlurEvent',
          'keyup input, textarea': '_handleChangeEvent',
          'change input, select': '_handleChangeEvent',
          'submit': '_handleSubmit'
        }
      },
      Validation;

  /**
   * Call base constructor
   * @constructor
   * @extends {DoughBaseComponent}
   */
  Validation = function($el, config) {
    Validation.baseConstructor.call(this, $el, config, defaultConfig);
  };

  DoughBaseComponent.extend(Validation);

  Validation.componentName = 'Validation';

  Validation.prototype.init = function(initialised) {
    this.ATTRIBUTE_VALIDATORS = {
      'required': '_validateRequired',
      'pattern': '_validatePattern',
      'min': '_validateMin',
      'max': '_validateMax',
      'minlength': '_validateMinLength'
    };

    // If there's server erros on the page, we back off completely
    // There are a number of different types of errors that the server
    // generates, and this file will grow in complexity trying to keep up.
    if (this.$el.find('[' + this.config.validationSummaryListAttribute + ']').find('li').length > 0) {
      this._unbindUiEvents();
      this.enabled = false;
      return this;
    }

    this.$allFieldsOnPage = this.$el.find(this.config.fieldSelector);
    this.errors = [];
    this._prepareMarkup();

    this.enabled = true;

    this._initialisedSuccess(initialised);
    return this;
  };

  /**
   * Register an error, to be used with both inline and validation summary
   * @param {Object} fieldGroupValidity The validity object generated by _getfieldGroupValidity()
   * @return {Validation}        Class instance
   */
  Validation.prototype.addError = function(fieldGroupValidity) {
    var existingErrorIndex = this._getErrorIndexByName(fieldGroupValidity.name);

    if (existingErrorIndex !== -1) {
      this.errors.splice(existingErrorIndex, 1);
    }

    this.errors.push(fieldGroupValidity);

    this._addAccessibility(fieldGroupValidity.$fieldGroup);
    this._sortErrorsByFieldDisplayOrder().refreshInlineErrors().refreshValidationSummary();

    return this;
  };

  /**
   * Remove an error
   * @param  {Object} fieldGroupValidity Field Validity Object
   * @return {Validation}        Class instance
   */
  Validation.prototype.removeError = function(fieldGroupValidity) {
    var existingErrorIndex = this._getErrorIndexByName(fieldGroupValidity.name);
    if (existingErrorIndex !== -1) {
      this.errors.splice(existingErrorIndex, 1);
    }

    this._removeAccessibility(fieldGroupValidity.$fieldGroup);
    this._sortErrorsByFieldDisplayOrder().refreshInlineErrors().refreshValidationSummary();

    return this;
  };

  /**
   * Refresh all the inline error messages
   * @return {Validation} Class instance
   */
  Validation.prototype.refreshInlineErrors = function() {
    if (!this.config.showInlineValidation) { return this; }

    this.$el.find('.form__row').each($.proxy(function(i, o) {
      var $formRow = $(o),
          $errorContainer = $formRow.find('.' + this.config.inlineErrorClass),
          $inputs = $formRow.find(this.config.fieldSelector),
          errorHTML = '',
          rowHasErrors = false,
          groupsDealtWith = [];

      $inputs.each($.proxy(function(_i, _o) {
        var $input = $(_o),
            inputName = $input.attr('name'),
            errorIndex = this._getErrorIndexByName(inputName);

        if (errorIndex > -1 && $.inArray(inputName, groupsDealtWith) === -1) {
          rowHasErrors = true;
          groupsDealtWith.push(inputName);
          errorHTML += '<p id="' + this._getInlineErrorID(inputName) + '" class="' +
                        this.config.validationSummaryErrorClass + '">' + (errorIndex + 1) + '. ' +
                        this.errors[errorIndex].message + '</p>';
        }
      }, this));

      if (rowHasErrors) {
        $formRow.addClass(this.config.rowInvalidClass);
      }
      else {
        $formRow.removeClass(this.config.rowInvalidClass);
      }

      $errorContainer.html(errorHTML);

    }, this));

    return this;
  };

  /**
   * Loop through the errors and build the summary markup
   * @return {Validation} Class instance
   */
  Validation.prototype.refreshValidationSummary = function() {
    if (!this.config.showValidationSummary) { return this; }

    var fieldName,
        summaryHTML = '';

    $.each(this.errors, $.proxy(function(errorIndex, fieldGroupValidity) {
      fieldName = fieldGroupValidity.name;
      if (!this.config.showValidationSummaryLinks) {
        summaryHTML += '<li class="' + this.config.validationSummaryErrorClass + '--no-link">' +
                        fieldGroupValidity.message + '</li>';
      } else {
        summaryHTML += '<li class="' + this.config.validationSummaryErrorClass + '"><a href="#' +
                      this._getInlineErrorID(fieldName) + '">' + fieldGroupValidity.message + '</a></li>';
      }

    }, this));

    this.$el.find('[' + this.config.validationSummaryListAttribute + ']').html(summaryHTML);

    if (this.errors.length < 1) {
      this._hideValidationSummary();
    }

    return this;
  };

  /**
   * Check a field's validity and update the errors array
   * @param  {jQuery} $field The field to validate
   * @return {Validation}        Class instance
   */
  Validation.prototype.checkfieldGroupValidity = function($field) {
    var $fieldGroup = this._getFieldGroup($field),
        fieldGroupValidity = this._getFieldGroupValidity($fieldGroup);

    if (fieldGroupValidity.hasError) {
      this.addError(fieldGroupValidity);
    }
    else {
      this.removeError(fieldGroupValidity);
    }

    return this;
  };

  /**
   * Prepare the markup for both inline errors and the validation summary
   *
   * This will check to see if there's an inline error block rendered by the server
   * (in case it's picked up errors we don't support)
   *
   * It will also generate a fallback list if the server hasn't been configured.
   *
   * @return {type} [description]
   */
  Validation.prototype._prepareMarkup = function() {
    var $validationSummary = this.$el.find('.' + this.config.validationSummaryClass);
    if (!$validationSummary.length && this.config.showValidationSummary) {
      this.$el.prepend(
        '<div class="' + this.config.validationSummaryClass + ' ' + this.config.validationSummaryHiddenClass + '">' +
          '<ol ' + this.config.validationSummaryListAttribute + '></ol>' +
        '</div>');
    }

    this.$el.find('.form__row').each($.proxy(function(i, o) {
      var $formRow = $(o),
          $existingInlineErrors = $formRow.find('.' + this.config.inlineErrorClass);

      if (!$existingInlineErrors.length && this.config.showInlineValidation) {
        $formRow.prepend($('<div class="' + this.config.inlineErrorClass + '" />'));
      }
    }, this));

    return this;
  };

  /**
   * Generate the ID to be used with the inline error blocks
   * These are used for the validation summary deeplinks, and
   * for the aria-describedby property on the field.
   *
   * @param  {String} fieldName The field name
   * @return {String}         The inline error ID
   */
  Validation.prototype._getInlineErrorID = function(fieldName) {
    var formID = this.$el.attr('id');
    return 'error-' + (formID ? formID + '-' : '') + fieldName;
  };

  /**
   * Add the accessibility attributes to an invalid field
   * @param {jQuery} $fieldGroup jQuery field
   * @return {Validation}  Class instance
   */
  Validation.prototype._addAccessibility = function($fieldGroup) {
    if (!this.config.showInlineValidation) { return this; }

    $fieldGroup.each($.proxy(function(i, field) {
      var $field = $(field),
          existingDescribedBy = $field.attr('aria-describedby') || '',
          inlineErrorID = this._getInlineErrorID($field.attr('name'));

      $field.attr('aria-invalid', 'true');

      if ($.inArray(inlineErrorID, existingDescribedBy) === -1) {
        $field.attr('aria-describedby', existingDescribedBy + ' ' + inlineErrorID);
      }
    }, this));

    return this;
  };

  /**
   * Remove aria attributes for a valid field
   * @param  {type} $fieldGroup [description]
   * @return {type}               [description]
   */
  Validation.prototype._removeAccessibility = function($fieldGroup) {
    $fieldGroup.each($.proxy(function(i, field) {
      var $field = $(field),
          existingDescribedBy = $field.attr('aria-describedby') || '',
          inlineErrorID = this._getInlineErrorID($field.attr('name'));

      $field.removeAttr('aria-invalid');
      $field.attr('aria-describedby', existingDescribedBy.replace(new RegExp(inlineErrorID, 'g'), ''));
    }, this));

    return this;
  };

  /**
   * Show the validation summary;
   *
   * @return {type} [description]
   */
  Validation.prototype._showValidationSummary = function() {
    if (!this.config.showValidationSummary) { return this; }

    this.$el.find('.' + this.config.validationSummaryClass).removeClass(this.config.validationSummaryHiddenClass);
    return this;
  };

  /**
   * Hide the validation summary;
   *
   * @return {type} [description]
   */
  Validation.prototype._hideValidationSummary = function() {
    this.$el.find('.' + this.config.validationSummaryClass).addClass(this.config.validationSummaryHiddenClass);
    return this;
  };

  /**
   * Check a field group's validity
   * For required fields, only ONE element in the group needs a value
   *
   * @param  {jQuery} $fieldGroup The field group to validate (grouped by 'name' attribute)
   * @return {Object}        A hash containing status and the appropriate error message
   */
  Validation.prototype._getFieldGroupValidity = function($fieldGroup) {
    var $primaryField = $fieldGroup.first(),
        fieldGroupValidity = {
          errors: [],
          isEmpty: true,
          isInvalid: false,
          hasError: false,
          message: '',
          name: $primaryField.attr('name'),
          $fieldGroup: $fieldGroup
        };

    // Populate the field validity with an array of results from the various validators
    $.each(this.ATTRIBUTE_VALIDATORS, $.proxy(function(attributeSelector, handler) {
      $fieldGroup.each($.proxy(function(_fieldIndex, field) {
        var $field = $(field),
            attr = $field.attr(attributeSelector);

        if (attr) {
          fieldGroupValidity.errors.push(this[handler]($field, $field.val(), attr));
        }
      }, this));
    }, this));

    return this._prepareFieldGroupValidity($primaryField, fieldGroupValidity);
  };

  /**
   * Make the fieldValidity object useful by hoisting up
   * various properties from the individual validators
   *
   * @param  {jQuery} $primaryField      The primary field of the group (usually the first)
   * @param  {Object} fieldGroupValidity Validity object including results of the validators
   * @return {Object}                    fieldGroupValidity with normalised states for display
   */
  Validation.prototype._prepareFieldGroupValidity = function($primaryField, fieldGroupValidity) {
    // Hoist up to top level for ease of access
    $.each(fieldGroupValidity.errors, function(i, validatorResults) {
      if (validatorResults.name === 'required' && validatorResults.isEmpty !== true) {
        fieldGroupValidity.isEmpty = false;
      }

      if (validatorResults.isInvalid) {
        fieldGroupValidity.isInvalid = true;
      }
    });

    fieldGroupValidity.hasError =
      fieldGroupValidity.errors.length && (fieldGroupValidity.isEmpty || fieldGroupValidity.isInvalid);

    // Check which message to use, empty should take prescedence
    if (fieldGroupValidity.isInvalid) {
      fieldGroupValidity.message =
        $primaryField.attr(this.config.attributeInvalid) || $primaryField.attr(this.config.attributeEmpty);
    }

    if (fieldGroupValidity.isEmpty) {
      fieldGroupValidity.message = $primaryField.attr(this.config.attributeEmpty);
    }

    return fieldGroupValidity;
  };

  /**
   * Returns true if the given field is a radio button or a checkbox
   *
   * @param  {jQuery} $field   the field being checked
   * @return {boolean}         whether or not the given field is a radio button or checkbox
   */
  Validation.prototype._isCheckable = function($field) {
    return $field.is('[type="radio"]') || $field.is('[type="checkbox"]');
  };

  /**
   * Basic required field validator, for non-empty
   *
   * @param  {jQuery} $field   the field being checked
   * @param  {String} value    the field value
   * @return {Object}          Validity object
   */
  Validation.prototype._validateRequired = function($field, value) {
    var validity = { name: 'required' };

    if (this._isCheckable($field) && !$field.prop('checked')) {
      validity.isEmpty = true;
    }
    else {
      if (value === '') {
        validity.isEmpty = true;
      }
    }

    return validity;
  };

  /**
   * Regular expression validator
   *
   * @param  {jQuery} $field   the field being checked
   * @param  {String} value    the field value
   * @param  {String} pattern Validation parameters
   * @return {Object}          Validity object
   */
  Validation.prototype._validatePattern = function($field, value, pattern) {
    var validity = { name: 'pattern' };
    if (!value.match(pattern)) {
      validity.isInvalid = true;
    }

    return validity;
  };

  /**
   * Check a number is above the minimum
   *
   * @param  {jQuery} $field   the field being checked
   * @param  {String} value    the field value
   * @param  {String} min Validation parameters
   * @return {Object}          Validity object
   */
  Validation.prototype._validateMin = function($field, value, min) {
    var validity = { name: 'min' },
        valueAsNumber = Number(value);

    if (isNaN(valueAsNumber) || valueAsNumber < min) {
      validity.isInvalid = true;
    }

    return validity;
  };

  /**
   * Check a number is below the maximum
   *
   * @param  {jQuery} $field   the field being checked
   * @param  {String} value    the field value
   * @param  {String} max Validation parameters
   * @return {Object}          Validity object
   */
  Validation.prototype._validateMax = function($field, value, max) {
    var validity = { name: 'max' },
        valueAsNumber = Number(value);

    if (isNaN(valueAsNumber) || valueAsNumber > max) {
      validity.isInvalid = true;
    }

    return validity;
  };

  /**
   * Ensure a minimum number of characters
   *
   * @param  {jQuery} $field   the field being checked
   * @param  {String} value    the field value
   * @param  {String} minlength Validation parameters
   * @return {Object}          Validity object
   */
  Validation.prototype._validateMinLength = function($field, value, minlength) {
    var validity = { name: 'minlength' };
    // Check for more than 0 otherwise we clash with 'isEmpty'
    if (value.length > 0 && value.length < minlength) {
      validity.isInvalid = true;
    }

    return validity;
  };

  /**
   * Get the index in the error array according to the field group name
   *
   * @param  {String} fieldName Field name
   * @return {Integer}    Index in errors array
   */
  Validation.prototype._getErrorIndexByName = function(fieldName) {
    var matchedErrorIndex = -1;
    $.each(this.errors, $.proxy(function(index, fieldGroupValidity) {
      var _fieldName = fieldGroupValidity.name;
      if (_fieldName === fieldName) {
        matchedErrorIndex = index;
        return;
      }
    }, this));

    return matchedErrorIndex;
  };

  /**
   * Sort the errors so they are in line with the order the fields are displayed on the page
   * regardless of the order they were 'created'
   *
   * If the user fills in the form bottom-to-top, then the first error will still be the
   * first field on the page.
   *
   * @return {Validation} Class Instance
   */
  Validation.prototype._sortErrorsByFieldDisplayOrder = function() {
    var sortedErrors = [],
        groupsDealtWith = [];

    this.$allFieldsOnPage.each($.proxy(function(i, o) {
      var $field = $(o),
          fieldName = $field.attr('name'),
          fieldErrorIndex = this._getErrorIndexByName(fieldName);

      if (fieldErrorIndex !== -1 && $.inArray(fieldName, groupsDealtWith) === -1) {
        sortedErrors.push(this.errors[fieldErrorIndex]);
        groupsDealtWith.push(fieldName);
      }
    }, this));

    this.errors = sortedErrors;
    return this;
  };

  /**
   * Look for all fields with the same name, and validate
   * as a group.
   * Typical use case for this is radio/checkboxes.
   *
   * @param  {jQuery} $field jQuery field
   * @return {jQuery}        jQuery fieldgroup, array of fields with matching name
   */
  Validation.prototype._getFieldGroup = function($field) {
    var fieldName = $field.attr('name');

    return this.$allFieldsOnPage.filter('[name="' + fieldName + '"]');
  };

  /**
   * Inline errors are shown on input blur
   *
   * @param  {Event} e BlurEvent
   * @return {void}
   */
  Validation.prototype._handleBlurEvent = function(e) {
    var $field = $(e.target),
        isCheckbox = $field.is('[type="checkbox"]');

    if (!isCheckbox) {
      this.checkfieldGroupValidity($field);
    }
  };

  /**
   * Error messages get corrected as the user types. Only do this if we can see an error exists.
   *
   * @param  {Object} e ChangeEvent
   * @return {void}
   */
  Validation.prototype._handleChangeEvent = function(e) {
    var $field = $(e.target);

    if (this._getErrorIndexByName($field.attr('name')) > -1) {
      this.checkfieldGroupValidity($field);
    }
  };

  /**
   * The validation summary is updated on form submit
   *
   * @return {void}
   */
  Validation.prototype._handleSubmit = function(e) {
    this.$allFieldsOnPage.each($.proxy(function(i, field) {
      this.checkfieldGroupValidity($(field));
    }, this));

    if (this.errors.length) {
      e.preventDefault();
      this._sortErrorsByFieldDisplayOrder().refreshValidationSummary()._showValidationSummary();
    }
  };


  return Validation;

});