Source: src/js/views/maps/viewfinder/ViewfinderView.js

"use strict";

define([
  "underscore",
  "backbone",
  "common/SearchParams",
  "text!templates/maps/viewfinder/viewfinder.html",
  "views/maps/viewfinder/SearchView",
  "views/maps/viewfinder/ViewfinderCardsListView",
  "views/maps/ExpansionPanelView",
  "models/maps/ExpansionPanelsModel",
  "models/maps/viewfinder/ViewfinderModel",
], (
  _,
  Backbone,
  SearchParams,
  Template,
  SearchView,
  ViewfinderCardsListView,
  ExpansionPanelView,
  ExpansionPanelsModel,
  ViewfinderModel,
) => {
  // The base classname to use for this View's template elements.
  const BASE_CLASS = "viewfinder";
  // The HTML classes to use for this view's HTML elements.
  const CLASS_NAMES = {
    searchView: `${BASE_CLASS}__search`,
    viewfinderCardsView: `${BASE_CLASS}__cards`,
  };

  /**
   * @class ViewfinderView
   * @classdesc ViewfinderView allows a user to search for
   * a latitude and longitude in the map view, and find suggestions
   * for places related to their search terms.
   * @classcategory Views/Maps
   * @name ViewfinderView
   * @augments Backbone.View
   * @screenshot views/maps/viewfinder/ViewfinderView.png
   * @since 2.28.0
   * @constructs ViewfinderView
   */
  const ViewfinderView = Backbone.View.extend(
    /** @lends ViewfinderView.prototype */ {
      /**
       * The type of View this is
       * @type {string}
       */
      type: "ViewfinderView",

      /**
       * The HTML class to use for this view's outermost element.
       * @type {string}
       */
      className: BASE_CLASS,

      /**
       * Values meant to be used by the rendered HTML template.
       */
      templateVars: {
        classNames: CLASS_NAMES,
      },

      /**
       * Initialize the ViewfinderView.
       * @param {object} options - The options for the view.
       * @param {Map} options.model - The map model to use for this view.
       */
      initialize({ model: mapModel }) {
        this.mapModel = mapModel;
        this.viewfinderModel = new ViewfinderModel({ mapModel });
        this.panelsModel = new ExpansionPanelsModel({ isMulti: true });
        this.viewfinderCardsListViews = [];
        this.expansionPanelsByCategoryCid = {};
        this.debouncedSyncVisualizationStateToUrl = _.debounce(
          (payload) => this.syncVisualizationStateToUrl(payload),
          250,
        );

        // When the visualization overlay closes, reset active button states
        // on all preset cards so none appears stuck in an active state.
        this.listenTo(
          mapModel,
          "change:activeVisualizationUrl",
          (model, url) => {
            if (!url) {
              this.viewfinderCardsListViews.forEach((listView) => {
                listView.children?.forEach((child) => child.resetActiveState());
              });
              if (mapModel.get("showShareUrl")) {
                const previousActionId = model.previous(
                  "activeVisualizationActionId",
                );
                if (previousActionId) {
                  SearchParams.clearActionStateInUrl(previousActionId);
                }
                SearchParams.updateActiveActionId(null);
              }
            }
          },
        );
        this.listenTo(mapModel, "visualization:state", (payload) => {
          this.debouncedSyncVisualizationStateToUrl(payload);
        });
      },

      /**
       * Synchronize incoming iframe URL state to namespaced parent URL params.
       * @param {object} payload State message forwarded from VisualizationPanelView.
       * @param {object} payload.action Active iframe action definition.
       * @param {string} payload.url URL sent by the embedded visualization.
       * @since 2.38.0
       */
      syncVisualizationStateToUrl(payload = {}) {
        if (!this.mapModel?.get("showShareUrl")) return;

        const action =
          payload.action || this.mapModel.get("activeVisualizationAction");
        const actionId = typeof action?.id === "string" ? action.id.trim() : "";
        const actionUrlTemplate =
          typeof action?.url === "string" ? action.url : null;
        const activeActionId =
          typeof this.mapModel.get("activeVisualizationActionId") === "string"
            ? this.mapModel.get("activeVisualizationActionId").trim()
            : "";

        if (!actionId.length || !actionUrlTemplate || !payload?.url) return;
        if (!activeActionId.length || activeActionId !== actionId) return;

        SearchParams.syncActionStateFromVisualizationUrl({
          actionId,
          actionUrlTemplate,
          visualizationUrl: payload.url,
        });
      },

      /**
       * Sync the active action id to the URL restore state.
       * @param {ViewfinderCardModel} _card The card model that owns the action.
       * @param {object} action The activated action object.
       * @since 2.38.0
       */
      syncActiveActionToUrl(_card, action) {
        if (!this.mapModel?.get("showShareUrl")) return;
        const actionId = typeof action?.id === "string" ? action.id : null;
        SearchParams.updateActiveActionId(actionId);
      },

      /**
       * Find a rendered card view that contains the given action id.
       * @param {string} actionId The action id to resolve.
       * @returns {object|null} The matching rendered view and action, if found.
       * @since 2.38.0
       */
      findRenderedAction(actionId) {
        if (typeof actionId !== "string" || !actionId.length) return null;

        const match = (this.viewfinderCardsListViews || []).reduce(
          (foundMatch, listView) => {
            if (foundMatch) return foundMatch;

            return (listView.children || []).reduce((cardMatch, cardView) => {
              if (cardMatch) return cardMatch;

              const buttons = cardView.preset.get("buttons") || [];
              const action = buttons.find(
                (candidate) => candidate?.id === actionId,
              );
              return action
                ? {
                    action,
                    cardView,
                    categoryCid: listView.categoryCid,
                  }
                : null;
            }, null);
          },
          null,
        );

        if (match) return match;

        return null;
      },

      /**
       * Expand the given category section if it has a panel view.
       * @param {string} categoryCid The category CID.
       * @since 2.38.0
       */
      openCategoryPanel(categoryCid) {
        const panel = this.expansionPanelsByCategoryCid[categoryCid];
        panel?.open();
      },

      /**
       * Restore a previously active viewfinder action by id.
       * @param {string} actionId The action id from URL restore state.
       * @returns {boolean} True if action was restored.
       * @since 2.38.0
       */
      restoreActiveAction(actionId) {
        const match = this.findRenderedAction(actionId);
        if (!match) return false;

        this.openCategoryPanel(match.categoryCid);
        return match.cardView.restoreAction(match.action);
      },

      /**
       * Apply active action restore state from URL for schema 1.
       * @since 2.38.0
       */
      applyActiveActionFromUrl() {
        if (!this.mapModel?.get("showShareUrl")) return;
        const restoreState =
          this.mapModel.get("restoreState") || SearchParams.parseStateFromUrl();
        if (!restoreState.activeActionId) return;
        this.restoreActiveAction(restoreState.activeActionId);
      },

      /**
       * Get the ViewfinderCardsView element. Renamed from getZoomPresets
       * when zoom presets were deprecated in favor of viewfinder cards in 2.37.0.
       * @returns {JQuery} The ViewfinderCardsView element.
       * @since 2.29.0
       */
      getViewfinderCards() {
        return this.$el.find(`.${CLASS_NAMES.viewfinderCardsView}`);
      },

      /**
       * Get the ViewfinderCardsView panel for a given category, if it exists.
       * @param {ViewfinderCardCategory} category The category of viewfinder
       * cards to get the panel for.
       * @returns {JQuery} The ViewfinderCardsView panel element, or an empty
       * jQuery object if it doesn't exist.
       * @since 2.35.0
       */
      getViewfinderCardsPanel(category) {
        return this.$el.find(`#${category.cid}`);
      },

      /**
       * Determine where to place a ViewfinderCardsView for a given category,
       * based on the order of categories in the collection. Renamed from
       * getZoomPresetsPlacement when zoom presets were deprecated in favor of
       * viewfinder cards in 2.37.0.
       * @param {ViewfinderCardCategory} category The category of viewfinder
       * cards to determine placement for.
       * @returns {string|object} "prepend" to add to the beginning of the list,
       * "append" to add to the end of the list, or { after: JQueryElement } to
       * add after a specific existing element.
       * @since 2.35.0
       */
      getViewfinderCardsPlacement(category) {
        const categories = this.viewfinderModel.get("viewfinderCards");
        const index = categories.indexOf(category);
        if (index === 0) return "prepend";
        const previousCategory = categories.at(index - 1);
        const previousPanel = this.getViewfinderCardsPanel(previousCategory);
        if (previousPanel?.length) return { after: previousPanel };
        return "append";
      },

      /**
       * Remove the ViewfinderCardsView panel for a given category, if it
       * exists.
       * @param {ViewfinderCardCategory} category The category of viewfinder
       * cards to remove the panel for.
       * @since 2.35.0
       */
      removeViewfinderCardsCategory(category) {
        const panel = this.getViewfinderCardsPanel(category);
        if (panel?.length) panel.remove();
      },

      /**
       * Get the SearchView element.
       * @returns {JQuery} The SearchView element.
       */
      getSearch() {
        return this.$el.find(`.${CLASS_NAMES.searchView}`);
      },

      /**
       * Helper function to focus input on the search query input and ensure
       * that the cursor is at the end of the text (as opposed to the beginning
       * which appears to be the default jQuery behavior).
       * @since 2.29.0
       */
      focusInput() {
        this.searchView.focusInput();
      },

      /**
       * Render child ViewfinderCardsListView and append to DOM. Extended from
       * renderZoomPresetsView when zoom presets were deprecated in favor of
       * generalized viewfinder cards in 2.37.0. Adds piping for visualization
       * panel as well.
       * @param {ViewfinderCardCategory} category The category of viewfinder
       * cards to render.
       * @since 2.29.0
       */
      renderViewfinderCardsView(category) {
        const viewfinderCards = category.get("viewfinderCards");
        if (!viewfinderCards.length && viewfinderCards.url) {
          viewfinderCards.fetch({
            success: () => this.renderViewfinderCardsView(category),
            error: () => this.removeViewfinderCardsCategory(category),
          });
          return;
        }

        const viewfinderCardsListView = new ViewfinderCardsListView({
          viewfinderCards,
          onMapAction: (card, action) => {
            this.viewfinderModel.applyMapAction(card, action);
          },
          onIframeAction: (action) => {
            this.viewfinderModel.openVisualization(action);
          },
          onRequestCloseVisualization: () => {
            this.viewfinderModel.closeVisualization();
          },
          onActivate: (activeView) => {
            this.viewfinderCardsListViews.forEach((lv) => {
              lv.children?.forEach((child) => {
                if (child !== activeView) child.resetActiveState();
              });
            });
          },
          onActionUiActivated: (card, action) => {
            this.syncActiveActionToUrl(card, action);
          },
        });
        viewfinderCardsListView.categoryCid = category.cid;
        this.viewfinderCardsListViews = this.viewfinderCardsListViews.filter(
          (lv) => lv.categoryCid !== category.cid,
        );
        this.viewfinderCardsListViews.push(viewfinderCardsListView);
        const expansionPanel = new ExpansionPanelView({
          contentViewInstance: viewfinderCardsListView,
          icon: category.get("icon"),
          panelsModel: this.panelsModel,
          title: category.get("label"),
          startOpen: category.get("expanded") === true,
          id: category.cid,
          variants: ["title"],
          isSvgIcon: category.get("isSvgIcon") === true,
        });
        expansionPanel.render();
        this.expansionPanelsByCategoryCid[category.cid] = expansionPanel;

        const existingPanel = this.getViewfinderCardsPanel(category);
        if (existingPanel?.length) {
          existingPanel.replaceWith(expansionPanel.el);
        } else {
          // otherwise, add it where it belongs according to collection order
          const placement = this.getViewfinderCardsPlacement(category);

          if (placement === "prepend") {
            this.getViewfinderCards().prepend(expansionPanel.el);
          } else if (placement === "append") {
            this.getViewfinderCards().append(expansionPanel.el);
          } else if (placement.after) {
            placement.after.after(expansionPanel.el);
          }
        }

        // Retry URL-based action restore after cards render, which is required
        // when a category loads cards asynchronously from a remote URL.
        this.applyActiveActionFromUrl();
      },

      /** Render child SearchView and append to DOM. */
      renderSearchView() {
        this.searchView = new SearchView({
          viewfinderModel: this.viewfinderModel,
        });
        this.searchView.render();

        this.getSearch().append(this.searchView.el);
      },

      /**
       * Render the view by updating the HTML of the element.
       * The new HTML is computed from an HTML template that
       * is passed an object with relevant view state.
       */
      render() {
        this.el.innerHTML = _.template(Template)(this.templateVars);

        this.renderSearchView();

        const categories = this.viewfinderModel.get("viewfinderCards");
        categories?.each((category) =>
          this.renderViewfinderCardsView(category),
        );
      },
    },
  );

  return ViewfinderView;
});