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

"use strict";

define([
  "jquery",
  "underscore",
  "backbone",
  "text!templates/maps/toolbar.html",
  "models/maps/Map",
  "common/IconUtilities",
  // Sub-views - TODO: import these as needed
  "views/maps/LayersPanelView",
  "views/maps/HelpPanelView",
  "views/maps/viewfinder/ViewfinderView",
  "views/maps/ShareUrlView",
], (
  $,
  _,
  Backbone,
  Template,
  Map,
  IconUtilities,
  // Sub-views
  LayersPanelView,
  HelpPanel,
  ViewfinderView,
  ShareUrlView,
) => {
  /**
   * @class ToolbarView
   * @classdesc The map toolbar view is a side bar that contains information about a map,
   * including the available layers, plus UI for changing the settings of a map.
   * @classcategory Views/Maps
   * @name ToolbarView
   * @extends Backbone.View
   * @screenshot views/maps/ToolbarView.png
   * @since 2.18.0
   * @constructs
   */
  const ToolbarView = Backbone.View.extend(
    /** @lends ToolbarView.prototype */ {
      /**
       * The type of View this is
       * @type {string}
       */
      type: "ToolbarView",

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

      /**
       * The model that this view uses
       * @type {Map}
       */
      model: null,

      /**
       * The primary HTML template for this view. The template must have two element,
       * one with the contentContainer class, and one with the linksContainer class.
       * See {@link ToolbarView#classes}.
       * @type {Underscore.template}
       */
      template: _.template(Template),

      /**
       * The classes of the sub-elements that combined to create a toolbar view.
       *
       * @name ToolbarView#classes
       * @type {Object}
       * @property {string} open The class to add to the view when the toolbar is open
       * (and the content is visible)
       * @property {string} contentContainer The element that contains all containers
       * for the toolbar section content. This element must be part of this view's
       * template.
       * @property {string} linksContainer The container for all of the section links
       * (i.e. tabs)
       * @property {string} link A section link
       * @property {string} linkTitle The section link title
       * @property {string} linkIcon The section link icon
       * @property {string} linkActive The class to add to a link when its content is
       * active
       * @property {string} content A section's content. This element will be the
       * container for the view associated with this section.
       * @property {string} contentActive A class added to a content container when it
       * is the active section
       */
      classes: {
        open: "toolbar--open",
        contentContainer: "toolbar__all-content",
        linksContainer: "toolbar__links",
        link: "toolbar__link",
        linkTitle: "toolbar__link-title",
        linkTitleHidden: "toolbar__link-title--hidden",
        linkIcon: "toolbar__link-icon",
        linkActive: "toolbar__link--active",
        content: "toolbar__content",
        contentActive: "toolbar__content--active",
      },

      /**
       * A string that represents an icon. Can be either the name of the Font Awesome
       * 3.2 icon OR an SVG string for an icon with all the following properties: 1)
       * Uses viewBox attribute and not width/height; 2) Sets fill or stroke to
       * "currentColor" in the svg element, no styles included elsewhere, 3) Has the
       * required xmlns attribute
       *
       * @typedef {string} MapIconString
       *
       * @see {@link https://fontawesome.com/v3.2/icons/}
       *
       * @example
       * '<svg viewBox="0 0 400 110" fill="currentColor"><path d="M0 0h300v100H0z"/></svg>'
       * @example
       * 'map-marker'
       */

      /**
       * Options/settings that are used to create a toolbar section and its associated
       * link/tab.
       *
       * @typedef {Object} SectionOption
       * @property {string} label The name of this section to show to the user.
       * @property {MapIconString} icon The icon to show in the link (tab) for this
       * section
       * @property {Backbone.View} [view] The view that renders the content of the
       * toolbar section.
       * @property {object} [viewOptions] Any additional options to pass to the content
       * view. By default, the label, icon, and Map model will be passed to the view as
       * 'label', 'icon', and 'model', respectively. To pass a specific attribute from
       * the Map model, use a string with the syntax 'model.desiredAttribute'. For
       * example, 'model.layers' will be converted to view.model.get('layers')
       * @property {function} [action] A function to call when the link/tab is clicked.
       * This can be provided instead of a view and viewOptions, in which case no
       * toolbar section will be created. The function will be passed the view and the
       * Map model as arguments.
       * @property {function} [isVisible] A function that determines whether this
       * section should be visible in the toolbar.
       */

      /**
       * The sections displayed in the toolbar will be created based on the options set
       * in this array.
       *
       * @type {SectionOption[]}
       */
      sectionOptions: [
        {
          label: "Viewfinder",
          icon: "globe",
          view: ViewfinderView,
          action(view, model) {
            const sectionEl = this;
            view.defaultActivationAction(sectionEl);
            sectionEl.sectionView.focusInput();
          },
          isVisible(model) {
            return MetacatUI.mapKey && model.get("showViewfinder");
          },
        },
        {
          label: "Layers",
          icon: '<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="m3.2 7.3 8.6 4.6a.5.5 0 0 0 .4 0l8.6-4.6a.4.4 0 0 0 0-.8L12.1 3a.5.5 0 0 0-.4 0L3.3 6.5a.4.4 0 0 0 0 .8Z"/><path d="M20.7 10.7 19 9.9l-6.7 3.6a.5.5 0 0 1-.4 0L5 9.9l-1.8.8a.5.5 0 0 0 0 .8l8.5 5a.5.5 0 0 0 .5 0l8.5-5a.5.5 0 0 0 0-.8Z"/><path d="m20.7 15.1-1.5-.7-7 3.8a.5.5 0 0 1-.4 0l-7-3.8-1.5.7a.5.5 0 0 0 0 .9l8.5 5a.5.5 0 0 0 .5 0l8.5-5a.5.5 0 0 0 0-.9Z"/></svg>',
          view: LayersPanelView,
          isVisible(model) {
            return model.get("showLayerList");
          },
        },
        {
          label: "Reset",
          icon: "rotate-left",
          action(view, model) {
            model.flyHome();
            model.resetLayerVisibility();
          },
          isVisible(model) {
            return model.get("showHomeButton");
          },
        },
        // We can enable to the draw tool once we have a use case for it
        // {
        //   label: 'Draw',
        //   icon: 'pencil',
        //   view: DrawTool,
        //   viewOptions: {}
        // },
        {
          label: "Help",
          icon: "question-sign",
          view: HelpPanel,
          viewOptions: {
            showFeedback: "model.showFeedback",
            feedbackText: "model.feedbackText",
            showNavHelp: "model.showNavHelp",
          },
          isVisible(model) {
            return model.get("showNavHelp") || model.get("showFeedback");
          },
        },
        {
          label: "Share",
          icon: "link",
          action(view) {
            const title = this.linkEl.querySelector(
              `.${view.classes.linkTitle}`,
            );

            if (view.el.querySelector(".share-url")) {
              return;
            }

            const shareUrlView = new ShareUrlView({
              top: this.linkEl.offsetTop,
              left: this.linkEl.offsetLeft,
              onRemove() {
                title.classList.remove(view.classes.linkTitleHidden);
              },
            });
            shareUrlView.render();
            // Make sure link's tooltip is hidden while its popup is visible.
            title.classList.add(view.classes.linkTitleHidden);
            view.$el.append(shareUrlView.el);
          },
          isVisible(model) {
            return model.get("showShareUrl");
          },
        },
      ],

      /**
       * Whether or not the toolbar menu is opened. This will get updated when the user
       * interacts with the toolbar links.
       * @type {Boolean}
       */
      isOpen: false,

      /**
       * Executed when a new ToolbarView is created
       * @param {Object} [options] - A literal object with options to pass to the view
       */
      initialize(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;
            }
          }
          if (!this.model || !(this.model instanceof Map)) {
            this.model = new Map();
          }

          if (this.model.get("toolbarOpen") === true) {
            this.isOpen = true;
          }

          // Check whether each section should be shown, defaulting to true.
          this.sections = this.sectionOptions.filter((section) => {
            return typeof section.isVisible === "function"
              ? section.isVisible(this.model)
              : true;
          });
        } catch (e) {
          console.log("Error initializing a ToolbarView", e);
        }
      },

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

          // Insert the template into the view
          this.$el.html(this.template({}));

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

          // Select and save a reference to the elements that will contain the
          // links/tabs and the section content.
          this.contentContainer = document.querySelector(
            "." + view.classes.contentContainer,
          );
          this.linksContainer = document.querySelector(
            "." + view.classes.linksContainer,
          );

          // sectionElements will store the section link, section content element, and
          // the status of the given section (whether it is active or not)
          this.sectionElements = [];

          // For each section configured in the view's sections property, create a link
          // and render the content. Set a listener for when the link is clicked.
          this.sections.forEach(function (sectionOption) {
            // Render the link and content elements
            var linkEl = view.renderSectionLink(sectionOption);
            var action = sectionOption.action;
            let contentEl = null;
            let sectionView;
            if (sectionOption.view) {
              const { contentContainer, sectionContent } =
                view.renderSectionContent(sectionOption);
              contentEl = contentContainer;
              sectionView = sectionContent;
            }
            // Set the section to false to start
            var isActive = false;
            // Save a reference to these elements and their status. sectionEl is an
            // object that has type SectionElement (documented in comments below)
            var sectionEl = {
              linkEl,
              contentEl,
              isActive,
              action,
              sectionView,
            };
            view.sectionElements.push(sectionEl);
            // Attach the link and content to the view
            if (contentEl) {
              view.contentContainer.appendChild(contentEl);
            }
            view.linksContainer.appendChild(linkEl);
            // Add a listener that shows the section when the link is clicked
            linkEl.addEventListener("click", function () {
              view.handleLinkClick(sectionEl);
            });
          });

          // Set the toolbar to open, depending on what is initially set in view.isOpen.
          // Set the first section to active if the toolbar is open.
          if (this.isOpen) {
            this.el.classList.add(this.classes.open);
            view.handleLinkClick(this.sectionElements[0]);
          }

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

      /**
       * A reference to all of the elements required to make up a toolbar section: the
       * section content and the section link (i.e. tab); as well as the status of the
       * section: active or in active.
       *
       * @typedef {Object} SectionElement
       * @property {HTMLElement} contentEl The element that contains the toolbar
       * section's content (the content rendered by the associated view)
       * @property {HTMLElement} linkEl The element that acts as a link to show the
       * section's content, and open/close the toolbar.
       * @property {Boolean} isActive True if this is the active section, false
       * otherwise.
       * @property {Backbone.View} sectionView The associated Backbone.View instance.
       */

      /**
       * Executed when any one of the tabs/links are clicked. Opens the toolbar if it's
       * closed, closes it if the active section is clicked, and otherwise activates the
       * clicked section content.
       * @param {SectionElement} sectionEl
       */
      handleLinkClick(sectionEl) {
        try {
          var toolbarOpen = this.isOpen;
          var sectionActive = sectionEl.isActive;
          if (toolbarOpen && sectionActive) {
            this.close();
            return;
          }
          if (!toolbarOpen && sectionEl.contentEl) {
            this.open();
          }
          if (!sectionActive) {
            if (sectionEl.contentEl) {
              this.inactivateAllSections();
            }
            this.activateSection(sectionEl);
          }
        } catch (error) {
          console.log(
            "There was an error handling a toolbar link click in a ToolbarView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Creates a link/tab for a given toolbar section
       * @param {SectionOption} sectionOption The label and icon that are set in the
       * Section Option are used to create the link content
       * @returns {HTMLElement} Returns the link element
       */
      renderSectionLink(sectionOption) {
        try {
          // Create a container, label
          const link = document.createElement("div");
          const title = document.createElement("div");
          // Create the icon
          const icon = this.createIcon(sectionOption.icon);

          // Add the relevant classes
          link.classList.add(this.classes.link);
          title.classList.add(this.classes.linkTitle);
          // Add the label text
          title.textContent = sectionOption.label;

          link.append(icon, title);

          return link;
        } catch (error) {
          console.log(
            "There was an error rendering a section link in a ToolbarView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Given the name of a Font Awesome 3.2 icon, or an SVG string, creates an icon
       * element with the appropriate classes for the tool bar link (tab)
       * @param {MapIconString} iconString The string to use to create the icon
       * @returns {HTMLElement} Returns either an <i> element with a Font Awesome icon,
       * or and SVG with a custom icon
       */
      createIcon(iconString) {
        try {
          // The icon element we will create and return. By default, return an empty span
          // element.
          let icon = document.createElement("span");

          // iconString must be string
          if (typeof iconString === "string") {
            // If the icon is an SVG element
            if (IconUtilities.isSVG(iconString)) {
              icon = new DOMParser().parseFromString(
                iconString,
                "image/svg+xml",
              ).documentElement;
              // If the icon is not an SVG, assume it's the name for a Font Awesome icon
            } else {
              icon = document.createElement("i");
              icon.className = "icon-" + iconString;
            }
          }
          icon.classList.add(this.classes.linkIcon);
          return icon;
        } catch (error) {
          console.log(
            "There was an error  in a ToolbarView" +
              ". Error details: " +
              error,
          );
          return document.createElement("span");
        }
      },

      /**
       * @typedef {Object} SectionContentReturnType
       * @property {HTMLElement} contentContainer - The content container HTML
       * element.
       * @property {Backbone.View} sectionContent - The Backbone.View instance
       */

      /**
       * Creates a container for a toolbar section's content, then rendered the
       * specified view in that container.
       * @param {SectionOption} sectionOption The view and view options that are set in
       * the Section Option are used to create the content container
       * @returns {SectionContentReturnType} The content container with the
       * rendered view, and the Backbone.View itself.
       */
      renderSectionContent(sectionOption) {
        try {
          const view = this;
          // Create the container for the toolbar section content
          var contentContainer = document.createElement("div");
          // Add the class that identifies a toolbar section's content
          contentContainer.classList.add(this.classes.content);
          // Render the toolbar section view
          // Merge the icon and label with the other section options
          var viewOptions = Object.assign(
            {
              label: sectionOption.label,
              icon: sectionOption.icon,
              model: this.model,
            },
            sectionOption.viewOptions,
          );
          // Convert any values in the form of 'model.someAttribute' to the model
          // attribute that is specified.
          for (const [key, value] of Object.entries(viewOptions)) {
            if (typeof value === "string" && value.startsWith("model.")) {
              const attr = value.replace(/^model\./, "");
              viewOptions[key] = view.model.get(attr);
            }
          }
          var sectionContent = new sectionOption.view(viewOptions);
          contentContainer.appendChild(sectionContent.el);
          sectionContent.render();
          return { contentContainer, sectionContent };
        } catch (error) {
          console.log("Error rendering ToolbarView section", error);
        }
      },

      /**
       * Opens the toolbar and displays the content of the active toolbar section
       */
      open() {
        try {
          this.isOpen = true;
          this.el.classList.add(this.classes.open);
        } catch (error) {
          console.log(
            "There was an error opening a ToolbarView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Closes the toolbar. Also inactivates all sections.
       */
      close() {
        try {
          this.isOpen = false;
          this.el.classList.remove(this.classes.open);
          // Ensure that no section is active when the toolbar is closed
          this.inactivateAllSections();
        } catch (error) {
          console.log(
            "There was an error closing a ToolbarView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Display the content of a given section
       * @param {SectionElement} sectionEl The section to activate
       */
      activateSection(sectionEl) {
        if (!sectionEl) return;
        try {
          if (sectionEl.action && typeof sectionEl.action === "function") {
            const view = this;
            const model = this.model;
            sectionEl.action(view, model);
          } else {
            this.defaultActivationAction(sectionEl);
          }
        } catch (error) {
          console.log("Failed to show a section in a ToolbarView", error);
        }
      },

      /**
       * The default action for a section being activated.
       * @param {SectionElement} sectionEl The section to activate
       */
      defaultActivationAction(sectionEl) {
        sectionEl.isActive = true;
        sectionEl.contentEl.classList.add(this.classes.contentActive);
        sectionEl.linkEl.classList.add(this.classes.linkActive);
      },

      /**
       * Hide the content of a section
       * @param {SectionElement} sectionEl The section to inactivate
       */
      inactivateSection(sectionEl) {
        try {
          sectionEl.isActive = false;
          if (sectionEl.contentEl) {
            sectionEl.contentEl.classList.remove(this.classes.contentActive);
            sectionEl.linkEl.classList.remove(this.classes.linkActive);
          }
        } catch (error) {
          console.log(
            "There was an error showing a toolbar section in a ToolbarView" +
              ". Error details: " +
              error,
          );
        }
      },

      /**
       * Hide all of the sections in a toolbar view
       */
      inactivateAllSections() {
        try {
          const view = this;
          this.sectionElements.forEach((sectionEl) => {
            view.inactivateSection(sectionEl);
          });
        } catch (error) {
          console.log(
            "There was an error hiding toolbar sections in a ToolbarView" +
              ". Error details: " +
              error,
          );
        }
      },
    },
  );

  return ToolbarView;
});