uktrade/directory-components

View on GitHub
directory_components/static/directory_components/js/dit.components.company-lookup.js

Summary

Maintainability
D
1 day
Test Coverage
var dit = dit || {};
dit.components = dit.components || {};

/*
  General utility methods
  ======================= */
dit.utils = (new function() {

  /* Try to dynamically generate a unique String value.
   **/
  this.uniqueString = function() {
    return "_" + ((new Date().getTime()) + "_" + Math.random().toString()).replace(/[^\w]*/mig, "");
  }

});

/*
  General data storage and services
  =================================== */
dit.data = (new function() {

  function Service(url, configuration) {
    var service = this;
    var config = $.extend({
      url: url,
      method: "GET",
      success: function(response) {
        service.response = response;
      }
    }, configuration || {});

    var listeners = [];
    var request; // Reference to active update request

    service.response = {}; // What we get back from an update

    /* Gets a fresh response
     * @params (String) Specify params for GET or data for POST
     **/
    service.update = function(params) {
      if(request) request.abort(); // Cancels a currently active request
      config.data = params || "";
      request = $.ajax(config);
      request.done(function() {
        // Activate each listener task
        for(var i=0; i<listeners.length; ++i) {
          listeners[i]();
        }
      })
    }

    /* Specify data processing task after response
     * @task (Function) Do something after service.response has been updated
     **/
    service.listener = function(task) {
      listeners.push(task);
    }
  }

  this.Service = Service;

});

