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

"use strict";

define([
  "jquery",
  "underscore",
  "backbone",
  "semantic",
  "views/searchSelect/SeparatorView",
  "views/searchSelect/SearchSelectOptionsView",
  "models/searchSelect/SearchSelect",
], ($, _, Backbone, Semantic, SeparatorView, OptionsView, SearchSelect) => {
  // The base class for the search select view
  const BASE_CLASS = "search-select";

  // Class names that we use in the view, including those from the dropdown
  // module
  const CLASS_NAMES = {
    inputLabel: [`${BASE_CLASS}-label`, "subtle"],
    popout: "popout-mode",
    accordion: "accordion-mode",
    chevronDown: "icon-chevron-down",
    chevronRight: "icon-chevron-right",
    dropdownIconRight: [
      Semantic.CLASS_NAMES.dropdown.base,
      "icon",
      "icon-on-right",
    ],
    accordionIcon: "accordion-mode-icon",
    popoutIcon: "popout-mode-icon",
    buttonStyle: Semantic.CLASS_NAMES.button.base,
    labeled: Semantic.CLASS_NAMES.button.labeled,
    compact: "compact",
  };

  // The placeholder element needs both to work properly
  CLASS_NAMES.placeholder = [
    Semantic.CLASS_NAMES.dropdown.placeholder,
    Semantic.CLASS_NAMES.dropdown.text,
  ];

  // Classes to use for different types of messages
  const MESSAGE_TYPES = {
    error: {
      messageClass: "text-error",
      selectUIClass: "error",
    },
    warning: {
      messageClass: "text-warning",
      selectUIClass: "warning",
    },
    info: {
      messageClass: "text-info",
      selectUIClass: "",
    },
  };

  // The delimiter used to separate values in the dropdown
  const DELIMITER = ";";

  /**
   * @class SearchSelectView
   * @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
   * @augments Backbone.View
   * @class
   * @since 2.14.0
   * @screenshot views/searchSelect/SearchSelectView.png
   */
  const SearchSelectView = Backbone.View.extend(
    /** @lends SearchSelectView.prototype */
    {
      /** @inheritdoc */
      type: "SearchSelect",

      /** @inheritdoc */
      className: BASE_CLASS,

      /**
       * The constructor function for the model that this view uses. Must be a
       * SearchSelect or an extension of it.
       * @type {Backbone.Model}
       * @since 2.31.0
       */
      ModelType: SearchSelect,

      /**
       * Options and selected values for the search select interface show a
       * tooltip with the description of the option when the user hovers over
       * the option. This object is passed to the Formantic UI popup module to
       * configure the tooltip. Set to false to disable tooltips.
       * @see https://fomantic-ui.com/modules/popup.html#/settings
       * @type {object|boolean}
       * @since 2.31.0
       */
      tooltipSettings: {
        position: "top left",
        variation: `${Semantic.CLASS_NAMES.variations.inverted} ${Semantic.CLASS_NAMES.variations.mini}`,
        delay: {
          show: 450,
          hide: 10,
        },
        exclusive: true,
      },

      /** @inheritdoc */
      initialize(opts) {
        const options = opts || {};
        // Set options on the view and create the model
        const { modelAttrs, viewAttrs } = this.splitModelViewOptions(options);
        if (!options.model) this.createModel(modelAttrs);
        Object.assign(this, viewAttrs);
      },

      /**
       * Split the options passed to the view into model and view attributes.
       * @param {object} options The options passed to the view
       * @returns {object} An object with two keys: modelAttrs and viewAttrs
       * @since 2.31.0
       */
      splitModelViewOptions(options) {
        const modelAttrNames = Object.keys(this.ModelType.prototype.defaults());
        const modelAttrs = _.pick(options, modelAttrNames);
        const viewAttrs = _.omit(options, modelAttrNames);
        return { modelAttrs, viewAttrs };
      },

      /**
       * Create a new SearchSelect model and set it on the view. If a model
       * already exists, it will be destroyed.
       * @param {object} options The options to pass to the model
       * @since 2.31.0
       */
      createModel(options) {
        const modelAttrs = options || {};

        if (this.model) {
          this.stopListening(this.model);
          this.model.destroy();
        }

        // Selected values can be part of other models
        if (modelAttrs.selected) {
          modelAttrs.selected = [...modelAttrs.selected];
        }
        this.model = new this.ModelType(modelAttrs);
      },

      /** @inheritdoc */
      render() {
        this.el.innerHTML = "";

        this.labelEl = this.createLabel();
        this.selectContainerEl = this.createSelectContainer();
        this.inputEl = this.createInput();
        this.iconEl = this.createIcon();
        this.placeholderEl = this.createPlaceholder();
        this.menu = this.createMenu();
        this.menuEl = this.menu.el;

        this.el.append(this.labelEl || "", this.selectContainerEl || "");
        this.selectContainerEl.append(
          this.inputEl || "",
          this.iconEl || "",
          this.placeholderEl || "",
          this.menuEl || "",
        );

        this.$selectUI = $(this.selectContainerEl);
        this.inactivate();
        this.showLoading();
        this.renderSelectUI();
        this.showSelected(true);
        this.listenToModel();
        this.listenToSelectUI();
        this.checkForInvalidSelections();
        this.enable();
        this.hideLoading();

        return this;
      },

      /** Initialize the dropdown interface */
      renderSelectUI() {
        const view = this;

        // Destroy any previous dropdowns
        if (typeof this.$selectUI.dropdown === "function") {
          this.$selectUI.dropdown("destroy");
        }

        // Initialize the dropdown interface For explanations of settings, see:
        // https://semantic-ui.com/modules/dropdown.html#/settings
        this.$selectUI = this.$selectUI.dropdown({
          delimiter: DELIMITER,
          apiSettings: this.model.get("apiSettings"),
          fullTextSearch: true,
          duration: 90,
          forceSelection: false,
          ignoreDiacritics: true,
          clearable: view.model.get("clearable"),
          allowAdditions: view.model.get("allowAdditions"),
          hideAdditions: false,
          allowReselection: true,
          onChange() {
            if (view.enabled) {
              // Update the model with the selected values
              const selected = view.$selectUI.dropdown("get values");
              view.model.setSelected(selected);
            }
            // ensure the DOM is updated before modifying the elements
            requestAnimationFrame(() => {
              view.addTooltipsToSelectionEls();
              view.addSeparators();
              view.addClickToTexts();
            });
          },
        });
        view.$selectUI.data("view", view);
      },

      /**
       * Because we've modified the text elements to be hoverable to show the
       * tooltip, we needed to move them to a higher z-index which blocks the
       * click action on the dropdown input element. This function ensures that
       * the dropdown is shown when any part of the input is clicked, including
       * the selected text elements in a single-select dropdown.
       * @since 2.31.0
       */
      addClickToTexts() {
        const showMenu = () => {
          this.$selectUI.dropdown("show");
        };
        const texts = this.getTexts();
        if (!texts?.length) return;
        texts.forEach((text) => text.removeEventListener("click", showMenu));
        const text = this.getTexts()?.[0];
        if (!text) return;
        text.addEventListener("click", showMenu);
      },

      /**
       * Update the dropdown interface with the selected values from the model
       * @param {boolean} [silent] Set to true to prevent the dropdown from
       * triggering a change event (an infinite loop can occur if this is not set,
       * as the dropdown will trigger a change event, which will update the model).
       * @since 2.31.0
       */
      showSelected(silent = false) {
        const enabledBefore = this.enabled;
        this.inactivate();

        const selected = this.model.get("selected");
        // Add one at a time so that labels appear in the correct order
        selected.forEach((s) => {
          this.$selectUI.dropdown("set selected", s);
        });

        if (!silent) {
          // trigger once to ensure the model is updated
          this.$selectUI.dropdown("set selected", selected);
        }

        if (enabledBefore) {
          this.enable();
        }
      },

      /** @returns {HTMLElement[]} The selected label elements in a multi-select dropdown */
      getLabels() {
        return this.$selectUI.find(Semantic.DROPDOWN_SELECTORS.label).toArray();
      },

      /** @returns {HTMLElement[]} The selected text element in a single-select dropdown */
      getTexts() {
        // default text is the placeholder
        return this.$selectUI
          .find(
            `${Semantic.DROPDOWN_SELECTORS.text}:not(.${Semantic.CLASS_NAMES.dropdown.placeholder})`,
          )
          .toArray();
      },

      /** Add tooltips to the selected labels or text elements */
      addTooltipsToSelectionEls() {
        const els = this.getLabels().concat(this.getTexts());
        els.forEach((el) => this.addTooltip(el));
      },

      /** Remove all messages from the view */
      removeAllSeparators() {
        this.separators?.forEach((sep) => sep.remove());
      },

      /** Add separators between labels in the dropdown if required */
      addSeparators() {
        this.removeAllSeparators();
        const labels = this.getLabels();

        labels.forEach((label) => {
          const value = $(label).data("value");
          if (this.model.separatorRequired(value)) {
            this.addSeparator(label);
          }
        });
      },

      /**
       * Add a separator before the given label element
       * @param {HTMLElement} el The label element to add a separator before
       */
      addSeparator(el) {
        if (!this.separators) this.separators = [];
        const separator = this.createSeparator();
        if (separator) {
          // Attach the separator to the label so that we can easily remove it
          // when the label is removed.
          $(el).data("separator", separator);
          // Add it before the label element.
          separator.$el.insertBefore($(el));
          this.separators.push(separator);
        }
      },

      /**
       * Update the view when certain model attributes change
       * @since 2.31.0
       */
      listenToModel() {
        this.stopListening(this.model);
        const view = this;
        this.listenTo(this.model.get("options"), "add remove reset", () => {
          // If pre-selected values were not in the options previously, then
          // they would have been removed and/or shown as invalid selections.
          // Re-add & check selections, after a timeout to ensure the DOM is
          // updated.
          requestAnimationFrame(() => {
            view.showSelected(true);
            view.checkForInvalidSelections();
            // save defaults
            view.$selectUI.dropdown("save defaults");
            view.$selectUI.dropdown("refresh");
          });
        });
        this.listenTo(this.model, "change:submenuStyle", this.updateMenuMode);
        if (this.model.get("hideEmptyCategoriesOnSearch")) {
          this.listenTo(
            this.model,
            "change:searchTerm",
            (_model, searchTerm) => {
              if (searchTerm) {
                view.menu.hideEmptyCategories();
              } else {
                view.menu.showAllCategories();
              }
            },
          );
        }
      },

      /**
       * Listen to events from the select UI interface and update the model
       * @since 2.31.0
       */
      listenToSelectUI() {
        // Save the active search term in the model
        this.$selectUI.find("input").off("keyup blur focus");
        this.$selectUI.find("input").on("keyup blur focus", (e) => {
          this.model.set("lastInteraction", e.type);
          this.model.set("searchTerm", e.target.value);
        });
      },

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

      /**
       * Create the HTML for a separator element to insert between two labels.
       * The view.separatorClass is added to the separator element.
       * @returns {JQuery} Returns the separator as a jQuery element
       * @since 2.15.0
       */
      createSeparator() {
        const separator = new SeparatorView({
          model: this.model,
          // hovering over one separator should highlight them all
          mouseEnterCallback: () =>
            this.separators.forEach((sep) => sep.highlight()),
          mouseOutCallback: () =>
            this.separators.forEach((sep) => sep.unhighlight()),
        });
        separator.render();
        this.separators = this.separators || [];
        this.separators.push(separator);
        return separator;
      },

      /**
       * Show an error message if the user has selected an invalid value
       * @since 2.31.0
       */
      checkForInvalidSelections() {
        const view = this;
        const invalidSelections = view.model.hasInvalidSelections();
        if (invalidSelections) {
          view.showInvalidSelectionError(invalidSelections);
        } else {
          view.removeMessages();
        }
      },

      /**
       * Create the label for the search select interface
       * @returns {HTMLElement|null} The label element, or null if no label is
       * specified.
       * @since 2.31.0
       */
      createLabel() {
        const inputLabel = this.model.get("inputLabel");
        if (!inputLabel) return null;
        const inputEl = document.createElement("label");
        inputEl.classList.add(...CLASS_NAMES.inputLabel);
        inputEl.textContent = inputLabel;
        return inputEl;
      },

      /**
       * Create the container for the select interface
       * @returns {HTMLElement} The select container element
       * @since 2.31.0
       */
      createSelectContainer() {
        const dropdownEl = document.createElement("div");
        let classesToAdd = [
          Semantic.CLASS_NAMES.base,
          Semantic.CLASS_NAMES.dropdown.dropdown,
          Semantic.CLASS_NAMES.dropdown.search,
          this.model.get("allowMulti")
            ? Semantic.CLASS_NAMES.dropdown.multiple
            : null,
          this.model.get("fluid")
            ? Semantic.CLASS_NAMES.variations.fluid
            : null,
          this.model.get("buttonStyle") ? CLASS_NAMES.buttonStyle : null,
          this.model.get("icon") ? Semantic.CLASS_NAMES.dropdown.icon : null,
          this.model.get("icon") ? CLASS_NAMES.labeled : null,
          this.model.get("icon")
            ? null
            : Semantic.CLASS_NAMES.dropdown.selection,
        ];

        classesToAdd = classesToAdd.filter(Boolean);
        classesToAdd = classesToAdd.map((c) => c.split(" ")).flat();

        dropdownEl.classList.add(...classesToAdd);

        if (this.model.get("compact")) {
          this.el.classList.add(CLASS_NAMES.compact);
        }

        return dropdownEl;
      },

      /**
       * Create the hidden input element that will store the selected values
       * @returns {HTMLElement} The input element
       * @since 2.31.0
       */
      createInput() {
        const inputEl = document.createElement("input");
        inputEl.name = `search-select-${this.cid}`;
        inputEl.type = "hidden";
        return inputEl;
      },

      /**
       * Create the icon element for the select interface
       * @returns {HTMLElement} The icon element
       * @since 2.31.0
       */
      createIcon() {
        let icon = this.model.get("icon");
        icon = icon ? `icon-${icon}` : "dropdown";
        const iconEl = document.createElement("i");
        iconEl.classList.add("icon", icon);
        return iconEl;
      },

      /**
       * Create the placeholder element for the select interface
       * @returns {HTMLElement} The placeholder element
       * @since 2.31.0
       */
      createPlaceholder() {
        const placeholder = this.model.get("placeholderText");
        const placeholderEl = document.createElement("span");
        placeholderEl.classList.add(...CLASS_NAMES.placeholder);
        placeholderEl.textContent = placeholder;
        return placeholderEl;
      },

      /**
       * Create the dropdown menu for the select interface
       * @returns {OptionsView} The dropdown menu
       * @since 2.31.0
       */
      createMenu() {
        const menu = new OptionsView({
          collection: this.model.get("options"),
          tooltipOptions: this.tooltipSettings,
          mode: this.model.get("submenuStyle"),
        });
        menu.render();
        return menu;
      },

      /**
       * Convert the submenu style to the style set in the model
       * @param {boolean} [force] Set to true to force the view to update
       * @since 2.31.0
       */
      updateMenuMode(force = false) {
        const mode = this.model.get("submenuStyle");
        this.menu.updateMode(mode, force);
      },

      /**
       * Show a message indicated that some of the selected values are not valid
       * choices.
       * @param {string[]} opts The values that are not valid choices
       */
      showInvalidSelectionError(opts) {
        let msg = "";
        if (opts?.length) {
          msg += opts.join(", ");
          if (opts.length > 1) {
            msg += " are not valid options. ";
          } else {
            msg += " is not a valid option. ";
          }
        }

        msg += "Please select from the available options.";

        this.showMessage(msg, "error", true);
      },

      /**
       * Get the option model given a dropdown text or label element. Label
       * elements are used for multi-select dropdowns, the value is a data
       * attribute. Text elements are for single-select dropdowns, so the value
       * is the current selection.
       * @param {HTMLElement} el The text or label element
       * @returns {SearchSelectOption|null} The option model or null if not
       * found
       * @since 2.31.0
       */
      optionFromSelectionEl(el) {
        if (!el) return null;
        const value =
          $(el).data("value") || this.$selectUI.dropdown("get value");
        if (!value && value !== 0) return null;
        return this.model.get("options").getOptionByLabelOrValue(value);
      },

      /**
       * Create HTML for a tooltip for a given option. By default this method
       * returns the description of the option, but can be overridden in
       * extended SearchSelectViews to return a custom HTML string based on
       * the option.
       * @param {SearchSelectOption} option The option to create a tooltip for
       * @param {JQuery} _$element The element to attach the tooltip to
       * @returns {string|null} An HTML string to use for the content of the
       * tooltip.
       * @since 2.31.0
       */
      tooltipHTML(option, _$element) {
        return option?.get("description") || null;
      },

      /**
       * 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  {object} settings Additional settings to override those set in
       * view.tooltipSettings.
       */
      addTooltip(element, settings) {
        // Tooltips are disabled when tooltipSettings is false
        const viewSettings = this.tooltipSettings;
        if (!viewSettings) return;

        const $element = $(element);
        const opt = this.optionFromSelectionEl(element);
        const html = this.tooltipHTML(opt, $element);
        if (!html) return;

        $element.popup({
          html,
          ...viewSettings,
          ...settings,
        });
      },

      /** Visually indicate that the select interface is enabled */
      enable() {
        this.enabled = true;
        this.$selectUI.removeClass(Semantic.CLASS_NAMES.dropdown.disabled);
      },

      /** Visually indicate that the select interface is inactive */
      inactivate() {
        this.enabled = false;
        this.$selectUI.addClass(Semantic.CLASS_NAMES.dropdown.disabled);
      },

      /**
       * 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(message, type = "info", removeOnChange = true) {
        if (!this.$selectUI) return;

        let level = typeof type === "string" ? type.toLowerCase() : "info";
        if (!Object.keys(MESSAGE_TYPES).includes(level)) {
          level = "info";
        }

        this.removeMessages();
        this.$selectUI.addClass(MESSAGE_TYPES[level].selectUIClass);

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

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

        if (removeOnChange) {
          this.listenToOnce(this.model, "change:selected", this.removeMessages);
        }
      },

      /** Remove all messages and classes set by the showMessage function */
      removeMessages() {
        if (!this.$selectUI) {
          return;
        }
        const classes = Object.values(MESSAGE_TYPES).map(
          (type) => type.selectUIClass,
        );
        this.$selectUI.removeClass(classes.join(" "));
        if (this.message) this.message.remove();
      },

      /** Visually indicate that dropdown options are loading */
      showLoading() {
        this.$selectUI.addClass(Semantic.CLASS_NAMES.dropdown.loading);
      },

      /** Remove the loading spinner set by the showLoading */
      hideLoading() {
        this.$selectUI.removeClass(Semantic.CLASS_NAMES.dropdown.loading);
      },

      /**
       * Remove the selected values from the dropdown interface
       * and from the model.
       * @param {boolean} [silent] Set to true to prevent the dropdown and the
       * model from triggering change events
       * @param {boolean} [closeMenu] Set to true to close the dropdown menu
       * @since 2.31.0
       */
      reset(silent = false, closeMenu = true) {
        this.$selectUI.dropdown("clear", silent);
        this.model.set("selected", [], { silent });
        if (closeMenu) this.$selectUI.dropdown("hide");
      },
    },
  );

  return SearchSelectView;
});