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

define([
    "jquery",
    "underscore",
    "backbone",
    "semanticUItransition",
    "semanticUIdropdown",
    "text!templates/selectUI/searchableSelect.html",
  ],
  function($, _, Backbone, Transition, Dropdown, Template) {

    /**
     * @class SearchableSelect
     * @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.
     * @extends Backbone.View
     * @constructor
     */
    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, how to display the items
         * within each category? Select one of the following options:
         *  list: display the items in a traditional, non-interactive list below
         *        category titles
         *  popout: 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)
         *  accordion: 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.
         * @type {string} set to "list", "popout", or "accordion"
         */         
        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,
        
        /**        
         * The path to the semanticUI transition CSS (required for this view to work)
         * @type {string}        
         */         
        transitionCSS: "/components/semanticUI/transition.min.css",
        
        /**        
         * The path to the semanticUI dropdown CSS (required for this view to work)
         * @type {string}        
         */ 
        dropdownCSS: "/components/semanticUI/dropdown.min.css",
        
        /**        
         * The list of options that a user can select from in the dropdown menu.
         * For uncategorized 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.
         * @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: [],

        /**
         * 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 {
            
            var view = this;
            
            // Given a path, check whether a CSS file was already added to the
            // head, and add it if not. Prevents adding the CSS file multiple
            // times if the view is loaded more than once. The first time each
            // CSS path is added, we need to cache a record of the event. It
            // doesn't work to just search the document head for the file to
            // determine if the CSS has already been added, because each instance
            // of this view is initialized too quickly, before the previous
            // instance has had a chance to add the stylesheet element.
            const addCSS = function(path){
              if(!MetacatUI.loadedCSS){
                MetacatUI.loadedCSS = []
              }
              if(!MetacatUI.loadedCSS.includes(path)){
                MetacatUI.loadedCSS.push(path)
                const link = document.createElement("link");
                link.rel = "stylesheet";
                link.href = path;
                document.querySelector("head").appendChild(link);
              }
            }
            
            // Add the CSS required for semanticUI components
            addCSS(view.transitionCSS);
            addCSS(view.dropdownCSS);
            
            // 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 {SeachableSelect}  Returns the view
         */
        render: function() {
          
          try {
            
            var view = this;
            
            // The semantic UI dropdown module requires that the transition
            // module CSS is loaded. If a user tries to select a value before
            // this has a chance to load, semantic will throw an error.
            // Don't render until the required CSS is loaded.
            if(!this.ready){
              this.checkIfReady(this.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({
                fullTextSearch: true,
                duration: 90,
                clearable: view.clearable,
                allowAdditions: view.allowAdditions,
                hideAdditions: false,
                allowReselection: true,
                onLabelCreate: function(value, text){
                  // Add tooltips to the selected label elements
                  view.addTooltip.call(view, this, "top");
                  return this
                },
                onLabelRemove: function(){
                  // Ensure tooltips for labels are removed
                  $(".search-select-tooltip").remove();
                },
                onChange: function(value, text, $choice){
                  
                  // Add tooltips to the selected fields that are not labels
                  // (i.e. that are not in multi-select UIs).
                  var textEl = view.$selectUI.find(".text")
                  if(textEl){
                    if(text == textEl.html()){
                      view.addTooltip.call(view, textEl, "top");
                    }
                  }
                  
                  // 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 = value.split(",");
                    view.trigger('changeSelection', newValues);
                    view.selected = newValues;
                  }
                },
              });
            
            view.postRender();
            
            return this;

          } catch (e) {
            console.log("Error rendering the search select, error message: ", e);
          }
        },
        
        /**        
         * 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)).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();
                  }
                }
              });
            }
            
          } 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"),
                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 tooltips 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);
          }
        },

        /**        
         * checkIfReady - Check if the searchable select field is ready to use.
         * If the transition UI CSS file isn't loaded in time, search fields
         * might give error when selecting (or pre-selecting) values.
         *          
         * @param  {function} callback The function to call when the UI is ready
         */         
        checkIfReady: function(callback){
          
          var view = this;
          
          // prevent flash of unstyled content
          view.$el.css("display", "none");
          
          // Find the transition CSS in the head of the document.
          var transitionCSS = _.find(
            Array.from(document.styleSheets),
            function(ss){
              if(ss.href){
                return ss.href.includes(view.transitionCSS)
              }
            }
          );
          
          // What to do when the CSS isn't loaded yet
          const notReady = function(){
            view.ready = false;
            // check again in 15ms
            setTimeout(function () {
              view.checkIfReady(callback)
            }, 15);
          }
          
          const ready = function(){
            view.ready = true;
            callback.call(view);
            // prevent flash of unstyled content
            setTimeout(function () {
              view.$el.css("display", "block");
            }, 10);
          }
          
          if(view.ready){
            ready();
          }
          
          try {
            if ( transitionCSS.cssRules.length === 0 ) {
              notReady();
            } else {
              ready();
            }
          } catch (e) {
            notReady();
          }
          
        },
        
        /**        
         * 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 a messsage.
         * @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);
          }
        },
        
      });
  });