tableau-mkt/jquery.addressfield

View on GitHub
src/jquery.addressfield.js

Summary

Maintainability
F
3 days
Test Coverage
/*
 * Address field
 * https://github.com/tableau-mkt/jquery.addressfield
 *
 * Licensed under the MIT license.
 */
(function(factory) {
  /* istanbul ignore next */
  if (typeof module === "object" && typeof module.exports === "object") {
    factory(require("jquery"));
  } else {
    factory(jQuery);
  }
}(function factory($) {
  /**
   * Modifies an address field for this wrapped set of fields, given a config
   * representing how the country writes its addresses (conforming roughly to
   * xNAL standards), and an array of fields you desire to show (again, roughly
   * xNAL compatible).
   *
   * @param options
   *   A configuration object with the following properties:
   *   - fields: (Required) An object mapping xNAL field names to jQuery
   *     selectors corresponding to the associated form elements. Any fields in
   *     your form that are not listed here will be ignored when mutating your
   *     postal address form. Note that the "country" field is required at a
   *     minimum. A common example might look like:
   *     {
   *       country: 'select#address-country',
   *       localityname: 'input.city',
   *       administrativearea: '#address-state',
   *       postalcode: '.zipcode'
   *     }
   *   - json: One of:
   *     - A string, representing the path to a JSON resource containing postal
   *       address field configurations matching the format defined by the
   *       addressfield.json project. This project comes packaged with a release
   *       of addressfield.json for ease-of-use, but you can provide your own
   *       configuration as well!
   *     - An object, representing the exact same data in the exact same format
   *       as would be returned by the JSON request for the string version of
   *       this configuration. Useful in cases where a hard-coded configuration
   *       would be more advantageous over the extra http request.
   *   - async: (Optional) Boolean flag that represents whether the request to
   *     the JSON resource specified above will be performed synchronously or
   *     asynchronously. Defaults to true (async JSON request).
   *   - defs: Deprecated; if no JSON config is provided (neither a valid
   *     path nor a full JavaScript object), you can use this key to apply a
   *     one-time postal form mutation given a field configuration and field
   *     map. Useful for quick-and-dirty upgrades from jquery.addressfield 0.x.
   *     Use of this functionality is highly discouraged.
   *
   * @returns {*}
   *   Returns itself (useful for chaining).
   */
  $.fn.addressfield = function(options) {
    var $container = this,
        configs = $.extend({
          fields: {},
          json: null,
          async: true,
          // @deprecated Support for manual, synchronous, external control.
          defs: {fields: {}}
        }, options),
        transformedData;

    // If a path was given for a JSON resource, load the resource and execute.
    if (typeof configs.json === 'string') {
      $.ajax({
        dataType: "json",
        url: configs.json,
        async: configs.async,
        success: function (data) {
          transformedData = $.fn.addressfield.transform(data);

          $.fn.addressfield.initCountries.call($container, configs.fields.country, transformedData);
          $.fn.addressfield.binder.call($container, configs.fields, transformedData);
          $(configs.fields.country).change();
        }
      });
      return $container;
    }
    // In this case, a direct configuration has been provided inline.
    else if (typeof configs.json === 'object' && configs.json !== null) {
      transformedData = $.fn.addressfield.transform(configs.json);

      $.fn.addressfield.initCountries.call($container, configs.fields.country, transformedData);
      $.fn.addressfield.binder.call($container, configs.fields, transformedData);
      $(configs.fields.country).change();
      return $container;
    }
    // Legacy support for manual, synchronous, external control.
    // @deprecated Remove this functionality in the next major version (2.0.x).
    else {
      return $.fn.addressfield.apply.call($container, configs.defs, configs.fields);
    }
  };

  /**
   * Applies a given field configuration against a given postal address form.
   *
   * @param config
   *   The field configuration to be applied to the postal address form.
   *
   * @param fieldMap
   *   A mapping of xNAL field names to their selectors within this form.
   *
   * @returns {*}
   *   Returns itself (useful for chaining).
   */
  $.fn.addressfield.apply = function (config, fieldMap) {
    var $container = $(this),
        fieldOrder = [],
        $element,
        selector,
        placeholder,
        fieldPos,
        field;

    // Iterate through defined address fields for this country.
    for (fieldPos in config.fields) {
      // Determine the xNAL name of this field and ignore
      if (!config.fields.hasOwnProperty(fieldPos)) {
        continue;
      }
      field = $.fn.addressfield.onlyKey(config.fields[fieldPos]);

      // Pick out the existing elements for the given field.
      selector = fieldMap.hasOwnProperty(field) ? fieldMap[field] : '.' + field;
      $element = $container.find(selector);

      // Account for nested fields.
      if (config.fields[fieldPos][field] instanceof Array) {
        return $.fn.addressfield.apply.call($element, {fields: config.fields[fieldPos][field]}, fieldMap);
      }
      // Otherwise perform the usual actions.
      else {
        // When swapping out labels / values for existing fields.
        // Ensure the element exists and is configured to be displayed.
        if ($element.length && fieldMap.hasOwnProperty(field)) {
          // Push this field selector onto the fieldOrder array.
          fieldOrder.push(selector);

          // Update the options.
          if (typeof config.fields[fieldPos][field].options !== 'undefined') {
            // If this field has options but it's currently a text field,
            // convert it back to a select field.
            if (!$element.is('select')) {
              $element = $.fn.addressfield.convertToSelect.call($element);
            }
            $.fn.addressfield.updateOptions.call($element, config.fields[fieldPos][field].options);
          }
          else {
            // If this field does not have options but it's currently a select
            // field, convert it back to a text field.
            if ($element.is('select')) {
              $element = $.fn.addressfield.convertToText.call($element);
            }

            // Apply a placeholder; empty one if none exists.
            placeholder = config.fields[fieldPos][field].hasOwnProperty('eg') ? config.fields[fieldPos][field].eg : '';
            $.fn.addressfield.updateEg.call($element, placeholder);
          }

          // Update the label.
          $.fn.addressfield.updateLabel.call($element, config.fields[fieldPos][field].label);
        }

        // When adding fields that didn't previously exist.
        if (!$.fn.addressfield.isVisible.call($element) && fieldMap.hasOwnProperty(field)) {
          $.fn.addressfield.showField.call($element);
        }

        // Add, update, or remove validation handling for this field.
        $.fn.addressfield.validate.call($element, field, config.fields[fieldPos][field]);
      }
    }

    // Now check for fields that are still on the page but shouldn't be.
    $.each(fieldMap, function (field_name, field_selector) {
      var $element = $container.find(field_selector);
      if ($element.length && !$.fn.addressfield.hasField(config, field_name)) {
        $.fn.addressfield.hideField.call($element);
      }
    });

    // Now ensure the fields are in their given order.
    $.fn.addressfield.orderFields.call($container, fieldOrder);

    // Trigger an addressfield:after event on the container.
    $container.trigger('addressfield:after', {config: config, fieldMap: fieldMap});

    return this;
  };

  /**
  * Populates country dropdown with list of countries from provided json file if it is empty.
  *
  * @param selector
  *   Field identifying the country dropdown from user-configs.
  *
  * @param countryMap
  *   A map of country codes to country names.
  */
  $.fn.addressfield.initCountries = function(selector, countryMap) {
    var $container = this,
        $countrySelect = $container.find(selector + ':not(:has(>option))'),
        defaultCountry;

    if (!$countrySelect.length) {
      return;
    }
    else {
      defaultCountry = $countrySelect.attr('data-country-selected');
    }

    $.each(countryMap, function(key, value) {
      if (typeof defaultCountry !== 'undefined' &&
          key.toLowerCase() === defaultCountry.toLowerCase()) {
        $countrySelect.append($('<option></option>')
          .attr('value', key)
          .attr('selected', 'selected')
          .text(value.label)
        );
      }
      else {
        $countrySelect.append($('<option></option>')
          .attr('value', key)
          .text(value.label)
        );
      }
    });
  };

  /**
   * Binds a handler to the country form field element, which applies postal
   * address form mutations to this form container based on the selected country
   * and given xNAL field map.
   *
   * @param fieldMap
   *   A map of xNAL fields to jQuery selectors representing their corresponding
   *   form field elements.
   *
   * @param countryConfigMap
   *   A map of field configurations to country ISO codes which should match
   *   the values associated with the country select element, defined in the
   *   fieldMap above).
   */
  $.fn.addressfield.binder = function(fieldMap, countryConfigMap) {
    var $container = this;

    $container.find(fieldMap.country).bind('change', function() {
      // Trigger the apply method with the country's data.
      $.fn.addressfield.apply.call($container, countryConfigMap[this.value], fieldMap);
    });

    return $container;
  };

  /**
   * Transforms JSON data returned in the instantiation method to the format
   * expected by the binder method.
   */
  $.fn.addressfield.transform = function(data) {
    var countryMap = {},
        position;

    // Store a map of countries to their associated field configs.
    for (position in data.options) {
      countryMap[data.options[position].iso] = data.options[position];
    }

    return countryMap;
  };

  /**
   * Returns the "first" (only) key of a JavaScript object.
   */
  $.fn.addressfield.onlyKey = function (obj) {
    for (var i in obj) {
      return i;
    }
  };

  /**
   * Returns whether or not a given configuration contains a given field.
   */
  $.fn.addressfield.hasField = function (config, expectedField) {
    var pos,
        field;

    for (pos in config.fields) {
      field = $.fn.addressfield.onlyKey(config.fields[pos]);
      if (config.fields[pos][field] instanceof Array) {
        return $.fn.addressfield.hasField({fields: config.fields[pos][field]}, expectedField);
      }
      else {
        if (field === expectedField) {
          return true;
        }
      }
    }

    return false;
  };

  /**
   * Updates a given field's label with a given label.
   */
  $.fn.addressfield.updateLabel = function (label) {
    var $this = $(this),
        elementName = $this.attr('id'),
        $label = $('label[for="' + elementName + '"]') || $this.prev('label');

    $label.text(label);
  };

  /**
   * Updates a given field's expected format. By default, the placeholder text.
   */
  $.fn.addressfield.updateEg = function (example) {
    var text = example ? 'e.g. ' + example : '';
    $(this).attr('placeholder', text);
  };

  /**
   * Updates a given select field's options with given options.
   */
  $.fn.addressfield.updateOptions = function (options) {
    var $self = $(this),
        oldVal = $self.data('_saved') || $self.val();

    $self.children('option').remove();
    $.each(options, function (optionPos) {
      var value = $.fn.addressfield.onlyKey(options[optionPos]);
      $self.append($('<option></option>').attr('value', value).text(options[optionPos][value]));
    });

    // Ensure the old value is still reflected after options are updated.
    $self.val(oldVal).change();

    // Clean up the data attribute; no-op if it was not previously populated.
    $self.removeData('_saved');
  };

  /**
   * Converts a given select field to a regular textarea.
   */
  $.fn.addressfield.convertToText = function () {
    var $self = $(this),
        $input = $('<input />').attr('type', 'text');

    // Copy attributes from $self to $input.
    $.fn.addressfield.copyAttrsTo.call($self, $input);

    // Ensure the old value is still reflected after conversion.
    $input.val($self.val());

    // Replace the existing element with our new one; also return it.
    $self.replaceWith($input);
    return $input;
  };

  /**
   * Converts a given input field to a select field.
   */
  $.fn.addressfield.convertToSelect = function() {
    var $self = $(this),
        $select = $('<select></select>');

    // Copy attributes from $self to $select.
    $.fn.addressfield.copyAttrsTo.call($self, $select);

    // Save the old input value to a data attribute, for use in updateOptions.
    $select.data('_saved', $self.val());

    // Replace the existing element with our new one; also return it.
    $self.replaceWith($select);
    return $select;
  };

  /**
   * Optional integration with jQuery.validate.
   */
  $.fn.addressfield.validate = function(field, config) {
    var $this = $(this),
        methodName = 'isValid_' + field,
        rule = {},
        message = "Please check your formatting.";

    // Only proceed if jQuery.validator is installed.
    if (typeof $.validator !== 'undefined') {
      // Support pre-set validation messages.
      message = $.validator.messages.hasOwnProperty(methodName) ? $.validator.messages[methodName] : message;

      // If the provided field has a specified format...
      if (config.hasOwnProperty('format')) {
        // Create the validation method.
        $.validator.addMethod(methodName, function (value) {
          // @todo Drop jQuery 1.3 support. No need for .toString() call.
          // Make validation case insenstitve.
          return new RegExp(config.format, 'i').test($.trim(value.toString()));
        }, message);

        // Apply the rule.
        rule[methodName] = true;
        $this.rules('add', rule);
      }
      else {
        // If there is no format, create the validation method anyway, but have
        // it do nothing.
        $.validator.addMethod(methodName, function () {return true;}, message);
      }
    }
  };

  /**
   * Hides the field, but stores it for restoration later, if necessary.
   */
  $.fn.addressfield.hideField = function() {
    $(this).val('').hide();
    $.fn.addressfield.container.call(this).hide();
  };

  /**
   * Shows / restores the field that had been previously hidden.
   */
  $.fn.addressfield.showField = function() {
    this.show();
    $.fn.addressfield.container.call(this).show();
  };

  /**
   * Returns whether or not the field is visible.
   */
  $.fn.addressfield.isVisible = function() {
    return $(this).is(':visible');
  };

  /**
   * Returns the container element for a given field.
   */
  $.fn.addressfield.container = function() {
    var $this = $(this),
        elementName = $this.attr('id'),
        $label = $('label[for="' + elementName + '"]') || $this.prev('label');

    // @todo drop support for jQuery 1.3, just use .has()
    if (typeof $.fn.has === 'function') {
      return $this.parents().has($label).first();
    }
    else {
      return $this.parents().find(':has(label):has(#' + elementName + '):last');
    }
  };

  /**
   * Copies select HTML attributes from a given element to the supplied element.
   */
  $.fn.addressfield.copyAttrsTo = function($to) {
    var attributes = ['class', 'id', 'name', 'propdescname'],
        $this = $(this);

    $.each($this[0].attributes, function () {
      if ($.inArray(this.name, attributes) !== -1) {
        // Compatibility for IE8.
        if (this.name === 'propdescname') {
          $to.attr('name', this.value);
        }
        else {
          $to.attr(this.name, this.value);
        }
      }
    });
  };

  /**
   * Re-orders fields given an array of selectors representing fields. Note that
   * this can be called recursively if one of the values passed in the
   * order array is itself an array.
   */
  $.fn.addressfield.orderFields = function(order) {
    var $self = $(this),
        // Create an empty jQuery object.
        // @todo Remove .not(document) when dropping jQuery 1.3 support.
        $orderedContainers = $().not(document);

    // Form a jQuery object with container elements in the correct order.
    $.each(order, function (index, selector) {
      var $container = $.fn.addressfield.container.call($self.find(selector));
      $orderedContainers.push($container[0]);
    });

    // Re-append to parent in the correct order.
    $orderedContainers.detach().appendTo($self);
  };
}));