Source: src/js/views/searchSelect/SearchableSelectView.js

define([
    "jquery",
    "underscore",
    "backbone",
    "semanticUItransition",
    "text!" + MetacatUI.root + "/components/semanticUI/transition.min.css",
    "semanticUIdropdown",
    "text!" + MetacatUI.root + "/components/semanticUI/dropdown.min.css",
    "text!templates/selectUI/searchableSelect.html",
  ],
  function($, _, Backbone, Transition, TransitionCSS, Dropdown, DropdownCSS, Template) {

    /**
     * @class SearchableSelectView
     * @classdesc A select interface that allows the user to search from within
     * the options, and optionally select multiple items. Also allows the items
     * to be grouped, and to display an icon or image for each item.
     * @classcategory Views/SearchSelect
     * @extends Backbone.View
     * @constructor
     * @since 2.14.0
     * @screenshot views/searchSelect/SearchableSelectView.png
     */
    return Backbone.View.extend(
      /** @lends SearchableSelectView.prototype */
      {
        /**
         * The type of View this is
         * @type {string}
         */
        type: "SearchableSelect",

        /**
         * The HTML class names for this view element
         * @type {string}
         */
        className: "searchable-select",

        /**
         * Text to show in the input field before any value has been entered
         * @type {string}
         */
        placeholderText: "Search for or select a value",

        /**
         * Label for the input element
         * @type {string}
         */
        inputLabel: "Select a value",

        /**
         * Whether to allow users to select more than one value
         * @type {boolean}
         */
        allowMulti: true,

        /**
         * Setting to true gives users the ability to add their own options that
         * are not listed in this.options. This can work with either single
         * or multiple search select dropdowns
         * @type {boolean}
         */
        allowAdditions: false,

        /**
         * Whether the dropdown value can be cleared by the user after being
         * selected.
         * @type {boolean}
         */
        clearable: true,

        /**
        * When items are grouped within categories, this attribute determines how to display the items
        * within each category.
        * @type {string}
        * @example
        * // display the items in a traditional, non-interactive list below category titles
        * "list"
        * @example
        * // initially show only a list of category titles, and popout
        * // a submenu on the left or right when the user hovers over
        * // or touches a category (can lead to the sub-menu being hidden
        * // on mobile devices if the element is wide)
        * "popout"
        * @example
        * // initially show only a list of category titles, and expand
        * // the list of items below each category when a user clicks
        * // on the category title, much like an "accordion" element.
        * "accordion"
        * @default "list"
         */
        submenuStyle: "list",

        /**
         * Set to false to always display category headers in the dropdown,
         * even if there are no results in that category when a user is searching.
         * @type {boolean}
         */
        hideEmptyCategoriesOnSearch: true,

        /**
         * The maximum width of images used for each option, in pixels
         * @type {number}
         */
        imageWidth: 30,

        /**
         * The maximum height of images used for each option, in pixels
         * @type {number}
         */
        imageHeight: 30,

        /**
         * For select inputs where multiple values are allowed
         * ({@link SearchableSelectView#allowMulti} is true), optional text to insert
         * between labels. Separator text is useful for indicating operators in filter
         * fields or values.
         * @type {string}
         * @since 2.15.0
         */
        separatorText: "",

        /**
        * For select inputs where multiple values are allowed
        * ({@link SearchableSelectView#allowMulti} is true), a list of
        * {@link SearchableSelectView#separatorText} options. If a list is provided here
        * (AND a value is provided for the {@link SearchableSelectView#separatorText}
        * option), then a user can click on the separator text between two values to
        * change the text to the next string in this list. If separatorTextOptions is
        * false (or if there is no separatorText value), then changing the separator text
        * is not possible. This view will trigger a "separatorChanged" event when the
        * separator is updated.
        * @type {string[]}
        * @since 2.17.0
        */
        separatorTextOptions: ["AND", "OR"],

        /**
         * The HTML class name to add to the separator elements that are created for this
         * view.
         * @type {string}
         * @since 2.15.0
         */
        separatorClass: "separator",

        /** 
         * An additional HTML class to add to separator elements on hover when a user can
         * click that element to switch the text.
         * @type {string}
         * @since 2.17.0
         */
        changeableSeparatorClass: "changeable-separator",

        /**
         * For separators that are changeable (see
         * {@link SearchableSelectView#separatorTextOptions}), optional tooltip text to
         * show when a user hovers over a separator element.
         * @type {string}
         * @since 2.17.0
         */
        changeableSeparatorTooltip: "Click to switch the operator",

        /**
         * The list of options that a user can select from in the dropdown menu. For
         * un-categorized options, provide an array of objects, where each object is a
         * single option. To create category headings, provide an object containing named
         * objects, where the key for each object is the category title to display, and
         * the value of each object comprises the option properties.
         * @name SearchableSelectView#options
         * @type {Object[]|Object}
         * @property {string} icon - The name of a Font Awesome 3.2.1 icon to display to
         * the left of the label (e.g. "lemon", "heart")
         * @property {string} image - The complete path to an image to use instead of an
         * icon. If both icon and image are provided, the icon will be used.
         * @property {string} label - The label to show for the option
         * @property {string} description - A description of the option, displayed as a
         * tooltip when the user hovers over the label
         * @property {string} value - If the value differs from the label, the value to
         * return when this option is selected (otherwise label is returned)
         * @example
         * [
         *   {
         *     icon: "",
         *     image: "https://www.dataone.org/uploads/member_node_logos/bcodmo_hu707c109c683d6da57b432522b4add783_33081_300x0_resize_box_2.png",
         *     label: "BCO",
         *     description: "The The Biological and Chemical Oceanography Data Management Office (BCO-DMO) serve data from research projects funded by the Biological and Chemical Oceanography Sections and the Division of Polar Programs Antarctic Organisms & Ecosystems Program at the U.S. National Science Foundation.",
         *     value: "urn:node:BCODMO"
         *   },
         *   {
         *     icon: "",
         *     image: "https://www.dataone.org/uploads/member_node_logos/arctic.png",
         *     label: "ADC",
         *     description: "The US National Science Foundation Arctic Data Center operates as the primary repository supporting the NSF Arctic community for data preservation and access.",
         *     value: "urn:node:ARCTIC"
         *   },
         * ]
         * @example
         * {
         *   "category A": [
         *     {
         *       icon: "flag",
         *       label: "Flag",
         *       description: "This is a flag"
         *     },
         *     {
         *       icon: "gift",
         *       label: "Gift",
         *       description: "This is a gift"
         *     }
         *   ],
         *   "category B": [
         *     {
         *       icon: "pencil",
         *       label: "Pencil",
         *       description: "This is a pencil"
         *     },
         *     {
         *       icon: "hospital",
         *       label: "Hospital",
         *       description: "This is a hospital"
         *     }
         *   ]
         * }
         */
        options: [],

        /**
         * The values that a user has selected. If provided to the view upon
         * initialization, the values will be pre-selected. Selected values must
         * exist as a label in the options {@link SearchableSelect#options}
         * @type {string[]}
         */
        selected: [],

        /**
         * Can be set to an object to specify API settings for retrieving remote selection
         * menu content from an API endpoint. Details of what can be set here are
         * specified by the Semantic-UI / Fomantic-UI package. Set to false if not
         * retrieving remote content.
         * @type {Object|booealn}
         * @default false
         * @since 2.15.0
         * @see {@link https://fomantic-ui.com/modules/dropdown.html#remote-settings}
         * @see {@link https://fomantic-ui.com/behaviors/api.html#/settings}
         */
        apiSettings: false,

        /**
         * The primary HTML template for this view. The template follows the
         * structure specified for the semanticUI dropdown module, see:
         * https://semantic-ui.com/modules/dropdown.html#/definition
         * @type {Underscore.template}
         */
        template: _.template(Template),

        /**
         * Creates a new SearchableSelectView
         * @param {Object} options - A literal object with options to pass to the view
         */
        initialize: function(options) {

          try {

            // Add CSS required for this view
            MetacatUI.appModel.addCSS(TransitionCSS, "semanticUItransition");
            MetacatUI.appModel.addCSS(DropdownCSS, "semanticUIdropdown");

            // If pre-selected values that are passed to this view are also attached to a
            // model (e.g. when they were passed to this view as {selected:
            // parentView.model.get("values")}), then it's important that we use a clone
            // instead. Otherwise this view may silently update the model, and important
            // events may not be triggered.
            if(options.selected){
              options.selected = _.clone(options.selected);
            }

            // If pre-selected values that are passed to this view are also attached to a
            // model (e.g. when they were passed to this view as {selected:
            // parentView.model.get("values")}), then it's important that we use a clone
            // instead. Otherwise this view may silently update the model, and important
            // events may not be triggered.
            if(options.selected){
              options.selected = _.clone(options.selected);
            }

            // Get all the options and apply them to this view
            if (typeof options == "object") {
              var optionKeys = Object.keys(options);
              _.each(optionKeys, function(key, i) {
                this[key] = options[key];
              }, this);
            }

          } catch (e) {
            console.log("Failed to initialize a Searchable Select view, error message:",
            e);
          }
        },

        /**
         * Render the view
         *
         * @return {SearchableSelect}  Returns the view
         */
        render: function() {

          try {

            var view = this;

            if(view.apiSettings && !view.semanticAPILoaded){
              require([MetacatUI.root + "/components/semanticUI/api.min.js"], function(SemanticAPI){
                view.semanticAPILoaded = true
                view.render();
              })
              return;
            }

            // Render the template using the view attributes
            this.$el.html(this.template(this));

            // Start the dropdown in a disabled state.
            // This allows us to pre-select values without triggering a change
            // event.
            this.disable();
            this.showLoading();

            // Initialize the dropdown interface
            // For explanations of settings, see:
            // https://semantic-ui.com/modules/dropdown.html#/settings
            this.$selectUI = this.$el.find('.ui.dropdown')
              .dropdown({
                keys : {
                  // So that a user may enter search text using a comma
                  delimiter  : false
                },
                apiSettings: this.apiSettings,
                fullTextSearch: true,
                duration: 90,
                forceSelection: false,
                ignoreDiacritics: true,
                clearable: view.clearable,
                allowAdditions: view.allowAdditions,
                hideAdditions: false,
                allowReselection: true,
                onRemove: function(removedValue){
                  // Callback when a value is removed *for multi-select inputs only*
                  // Remove the value from the selected array
                  view.selected = view.selected.filter(function(value){
                    return value !== removedValue
                  })
                },
                onLabelCreate: function(value, text){
                   // Callback when a label is created *for multi-select inputs only*

                  // Add the value to the selected array (but don't add twice). Do this in
                  // the onLabelCreate callback instead of in the onAdd callback because
                  // we would like to update the selected array before we create the
                  // separator element (below).
                  if(!view.selected.includes(value)){
                    view.selected.push(value)
                  }
                  // Add a separator between labels if required.
                  var label = this;
                  if(view.separatorRequired.call(view)){
                    // Create the separator element.
                    var separator = view.createSeparator.call(view);
                    if(separator){
                      // Attach the separator to the label so that we can easily remove it
                      // when the label is removed.
                      label.data("separator", separator);
                      // Add it before the label element.
                      label = separator.add(label);
                    }
                  }
                  return label
                },
                onLabelRemove(value){
                  // Call back when a user deletes a label *for multi-select inputs only*
                  var label = this;
                  // Remove the separator before this label if there is one.
                  var sep = label.data("separator")
                  if(sep){
                    sep.remove()
                  }
                  // If this is the first label in an input of at least two, then delete
                  // the separator directly *after* this label - The label that's second
                  // will become first, and should not have an separator before it.
                  var allLabels = view.$selectUI.find(".label");
                  if(allLabels.index(label) === 0){
                    var separatorAfter = label.next("." + view.separatorClass);
                    if(separatorAfter){
                      separatorAfter.remove();
                    }
                  }
                },
                onChange: function(values, text, $choice){

                  // Callback when values change for any type of input.

                  // NOTE: The "values" argument is a string that contains all the
                  // selected values separated by commas. We updated the view.selected
                  // array with the onLabelCreate and onRemove callbacks instead of using
                  // the values argument passed to this function in order to allow commas
                  // within individual values. For example, if the user selected the value
                  // "x" and the value "y,z", the values string would be "x,y,z" and it
                  // would be difficult to see that two values were selected instead of
                  // three.

                  // Update values for single-select inputs (multi-select are updated
                  // using the onLabelCreate and onRemove callbacks)
                  if(!view.allowMulti){
                    view.selected = [values]
                  }

                  // Trigger an event if items are selected after the UI has been rendered
                  // (It is set as disabled until fully rendered).
                  if(!$(this).hasClass("disabled")){
                    var newValues = _.clone(view.selected);
                    view.trigger('changeSelection', newValues);
                  }

                  // Refresh the tooltips on the labels/text

                  // Ensure tooltips for labels are removed
                  $(".search-select-tooltip").remove();

                  // Add a tooltip for single select elements (.text) or multi-select
                  // elements (.label). Delay so that to give time for DOM elements to be
                  // added or removed.
                  setTimeout(function(params) {
                    var textEl = view.$selectUI.find(".text:not(.default),.label");
                    // Single select text element will not have the value attribute, add
                    // it so that we can find the matching description for the tooltip
                    if(!textEl.data("value") && !view.allowMulti){
                      textEl.data("value", values)
                    }
                    if(textEl){
                      textEl.each(function(i, el){
                        view.addTooltip.call(view, el, "top");
                      })
                    }
                  }, 50);
                },
              });

            view.$selectUI.data("view", view);

            view.postRender();

            return this;

          } catch (e) {
            console.log("Error rendering the search select, error message: ", e);
          }
        },

        /**
         * Change the options available in the dropdown menu and re-render.
         * @param {SearchableSelectView#options} options - The new options
         * @since 2.24.0
         */
        updateOptions: function (options) {
          this.options = options;
          this.render();
        },

        /**
         * Checks whether a separator should be created for the label that was just
         * created, but not yet attached to the DOM
         * @return {boolean} - Returns true if a separator should be created, false
         * otherwise.
         * @since 2.15.0
         */
        separatorRequired: function(){
          try {
            if(
              // Separators not required if only one selection is allowed
              !this.allowMulti ||
              // Need separator text to create a separator element
              !this.separatorText ||
              // Need the list of selected values to determine the value's position
              !this.selected ||
              // Separator is only required between two or more values
              this.selected.length <= 1 ||
              // Separator is only required after the first element has been added
              this.$selectUI.find(".label").length === 0
            ){
              return false
            } else {
              return true
            }
          } catch (error) {
            console.log("Error checking if a label in a searchable select input " +
            "requires a separator. Assuming that it does not need one. Error details: " +
            error);
            return false
          }
        },

        /**
         * Create the HTML for a separator element to insert between two labels. The
         * view.separatorClass is added to the separator element.
         * @return {JQuery} Returns the separator as a jQuery element
         * @since 2.15.0
         */
        createSeparator: function(){
          try {
            var view = this;
            var separatorText = this.separatorText;
            // Text is required to create a separator.
            if(!separatorText){
              return null
            }
            var separator = $("<span>" + separatorText + "</span>");
            separator.addClass(this.separatorClass);
            
            // Set a listener to change the text to one of the separatorText
            // options on click, and to highlight all the separators when one is hovered
            var separatorElHovered = false
            if (view.separatorTextOptions && view.separatorTextOptions.length) {
              // Indicate that the separator is clickable
              separator.css('cursor', 'pointer');
              // Make sure the listeners set below are only set once
              separator.off("click mouseenter mouseout");
              // Change all the separator text when one is clicked
              separator.on("click", function () {
                view.changeSeparator();
              })
              // Create the tooltip
              if (view.changeableSeparatorTooltip) {
                $(separator).tooltip('destroy');
                $(separator).tooltip({
                  title: view.changeableSeparatorTooltip,
                  trigger: 'manual',
                })
              }
              // Highlight all of the separator elements when one is hovered
              separator.on("mouseenter", function () {
                var separatorEls = view.$el.find("." + view.separatorClass)
                separatorElHovered = true;
                // Add a delay before the highlight class is added
                setTimeout(function () {
                  if (separatorElHovered){
                    separatorEls.addClass(view.changeableSeparatorClass);
                    if (view.changeableSeparatorTooltip){
                      // Add an even longer delay before the tooltip is shown
                      setTimeout(function () {
                        if (separatorElHovered) {
                          $(separator).tooltip('show')
                        }
                      }, 600);
                    }
                  }
                }, 285);
              })
              // Hide all the tooltips and remove the highlight class on mouse out
              separator.on("mouseout", function () {
                separatorElHovered = false;
                var separatorEls = view.$el.find("." + view.separatorClass)
                separatorEls.removeClass(view.changeableSeparatorClass)
                separatorEls.tooltip('hide')
              })
            }
            return separator
          } catch (error) {
            console.log("There was an error creating a separator element in a " +
              "Searchable Select View. Error details: " + error);
          }
        },

        /**
         * Changes the separator text for all separator elements to the next value that's
         * set in the {@link SearchableSelectView#separatorTextOptions}. Triggers a
         * "separatorChanged" event that passes on the new separator value.
         */
        changeSeparator: function(){
          try {
            var view = this;
            if (
              !view.separatorTextOptions ||
              !view.separatorTextOptions.length ||
              !view.separatorText
            ){
              return
            }
            // Get the next separator text option
            var currentIndex = view.separatorTextOptions.indexOf(view.separatorText),
                nextIndex = currentIndex + 1;
            if (currentIndex === -1 || !view.separatorTextOptions[nextIndex] ){
              nextIndex = 0
            }
            // Update the current separator text on the view
            view.separatorText = view.separatorTextOptions[nextIndex]
            // Change the separator text for all of the separators in the view with an
            // animation
            var separatorEls = view.$el.find("." + view.separatorClass)
            separatorEls.transition({
              animation: 'pulse',
              displayType: 'inline-block',
              duration: '250ms',
              onComplete: function(){
                $(this).text(view.separatorText)
              }
            });
            // Trigger an event for parent views
            view.trigger("separatorChanged", view.separatorText);
          }
          catch (error) {
            console.log(
              'There was an error switching the separator text in a SearchableSelectView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * updateMenu - Re-render the menu of options. Useful after changing
         * the options that are set on the view.
         */
        updateMenu: function(){
          try {
            var menu = $(this.template(this).trim()).find(".menu")[0].innerHTML;
            this.$el.find(".menu").html(menu);
          } catch (e) {
            console.log("Failed to update a searchable select menu, error message: " + e);
          }
        },

        /**
         * postRender - Updates to the view once the dropdown UI has loaded
         */
        postRender: function(){
          try {

            var view = this;
            view.trigger("postRender");

            // Add tool tips for the description
            this.$el.find(".item").each(function(){
              view.addTooltip(this)
            });

            // Show an error message if the pre-selected options are not in the
            // list of available options (only if user additions are not allowed)
            if(!view.allowAdditions){
              if(view.selected && view.selected.length){
                var invalidOptions = [];
                view.selected.forEach(function(item){
                  if(!view.isValidOption(item)){
                    invalidOptions.push(item)
                  }
                });
                if(invalidOptions.length){
                  var optionsString = "\"" + invalidOptions.join(", ") + "\"";
                  var phrase = (invalidOptions.length === 1) ? "is not a valid option" : "are not valid options";
                  var ending = ". Please change selection."
                  var message = optionsString + " " + phrase + ending;
                  view.showMessage(message, "error", true);
                }
              }
            }

            // Set the selected values in the dropdown
            this.$selectUI.dropdown('set exactly', view.selected);
            this.$selectUI.dropdown('save defaults');
            this.enable();
            this.hideLoading();

            // Make sub-menus if the option is configured in this view
            if(this.submenuStyle === "popout"){
              this.convertToPopout();
            }
            else if (this.submenuStyle === "accordion"){
              this.convertToAccordion();
            }

            // Convert interactive submenus to lists and hide empty categories
            // when the user is searching for a term
            if(
              ["popout", "accordion"].includes(view.submenuStyle) ||
              view.hideEmptyCategoriesOnSearch
            ){
              this.$selectUI.find("input").on("keyup blur", function(e){

                inputVal = e.target.value;

                // When the input is NOT empty
                if(inputVal !== ""){
                  // For interactive type submenus where items are sometimes
                  // hidden, show all the matching items when a user is searching
                  if(["popout", "accordion"].includes(view.submenuStyle)){
                    view.convertToList();
                  }
                  if(view.hideEmptyCategoriesOnSearch){
                    view.hideEmptyCategories();
                  }

                // When the input is EMPTY
                } else {
                  // Convert back to sub-menus if the option is configured in this view
                  if(view.submenuStyle === "popout"){
                    view.convertToPopout();
                  }
                  else if (view.submenuStyle === "accordion"){
                    view.convertToAccordion();
                  }
                  // Show all the category titles again, in cases some where hidden
                  if(view.hideEmptyCategoriesOnSearch){
                    view.showAllCategories();
                  }
                }
              });
            }

            // Trigger an event when the user focuses in searchable inputs
            var inputEl = this.$el.find("input.search")
            if(inputEl){
              inputEl.off("focus");
              inputEl.on("focus", function(event){
                view.trigger("inputFocus", event)
              })
            }

          } catch (e) {
            console.log("The searchable select post-render function failed, error message: " + e);
          }
        },

        /**
         * isValidOption - Checks if a value is one of the values given in view.options
         *
         * @param  {string} value The value to check
         * @return {boolean}      returns true if the value is one of the values given in
         * view.options
         */
        isValidOption: function(value){

          try {
            var view = this;
            var options = view.options;

            // If there are no options set on the view, assume the value is invalid
            if(!options || options.length === 0){
              return false
            }

            // If the list of options doesn't have category headings, put it in the
            // same format as options that do have headings.
            if (Array.isArray(options)) { options = { "" : options } };

            // Reduce the options object to just an Array of value and label strings
            var validValues = _(options)
              .chain()
              .values()
              .flatten()
              .map(function(item){
                var items = [];
                if(item.value !== undefined ){ items.push(item.value) }
                if(item.label !== undefined ){ items.push(item.label) }
                return items
              })
              .flatten()
              .value();

            return validValues.includes(value);
          } catch (e) {
            console.log("Failed to check if an option is valid in a Searchable Select View, error message: " + e);
          }

        },

        /**
         * addTooltip - Add a tooltip to a given element using the description in the
         * options object that's set on the view.
         *
         * @param  {HTMLElement} element The HTML element a tooltip should be added
         * @param  {string} position how to position the tooltip - top | bottom | left |
         * right
         * @return {jQuery} The element with a tooltip wrapped by jQuery
         */
        addTooltip: function(element, position = "bottom"){

          try {
            if(!element){
              return
            }

            // Find the description in the options object, using the data-value
            // attribute set in the template. The data-value attribute is either
            // the label, or the value, depending on if a value is provided.
            var valueOrLabel = $(element).data("value");
            if(typeof valueOrLabel === "undefined"){
              return
            }
            if(typeof valueOrLabel === "boolean"){
              valueOrLabel = valueOrLabel.toString()
            }
            var opt = _.chain(this.options)
                            .values()
                            .flatten()
                            .find(function(option){
                              return option.label == valueOrLabel || option.value == valueOrLabel
                            })
                            .value()

            if(!opt){
              return
            }
            if(!opt.description){
              return
            }

            $(element).tooltip({
              title: opt.description,
              placement: position,
              container: "body",
              delay: {
                show: 900,
                hide: 50
              }
            })
            .on("show.bs.popover",
              function(){
                var $el = $(this);
                // Allow time for the popup to be added to the DOM
                setTimeout(function () {
                  // Then add a special class to identify
                  // these popups if they need to be removed.
                  $el.data('tooltip').$tip.addClass("search-select-tooltip")
                }, 10);
            });

            return $(element)
          } catch (e) {
            console.log("Failed to add tooltip in a searchable select view, error message: " + e);
          }

        },

        /**
         * convertToPopout - Re-arrange the HTML to display category contents
         * as sub-menus that popout to the left or right of category titles
         */
        convertToPopout: function(){
          try {
            if(!this.$selectUI){
              return
            }
            if(this.currentSubmenuMode === "popout"){
              return
            }
            this.currentSubmenuMode = "popout";
            this.$selectUI.addClass("popout-mode");
            var $headers = this.$selectUI.find(".header");
            if(!$headers || $headers.length === 0){
              return
            }
            $headers.each(function(i){
              var $itemGroup = $().add($(this).nextUntil(".header"));
              var $itemAndHeaderGroup = $(this).add($(this).nextUntil(".header"));
              var $icon = $(this).next().find(".icon");
              if($icon && $icon.length > 0){
                var $headerIcon = $icon
                  .clone()
                  .addClass("popout-mode-icon")
                  .css({
                    "opacity": "0.9",
                    "margin-right" : "1rem"
                  });
                $(this).prepend($headerIcon[0])
              }
              $itemAndHeaderGroup.wrapAll("<div class='item popout-mode'/>");
              $itemGroup.wrapAll("<div class='menu popout-mode'/>");
              $(this).append("<i class='popout-mode-icon dropdown icon icon-on-right icon-chevron-right'></i>")
            });
          } catch (e) {
            console.log("Failed to convert a Searchable Select interface to sub-menu mode, error message: " + e);
          }
        },

        /**
         * convertToList - Re-arrange HTML to display the full list of options
         * in one static menu
         */
        convertToList: function(){
          try {
            if(!this.$selectUI){
              return
            }
            if(this.currentSubmenuMode === "list"){
              return
            }
            this.currentSubmenuMode = "list";
            this.$selectUI.find(".popout-mode > *").unwrap();
            this.$selectUI.find(".accordion-mode > *").unwrap();
            this.$selectUI.find(".popout-mode-icon").remove();
            this.$selectUI.find(".accordion-mode-icon").remove();
            this.$selectUI.removeClass("popout-mode accordion-mode");
          } catch (e) {
            console.log("Failed to convert a Searchable Select interface to list mode, error message: " + e);
          }
        },


        /**
         * convertToAccordion - Re-arrange the HTML to display category items
         * with expandable sections, similar to an accordion element.
         */
        convertToAccordion: function(){

          try {

            if(!this.$selectUI){
              return
            }
            if(this.currentSubmenuMode === "accordion"){
              return
            }
            this.currentSubmenuMode = "accordion";
            this.$selectUI.addClass("accordion-mode");
            var $headers = this.$selectUI.find(".header");
            if(!$headers || $headers.length === 0){
              return
            }

            // Id to match the header to the
            $headers.each(function(i){

              // Create an ID
              var randomNum = Math.floor((Math.random() * 100000) + 1),
                  headerText = $(this).text().replace(/\W/g, ''),
                  id = headerText + randomNum;

              var $itemGroup = $().add($(this).nextUntil(".header"));
              var $icon = $(this).next().find(".icon");
              if($icon && $icon.length > 0){
                var $headerIcon = $icon
                  .clone()
                  .addClass("accordion-mode-icon")
                  .css({
                    "opacity": "0.9",
                    "margin-right" : "1rem"
                  });
                $(this).prepend($headerIcon[0])
                $(this).wrap("<a data-toggle='collapse' data-target='#" +
                                id +
                                "' class='accordion-mode collapsed'/>" )
              }
              $itemGroup.wrapAll("<div id='" + id + "' class='accordion-mode collapse'/>");
              $(this).append("<i class='accordion-mode-icon dropdown icon icon-on-right icon-chevron-down'></i>");

            });
          } catch (e) {
            console.log("Failed to convert a Searchable Select interface to accordion mode, error message: " + e);
          }
        },

        /**
         * hideEmptyCategories - In the searchable select interface, hide
         * category headers that are empty, if any
         */
        hideEmptyCategories: function(){
          try {
            var $headers = this.$selectUI.find(".header")
            if(!$headers || $headers.length === 0){
              return
            }
            $headers.each(function(i){
              // this is the header
              var $itemGroup = $().add($(this).nextUntil(".header"));
              var $itemGroupFiltered = $().add($(this).nextUntil(".header", ".filtered"));
              // If all items are filtered
              if($itemGroup.length === $itemGroupFiltered.length){
                // Then also hide the header
                $(this).hide()
              } else {
                $(this).show()
              }
            });
          } catch (e) {
            console.log("Failed to hide empty categories in a dropdown, error message: " + e);
          }
        },

        /**
         * showAllCategories - In the searchable select interface, show all
         * category headers that were previously empty
         */
        showAllCategories: function(){
          try {
            this.$selectUI.find(".header:hidden").show();
          } catch (e) {
            console.log("Failed to show all categories in a dropdown, error message: " + e);
          }
        },

        /**
         * changeSelection - Set selected values in the interface
         *
         * @param  {string[]} newValues - An array of strings to select
         */
        changeSelection: function(newValues, silent = false) {
          try {
            if(
              !this.$selectUI ||
              typeof newValues === "undefined" ||
              !Array.isArray(newValues)
            ){
              return
            }
            var view = this;
            this.selected = newValues;
            if(silent === true){
              view.disable();
            }
            this.$selectUI.dropdown('set exactly', newValues);
            if(silent === true){
              view.enable();
            }
          } catch (e) {
            console.log("Failed to change the selected values in a searchable select field, error message: " + e);
          }
        },

        /**
         * enable - Remove the class the makes the select UI appear disabled
         */
        enable: function(){
          try {
            this.$el.find('.ui.dropdown').removeClass("disabled");
          } catch (e) {
            console.log("Failed to enable the searchable select field, error message: " + e);
          }
        },

        /**
         * disable - Add the class the makes the select UI appear disabled
         */
        disable: function(){
          try {
            this.$el.find('.ui.dropdown').addClass("disabled");
          } catch (e) {
            console.log("Failed to enable the searchable select field, error message: " + e);
          }
        },

        /**
         * showMessage - Show an error, warning, or informational message, and highlight
         * the select interface in an appropriate colour.
         *
         * @param  {string} message The message to display. Use an empty string to only
         * highlight the select interface without showing any message text.
         * @param  {string} type one of "error", "warning", or "info"
         * @param  {boolean} removeOnChange set to true to remove the message as soon as
         * the user changes the selection
         *
         */
        showMessage: function(message, type = "info", removeOnChange = true){
          try {

            if(!this.$selectUI){
              console.warn("A select UI element wasn't found, can't display error.");
              return
            }

            var messageTypes = {
              error: {
                messageClass: "text-error",
                selectUIClass: "error"
              },
              warning: {
                messageClass: "text-warning",
                selectUIClass: "warning"
              },
              info: {
                messageClass: "text-info",
                selectUIClass: ""
              }
            };

            if(!messageTypes.hasOwnProperty(type)){
              console.log(type + "is not a message type for Select UI interfaces. Showing message as info type");
              type = "info"
            }

            this.removeMessages();
            this.$selectUI.addClass(messageTypes[type].selectUIClass);

            if(message && message.length && typeof message === "string"){
              this.message = $(
                "<p style='margin:0.2rem' class='" +
                messageTypes[type].messageClass +
                "'><small>" + message +
                "</small></p>"
              );
            }

            this.$el.append(this.message);

            if(removeOnChange){
              this.listenToOnce(this, "changeSelection", this.removeMessages);
            }

          } catch (e) {
            console.log("Failed to show an error state in a Searchable Select View, error message: " + e);
          }
        },


        /**
         * removeMessages - Remove all messages and classes set by the
         * showMessage function.
         */
        removeMessages: function(){
          try {
            if(!this.$selectUI){
              console.warn("A select UI element wasn't found, can't remove error.");
              return
            }

            this.$selectUI.removeClass("error warning");
            if(this.message){
              this.message.remove();
            }
          } catch (e) {
            console.log("Failed to hide an error state in a Searchable Select View, error message: " + e);
          }
        },

        /**
         * showLoading - Indicate that dropdown options are loading by showing
         * a spinner in the select interface
         */
        showLoading: function(){
          try {
            this.$el.find('.ui.dropdown').addClass("loading");
          } catch (e) {
            console.log("Failed to show a loading state in a Searchable Select View, error message: " + e);
          }
        },

        /**
         * hideLoading - Remove the loading spinner set by the showLoading
         */
        hideLoading: function(){
          try {
            this.$el.find('.ui.dropdown').removeClass("loading");
          } catch (e) {
            console.log("Failed to remove a loading state in a Searchable Select View, error message: " + e);
          }
        },

      });
  });