Source: src/js/views/maps/LayerItemView.js

"use strict";

define([
  "jquery",
  "underscore",
  "backbone",
  "models/maps/assets/MapAsset",
  "common/IconUtilities",
  "text!templates/maps/layer-item.html",
  // Sub-views
  "views/maps/LegendView",
], function (
  $,
  _,
  Backbone,
  MapAsset,
  IconUtilities,
  Template,
  // Sub-views
  Legend,
) {
  /**
   * @class LayerItemView
   * @classdesc One item in a Layer List: shows some basic information about the Map
   * Asset (Layer), including label and icon. Also has a button that changes the
   * visibility of the Layer of the map (by updating the 'visibility' attribute in the
   * MapAsset model). Clicking on the Layer Item opens the Layer Details panel (by
   * setting the 'selected' attribute to true in the Layer model.) Additionally, shows a
   * small preview of a legend for the data that's on the map.
   * @classcategory Views/Maps
   * @name LayerItemView
   * @extends Backbone.View
   * @screenshot views/maps/LayerItemView.png
   * @since 2.18.0
   * @constructs
   */
  var LayerItemView = Backbone.View.extend(
    /** @lends LayerItemView.prototype */ {
      /**
       * The type of View this is
       * @type {string}
       */
      type: "LayerItemView",

      /**
       * The HTML classes to use for this view's element
       * @type {string}
       */
      className: "layer-item",

      /**
       * The model that this view uses
       * @type {MapAsset}
       */
      model: undefined,

      /**
       * Whether the layer item is a under a category. Flat layer item and categorized
       * layer item are styled differently.
       * @type {boolean}
       */
      isCategorized: undefined,

      /**
       * The primary HTML template for this view
       * @type {Underscore.template}
       */
      template: _.template(Template),

      /**
       * Classes that are used to identify or create the HTML elements that comprise this
       * view.
       * @type {Object}
       * @property {string} label The element that contains the layer's name/label
       * @property {string} icon The span element that contains the SVG icon
       * @property {string} visibilityToggle The element that acts like a button to
       * switch the Layer's visibility on and off
       * @property {string} legendContainer The element that the legend preview will be
       * inserted into.
       * @property {string} selected The class that gets added to the view when the Layer
       * Item is selected
       * @property {string} shown The class that gets added to the view when the Layer
       * Item is visible
       * @property {string} badge The class to add to the badge element that is shown
       * when the layer has a notification message
       * @property {string} tooltip Class added to tooltips used in this view
       */
      classes: {
        label: "layer-item__label",
        icon: "layer-item__icon",
        visibilityToggle: "layer-item__visibility-toggle",
        legendContainer: "layer-item__legend-container",
        selected: "layer-item--selected",
        shown: "layer-item--shown",
        labelText: "layer-item__label-text",
        highlightedText: "layer-item__highlighted-text",
        categorized: "layer-item__categorized",
        legendAndSettings: "layer-item__legend-and-settings",
        badge: "map-view__badge",
        tooltip: "map-tooltip",
      },

      /**
       * The text to show in a tooltip when the MapAsset's status is set to 'error'. If
       * the model also has a 'statusMessage', that will be appended to the end of this
       * error message.
       * @type {string}
       */
      errorMessage: "There was a problem showing this layer.",

      /**
       * A function that gives the events this view will listen to and the associated
       * function to call.
       * @returns {Object} Returns an object with events in the format 'event selector':
       * 'function'
       */
      events: function () {
        try {
          var events = {};
          events["click ." + this.classes.legendAndSettings] = "toggleSelected";
          events["click"] = "toggleVisibility";
          return events;
        } catch (error) {
          console.log(
            "There was an error setting the events object in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Executed when a new LayerItemView is created
       * @param {Object} [options] - A literal object with options to pass to the view
       */
      initialize: function (options) {
        try {
          // Get all the options and apply them to this view
          if (typeof options == "object") {
            for (const [key, value] of Object.entries(options)) {
              this[key] = value;
            }
          }
        } catch (e) {
          console.log(
            "A LayerItemView failed to initialize. Error message: " + e,
          );
        }
      },

      /**
       * Renders this view
       * @return {LayerItemView} Returns the rendered view element
       */
      render: function () {
        try {
          // Save a reference to this view
          var view = this;

          if (!this.model) {
            return;
          }

          // Insert the template into the view
          this.$el.html(
            this.template({
              label: this.model.get("label"),
              classes: this.classes,
            }),
          );
          // Save a reference to the label element
          this.labelEl = this.el.querySelector("." + this.classes.label);

          // Insert the icon on the left
          if (!this.isCategorized) {
            this.insertIcon();
          }

          // Add a thumbnail / legend preview
          const legendContainer = this.el.querySelector(
            "." + this.classes.legendContainer,
          );
          const legendPreview = new Legend({
            model: this.model,
            mode: "preview",
          });
          legendContainer.append(legendPreview.render().el);

          // Ensure the view's main element has the given class name
          this.el.classList.add(this.className);

          // Show the item as hidden and/or selected depending on the model properties
          // that are set initially
          this.showVisibility();
          this.showSelection();
          // Show the current status of this layer
          this.showStatus();

          // When the Layer is selected, highlight this item in the Layer List. When
          // it's no longer selected, then make sure it's no longer highlighted. Set a
          // listener because the 'selected' attribute can be changed within this view,
          // from the parent Layers collection, or from the Layer Details View.
          this.stopListening(this.model, "change:selected");
          this.listenTo(this.model, "change:selected", this.showSelection);

          // Similar to above, add or remove the shown class when the layer's
          // visibility changes
          this.stopListening(this.model, "change:visible");
          this.listenTo(this.model, "change:visible", this.showVisibility);

          // Update the item in the list to show when it is loading, loaded, or there's
          // been an error.
          this.stopListening(this.model, "change:status");
          this.listenTo(this.model, "change:status", this.showStatus);

          return this;
        } catch (error) {
          console.log(
            "There was an error rendering a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Waits for the icon attribute to be ready in the Map Asset model, then inserts
       * the icon before the label.
       */
      insertIcon: function () {
        try {
          const model = this.model;
          let icon = model.get("icon");
          if (!icon || typeof icon !== "string" || !IconUtilities.isSVG(icon)) {
            icon = model.defaults().icon;
          }
          const iconContainer = document.createElement("span");
          iconContainer.classList.add(this.classes.icon);
          iconContainer.innerHTML = icon;
          this.el
            .querySelector("." + this.classes.visibilityToggle)
            .replaceChildren(iconContainer);

          const iconStatus = model.get("iconStatus");
          if (iconStatus && iconStatus === "fetching") {
            this.listenToOnce(model, "change:iconStatus", this.insertIcon);
            return;
          }
        } catch (error) {
          console.log(
            "There was an error inserting an icon in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Sets the Layer model's 'selected' status attribute to true if it's false, and
       * to false if it's true. Executed when a user clicks on this Layer Item in a
       * Layer List view.
       */
      toggleSelected: function () {
        try {
          var layerModel = this.model;
          if (layerModel.get("selected")) {
            layerModel.set("selected", false);
          } else {
            layerModel.set("selected", true);
          }
        } catch (error) {
          console.log(
            "There was an error selecting or unselecting a layer in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Sets the Layer model's visibility status attribute to true if it's false, and
       * to false if it's true. Executed when a user clicks on the visibility toggle.
       */
      toggleVisibility: function (event) {
        try {
          if (
            this.$(`.${this.classes.legendAndSettings}`).is(event.target) ||
            this.$(`.${this.classes.legendAndSettings}`).has(event.target)
              .length > 0
          ) {
            return;
          }

          const layerModel = this.model;
          // Hide if visible
          if (layerModel.get("visible")) {
            layerModel.set("visible", false);
            // Show if hidden
          } else {
            // If user is trying to make the layer visible, make sure the opacity is not 0
            if (layerModel.get("opacity") === 0) {
              layerModel.set("opacity", 0.5);
            }
            layerModel.set("visible", true);
          }
        } catch (error) {
          console.log(
            "There was an error selecting or unselecting a layer in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Highlight/emphasize this item in the Layer List when it is selected (i.e. when
       * the Layer model's 'selected' attribute is set to true). If it is not selected,
       * then remove any highlighting. This function is executed whenever the model's
       * 'selected' attribute changes. It can be changed from within this view (with the
       * toggleSelected function), from the parent Layers collection, or from the
       * Layer Details View.
       */
      showSelection: function () {
        try {
          var layerModel = this.model;
          if (layerModel.get("selected")) {
            this.$(`.${this.classes.legendAndSettings}`).addClass(
              this.classes.selected,
            );
          } else {
            this.$(`.${this.classes.legendAndSettings}`).removeClass(
              this.classes.selected,
            );
          }
        } catch (error) {
          console.log(
            "There was an error changing the highlighting in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Add or remove styles that indicate that the layer is shown based on what is
       * set in the Layer model's 'visible' attribute. Executed whenever the 'visible'
       * attribute changes.
       */
      showVisibility: function () {
        try {
          var layerModel = this.model;
          if (layerModel.get("visible")) {
            this.$el.addClass(this.classes.shown);
          } else {
            this.$el.removeClass(this.classes.shown);
          }
        } catch (error) {
          console.log(
            "There was an error changing the shown styles in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Gets the Map Asset model's status and updates this Layer Item View to reflect
       * that status to the user.
       */
      showStatus: function () {
        try {
          var layerModel = this.model;
          var status = layerModel.get("status");
          if (status === "error") {
            const errorMessage = layerModel.get("statusDetails");
            this.showError(errorMessage);
          } else if (status === "ready") {
            this.removeStatuses();
            const notice = layerModel.get("notification");
            const badge = notice ? notice.badge : null;
            if (badge) {
              this.showBadge(badge, notice.style);
            }
          } else if (status === "loading") {
            this.showLoading();
          }
        } catch (error) {
          console.log(
            "There was an error showing the status in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Remove any icons, tooltips, or other visual indicators of a Map Asset's error
       * or loading status in this view
       */
      removeStatuses: function () {
        try {
          if (this.statusIcon) {
            this.statusIcon.remove();
          }
          if (this.badge) {
            this.badge.remove();
          }
          this.$el.tooltip("destroy");
        } catch (error) {
          console.log(
            "There was an error removing status indicators in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Create a badge element and insert it to the right of the layer label.
       * @param {string} text - The text to display in the badge
       * @param {string} [style] - The style of the badge. Can be any of the styles
       * defined in the {@link MapConfig#Notification} style property, e.g. 'green'
       */
      showBadge: function (text, style) {
        try {
          if (!text) {
            return;
          }
          this.removeStatuses();
          this.badge = document.createElement("span");
          this.badge.classList.add(this.classes.badge);
          this.badge.innerText = text;
          this.labelEl.append(this.badge);
          if (style) {
            const badgeClass = this.classes.badge + "--" + style;
            this.badge.classList.add(badgeClass);
          }
        } catch (error) {
          console.log(
            "There was an error showing the badge in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Indicate to the user that there was a problem showing or loading this error.
       * Shows a 'warning' icon to the right of the label for the asset and a tooltip
       * with more details
       * @param {string} message The error message to show in the tooltip.
       */
      showError: function (message = "") {
        try {
          const view = this;

          // Remove any style elements for other statuses
          this.removeStatuses();

          // Show a warning icon
          this.statusIcon = document.createElement("span");
          this.statusIcon.innerHTML = `<i class="icon-warning-sign icon icon-on-right"></i>`;
          this.statusIcon.style.opacity = "0.6";
          this.labelEl.append(this.statusIcon);

          // Show a tooltip with the error message
          let fullMessage = this.errorMessage;
          if (message) {
            fullMessage = fullMessage + " Error details: " + message;
          }
          this.$el.tooltip({
            placement: "top",
            trigger: "hover",
            title: fullMessage,
            container: "body",
            animation: false,
            template:
              '<div class="tooltip ' +
              view.classes.tooltip +
              '"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
            delay: { show: 250, hide: 5 },
          });
        } catch (error) {
          console.log(
            "Failed to show the error status in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Show a spinner icon to the right of the Map Asset label to indicate that this
       * layer is loading
       */
      showLoading: function () {
        try {
          // Remove any style elements for other statuses
          this.removeStatuses();

          // Show a spinner icon
          this.statusIcon = document.createElement("span");
          this.statusIcon.innerHTML = `<i class="icon-spinner icon-spin icon-small loading icon icon-on-right"></i>`;
          this.statusIcon.style.opacity = "0.6";
          this.labelEl.append(this.statusIcon);
        } catch (error) {
          console.log(
            "There was an error showing the loading status in a LayerItemView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Searches and only displays self if layer label matches the text. Highlights the
       * matched text.
       * @param {string} [text] - The search text from user input.
       * @returns {boolean} - True if a layer label matches the text
       */
      search(text) {
        let newLabel = this.model.get("label");
        if (text) {
          const regex = new RegExp(text, "ig");
          newLabel = this.model
            .get("label")
            .replaceAll(regex, (matchedText) => {
              return $("<span />")
                .addClass(this.classes.highlightedText)
                .html(matchedText)
                .prop("outerHTML");
            });

          // Label is unchanged.
          if (newLabel === this.model.get("label")) {
            this.$el.hide();
            return false;
          }
        }

        this.labelEl.querySelector(`.${this.classes.labelText}`).innerHTML =
          newLabel;
        this.$el.show();
        return true;
      },
    },
  );

  return LayerItemView;
});