dit.components.lookup = (new function() {

  /* Performs a data lookup and displays multiple choice results
   * to populate the input value with user choice.
   *
   * @$input (jQuery node) Target input element
   * @request (Function) Returns reference to the jqXHR requesting data
   * @content (Function) Returns content to populate the dropdown
   * @options (Object) Allow some configurations
   **/
  this.SelectiveLookup = SelectiveLookup;
  function SelectiveLookup($input, service, options) {
    var instance = this;
    var popupId = dit.utils.uniqueString();

    // Configure options.
    var opts = $.extend({
      lookupOnCharacter: 4,  // (Integer) At what character input to trigger the request for data.
      showNoneOfThese: false // (Boolean) Show "none of these results" at the end.
    }, options || {});

    instance.options = opts
    // Some inner variable requirement.
    instance._private = {
      active: false, // State management to isolate the listener.
      service: service, // Service that retrieves and stores the data
      $list: $("<ul class=\"SelectiveLookupDisplay\" style=\"display:none;\" id=\"" + popupId + "\" role=\"listbox\"></ul>"),
      $input: $input,
      timer: null
    }

    // Will not have arguments if being inherited for prototype
    if(arguments.length >= 2) {
      // Bind lookup event.
      $input.attr("autocomplete", "off"); // Because it interferes with results display.
      $input.on("focus.SelectiveLookup", function() { instance._private.active = true; });
      $input.on("blur.SelectiveLookup", function() { instance._private.active = false; });
      $input.on("input.SelectiveLookup", function() {
        if(instance._private.timer) {
          clearTimeout(instance._private.timer);
        }

        if(this.value.length >= opts.lookupOnCharacter) {
          instance._private.timer = setTimeout(function() {
            instance.search()
          }, 500);
        }
      });

      $input.on("keyup.SelectiveLookup", function(e) {
        // check backspace
        if(e.which == 8) {
          instance._private.$field.val('');
        }
      })

      $input.on("keypress.SelectiveLookup", function(e) {
        if(e.which !== 0) {
          instance._private.$field.val('');
        }
      });

      /* Bind events to allow keyboard navigation of component.
       * Using keydown event because works better with Tab capture.
       * Supports following keys:
       * 9 = Tab
       * 13 = Enter
       * 27 = Esc
       * 38 = Up
       * 40 = Down
       */
      $input.on("keydown.SelectiveLookup", function(e) {
        switch(e.which) {

          // Esc to close when on input
          case 27:
            instance.close();
            break;

          // Tab or arrow from input to list
          case  9:
          case 40:
            if(!e.shiftKey && instance._private.$input.attr("aria-expanded") === "true") {
              e.preventDefault();
              instance._private.$list.find("li:first-child").focus();
            }
        }
      });

      instance._private.$list.on("keydown.SelectiveLookup", "li", function(e) {
        var $current = $(e.target);
        switch(e.which) {
          // Prevent tabbing beyond list
          case 9:
            if($current.is(":last-child") && !e.shiftKey) {
              e.preventDefault();
            }
            break;

          // Arrow movement between list items
          case 38:
            e.preventDefault();
            $current.prev("li").focus();
            break;
          case 40:
            e.preventDefault();
            $current.next("li").focus();
            break;

          // Esc to close when on list item (re-focus on input)
          case 27:
            instance.close();
            $input.focus();
            break;

          // Enter key item selection
          case 13:
            e.preventDefault();
            $current.click();
        }
      });

      // Tab or arrow movement from list to input
      instance._private.$list.on("keydown.SelectiveLookup", "li:first-child", function(e) {
        if(e.shiftKey && e.which === 9 || e.which === 38) {
          e.preventDefault();
          $input.focus();
        }
      });

      // Bind service update listener
      instance._private.service.listener(function() {
        if(instance._private.active) {
          instance.setContent();
          instance.bindContentEvents();
          instance.open();
        }
      });

      // Add some accessibility support
      $input.attr("aria-autocomplete", "list");
      $input.attr("role", "combobox");
      $input.attr("aria-expanded", "false");
      $input.attr("aria-owns", popupId);

      // Add display element
      $(document.body).append(instance._private.$list);

      // Register the instance
      SelectiveLookup.instances.push(this);

      // A little necessary visual calculating.
      $(window).on("resize", function() {
        instance.setSizeAndPosition();
      });
    }
  }

  SelectiveLookup.prototype = {};
  SelectiveLookup.prototype.bindContentEvents = function() {
    var instance = this;
    instance._private.$list.off("click.SelectiveLookupContent");
    instance._private.$list.on("click.SelectiveLookupContent", function(event) {
      var $eventTarget = $(event.target);
      if($eventTarget.attr("data-value")) {
        instance._private.$input.val($eventTarget.attr("data-value"));
      }
    });
  }
  SelectiveLookup.prototype.close = function() {
    var $input = this._private.$input;
    if($input.attr("aria-expanded") === "true") {
      this._private.$list.css({ display: "none" });
      $input.attr("aria-expanded", "false");
      $input.focus();
    }
  }
  SelectiveLookup.prototype.search = function() {
   this._private.$errors.empty();
   this._private.service.update(this.param());
  }
  SelectiveLookup.prototype.param = function() {
    // Set param in separate function to allow easy override.
    return this._private.$input.attr("name") + "=" + this._private.$input.value;
  }
  /* Uses the data set on associated service to build HTML
   * result output. Since data keys are quite likely to vary
   * across services, you can pass through a mappingn object
   * to avoid the default/expected key names.
   * @datamapping (Object) Allow change of required key name
   **/
  SelectiveLookup.prototype.setContent = function(datamapping) {
    var data = this._private.service.response;
    var $list = this._private.$list;
    var map = datamapping || { text: "text", value: "value" };
    $list.empty();
    if(data && data.length) {
      for(var i=0; i<data.length; ++i) {
        // Note:
        // Only need to set a tabindex attribute to allow focus.
        // The value is not important here.
        $list.append("<li role=\"option\" tabindex=\"1000\" data-value=\"" + data[i][map.value] + "\">" + data[i][map.text] + "</li>");
      }
      if (this.options.showNoneOfThese) {
        $list.append('<li id="company-lookup-name-not-in-companies-house" role="option">None of these companies. I\'m not in Companies House</li>');
      }
    } else {
      $list.append('<li id="company-lookup-name-no-results-found" role="option">No results found</li>');
    }
  }
  SelectiveLookup.prototype.setSizeAndPosition = function() {
    var position = this._private.$input.offset();
    this._private.$list.css({
      left: parseInt(position.left) + "px",
      position: "absolute",
      top: (parseInt(position.top) + this._private.$input.outerHeight()) + "px",
      width: this._private.$input.outerWidth() + "px"
    });
  }
  SelectiveLookup.prototype.open = function() {
    this.setSizeAndPosition();
    this._private.$list.css({ display: "block" });
    this._private.$input.attr("aria-expanded", "true");
  }


  SelectiveLookup.instances = [];
  SelectiveLookup.closeAll = function() {
    for(var i=0; i<SelectiveLookup.instances.length; ++i) {
      SelectiveLookup.instances[i].close();
    }
  }

  /* Extends SelectiveLookup to perform specific requirements
   * for Companies House company search by name, and resulting
   * form field population.
   * @$input (jQuery node) Target input element
   * @$field (jQuery node) Alternative element to populate with selection value
   **/
  this.CompaniesHouseNameLookup = CompaniesHouseNameLookup;
  function CompaniesHouseNameLookup($input, $field, url, options) {
    var instance = this;
    var service = new dit.data.Service(url);
    SelectiveLookup.call(this, $input, service, options);

    // Some inner variable requirement.
    this._private.$field = $field || $input; // Allows a different form field to receive value.
    this._private.$form = $input.parents("form");
    this._private.$errors = $(".errors", this._private.$form);

    // Custom error handling.
    this._private.$form.on("submit.CompaniesHouseNameLookup", function(e) {
      // If no input or no company selected
      if(instance._private.$field.val() === "") {
        instance._private.$input.val($eventTarget.text());
        instance._private.$field.val($eventTarget.attr("data-value"));
        instance._private.$errors.empty();
        instance._private.$errors.append("<p>Check that you entered the company name correctly and select the matching company name from the list.</p>");
      }
    });
  }
  CompaniesHouseNameLookup.prototype = new SelectiveLookup;
  CompaniesHouseNameLookup.prototype.bindContentEvents = function() {
    var instance = this;
    instance._private.$list.off("click.SelectiveLookupContent");
    instance._private.$list.on("click.CompaniesHouseNameLookup", function(event) {
      var $eventTarget = $(event.target);

      // Try to set company number value.
      if($eventTarget.attr("data-value")) {
        instance._private.$input.val($eventTarget.text());
        instance._private.$field.val($eventTarget.attr("data-value"));
      }
    });
  }
  CompaniesHouseNameLookup.prototype.param = function() {
    return "term=" + escape(this._private.$input.val());
  }
  CompaniesHouseNameLookup.prototype.setContent = function() {
    SelectiveLookup.prototype.setContent.call(this, {text: "title", value: "company_number"});
  }
});