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

define([
    "jquery",
    "underscore",
    "backbone",
    "views/searchSelect/SearchableSelectView",
    "collections/queryFields/QueryFields"
  ],
  function($, _, Backbone, SearchableSelect, QueryFields) {

    /**
     * @class QueryFieldSelectView
     * @classdesc A select interface that allows the user to search for and
     * select metadata field(s).
     * @classcategory Views/SearchSelect
     * @extends SearchableSelect
     * @constructor
     * @since 2.14.0
     * @screenshot views/searchSelect/QueryFieldSelectView.png
     */
    var QueryFieldSelectView = SearchableSelect.extend(
      /** @lends QueryFieldSelectView.prototype */
      {
        /**
         * The type of View this is
         * @type {string}
         */
        type: "QueryFieldSelect",

        /**
         * className - the class names for this view element
         *
         * @type {string}
         */
        className: SearchableSelect.prototype.className + " query-field-select",

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

        /**
         * Label for the input element
         * @type {string}
         */
        inputLabel: "Select one or more metadata fields to query",

        /**
         * @see SearchableSelectView#submenuStyle
         * @default "accordion"
         */
        submenuStyle: "accordion",

        /**
         * A list of query fields names to exclude from the list of options
         * @type {string[]}
         */
        excludeFields: [],

        /**
         * An additional field object contains the properties an additional query field to
         * add that are required to render it correctly. An additional query field is one
         * that does not actually exist in the query service index.
         *
         * @typedef {Object} AdditionalField
         *
         * @property {string} name - A unique ID to represent this field. It must not
         * match the name of any other query fields.
         * @property {string[]} fields - The list of real query fields that this
         * abstracted field will represent. It must exactly match the names of the query
         * fields that actually exist.
         * @property {string} label - A user-facing label to display.
         * @property {string} description - A description for this field.
         * @property {string} category - The name of the category under which to place
         * this field. It must match one of the category names for an existing query
         * field.
         *
         * @since 2.15.0
         */

        /**
         * A list of additional fields which are not retrieved from the query service
         * index, but which should be added to the list of options. This can be used to
         * add abstracted fields which are a combination of multiple query fields, or to
         * add a duplicate field that has a different label.
         *
         * @type {AdditionalField[]}
         * @since 2.15.0
         */
        addFields: [],

        /**
         * A list of query fields names to display at the top of the menu, above
         * all other category headers
         * @type {string[]}
         */
        commonFields: ["text", "documents-special-field"],

        /**
         * The names of categories that should have items sorted alphabetically. Names
         * must exactly match those in the
         * {@link QueryField#categoriesMap Query Field model}
         * @type {string[]}
         * @since 2.15.0
         */
        categoriesToAlphabetize: ["General"],

        /**
         * Whether or not to exclude fields which are not searchable. Set to
         * false to keep query fields that are not searchable in the returned list
         * @type {boolean}
         */
        excludeNonSearchable: true,

        /**
         * Creates a new QueryFieldSelectView
         * @param {Object} options - A literal object with options to pass to the view
         */
        initialize: function(options){
          try {
            // Ensure the query fields are cached
            if ( typeof MetacatUI.queryFields === "undefined" ) {
              MetacatUI.queryFields = new QueryFields();
              MetacatUI.queryFields.fetch();
            }
            SearchableSelect.prototype.initialize.call(this, options);
          } catch (e) {
            console.log("Failed to initialize a Query Field Select View, error message: " + e);
          }
        },

        /**
         * postRender - Updates the view once the dropdown UI has loaded. Processes the
         * QueryFields given the options passed to this view, then updates the menu and
         * selection. Processing the fields takes some time, which is why we allow the
         * view to render before starting that process. This prevents slowing down the
         * rendering of parent views.
         */
        postRender : function(){

          try {
            var view = this;
            _.defer(function(){
              view.processFields();
              view.updateMenu();
              // With the new menu in place, show the pre-selected values. Silent is set
              // to true so that it doesn't trigger an update of the model. Defer to make
              // sure the menu elements are attached.
              _.defer(function() {
                view.changeSelection(view.selected, true);
              });
              SearchableSelect.prototype.postRender.call(view);
            })
          }
          catch (error) {
            console.log( 'Post-render failed in a QueryFieldSelectView.' +
              ' Error details: ' + error );
          }
        },

        /**
         * Retrieves the queryFields collection if not already fetched, then organizes the
         * fields based on the options passed to this view.
         * @since 2.17.0
         */
        processFields : function(){
          try {
            var view = this;

            // Ensure the query fields are cached for the Query Field Select
            // View and the Query Rule View
            if (
              typeof MetacatUI.queryFields === "undefined" ||
              MetacatUI.queryFields.length === 0
            ) {
              if (typeof MetacatUI.queryFields === "undefined") {
                MetacatUI.queryFields = new QueryFields();
              }
              this.listenToOnce(MetacatUI.queryFields, "sync", this.processFields)
              MetacatUI.queryFields.fetch();
              return
            }

            // Convert the queryFields collection to an object formatted for the
            // SearchableSelect view.
            var fieldsJSON = MetacatUI.queryFields.toJSON();

            // Process & add additional fields set on this view (these are fields not
            // retrieved from the query service API)
            if (this.addFields && Array.isArray(this.addFields)) {
              // For each added field, find the icon and category order from the already
              // existing fields with the same category.
              this.addFields = _.map(this.addFields, function (field) {
                if (field.category) {
                  var categoryInfo = _.findWhere(fieldsJSON, { category: field.category });
                  ["icon", "categoryOrder"].forEach(function (prop) {
                    if (!field[prop]) {
                      field[prop] = categoryInfo[prop]
                    }
                  })
                }
                return field
              }, this);
              // Add the additional fields to the array of fields fetched from the
              // query service API
              fieldsJSON = fieldsJSON.concat(this.addFields);
            }

            // Move common fields to the top of the menu, outside of any
            // category headers, so that they are easy to find
            if (this.commonFields && Array.isArray(this.commonFields)) {
              this.commonFields.forEach(function (commonFieldName) {
                var i = _.findIndex(fieldsJSON, { name: commonFieldName });
                if (i > 0) {
                  // If the category name is an empty string, no header will
                  // be created in the menu
                  fieldsJSON[i].category = ""
                  // The min categoryOrder in the QueryFields collection is 1
                  fieldsJSON[i].categoryOrder = 0
                  fieldsJSON[i].icon = "star"
                }
              });
            }

            // Filter out non-searchable fields (if option is true),
            // and fields that should be excluded
            var processedFields = _(fieldsJSON)
              .chain()
              .sortBy("categoryOrder")
              .filter(
                function (filter) {
                  if (this.excludeNonSearchable) {
                    if (["false", false].includes(filter.searchable)) {
                      return false
                    }
                  }
                  if (this.excludeFields && this.excludeFields.length) {
                    if (this.excludeFields.includes(filter.name)) {
                      return false
                    }
                  }
                  return true
                }, this
              )
              .map(view.fieldToOption)
              .groupBy("categoryOrder")
              .value();

            // Rename the grouped categories
            for (const [key, value] of Object.entries(processedFields)) {
              processedFields[value[0].category] = value;
              delete processedFields[key];
            }

            // Sort items alphabetically for the specified categories
            if (this.categoriesToAlphabetize && this.categoriesToAlphabetize.length) {
              this.categoriesToAlphabetize.forEach(function (categoryName) {
                // Sort by category label
                processedFields[categoryName].sort(function (a, b) {
                  // Ignore upper and lowercase
                  var nameA = a.label.toUpperCase();
                  var nameB = b.label.toUpperCase();
                  if (nameA < nameB)
                    return -1;
                  if (nameA > nameB)
                    return 1;
                  return 0;
                });
              })
            }

            // Set the formatted fields on the view
            this.options = processedFields;
          }
          catch (error) {
            console.log( 'There was an error organizing the Fields in a QueryFieldSelectView' +
              ' Error details: ' + error );
          }
        },

        /**
         * fieldToOption - Converts an object that represents a QueryField model in the
         * format specified by the SearchableSelectView.options
         *
         * @param  {object} field An object with properties corresponding to a QueryField
         * model
         * @return {object}       An object in the format specified by
         * SearchableSelectView.options
         */
        fieldToOption: function(field) {
          return {
            label: field.label ? field.label : field.name,
            value: field.name,
            description: field.friendlyDescription ? field.friendlyDescription : field.description,
            icon: field.icon,
            category: field.category,
            categoryOrder: field.categoryOrder,
            type: field.type
          };
        },

        /**
         * addTooltip - Add a tooltip to a given element using the description in the
         * options object that's set on the view. This overwrites the prototype addTooltip
         * function so that we can use popovers with more details for query select fields.
         *
         * @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"){

          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();

          var label = opt.label,
              value = opt.value,
              type = opt.type,
              description = (opt.description ? opt.description : "");

          // For added fields, the value set on the options.value element is just a
          // unique identifier. The values that should be used to build a query are saved
          // in the addFields array set on this view.
          if(this.addFields && Array.isArray(this.addFields)){
            var specialField = _.findWhere(this.addFields, { name: valueOrLabel });
            if(specialField){
              value = specialField.fields;
              type = [];
              specialField.fields.forEach(function(fieldName){
                var realField = MetacatUI.queryFields.findWhere({
                  name: fieldName
                });
                if(realField){
                  type.push(realField.get("type"))
                } else {
                  type.push("special field")
                }
              }, this);
              type = type.join(", ");
            }
          }

          var contentEl = $(document.createElement("div")),
              titleEl = $("<div>" + label + "</div>"),
              valueEl = $("<code class='pull-right'>" + value + "</code>"),
              typeEl = $("<span class='muted pull-right'><b>Type: " + type + "</b></span>"),
              descriptionEl = $("<p>" + description + "</p>");

            titleEl.append(valueEl);
            contentEl.append(descriptionEl, typeEl)

          $(element).popover({
            title: titleEl,
            content: contentEl,
            html: true,
            trigger: "hover",
            placement: position,
            container: "body",
            delay: {
              show: 1100,
              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 some css rules, and a special class to identify
                // these popups if they need to be removed.
                $el.data('popover').$tip
                  .css({
                    "maxWidth": "400px",
                    "pointerEvents" : "none"
                  })
                  .addClass("search-select-tooltip");
              }, 10);
          });

          return $(element)

        },

        /**
         * 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;

            // First check if the value is one of the fields that's excluded.
            if(view.excludeFields.includes(value)){
              // If it is, then add it to the list of options
              var newField = MetacatUI.queryFields.findWhere({
                name: value
              });
              if(newField){
                newField = view.fieldToOption(newField.toJSON());
              }
              view.options[newField.category].push(newField);
              view.updateMenu();
              // Make sure the new menu is attached before updating the selections
              setTimeout(function () {
                // If the selected value has been removed, re-add it.
                if(!view.selected.includes(value)){
                  view.selected.push(value)
                }
                view.changeSelection(view.selected, silent = true);
              }, 25);
              return true
            } else {
              var isValid = SearchableSelect.prototype.isValidOption.call(view, value);
              return isValid
            }
          } catch (e) {
            console.log("Failed to check if option is valid in a Query Field Select View, error message: " + e);
          }

        },

      });
      return QueryFieldSelectView;
  });