Source: src/js/views/versionHistory/VersionHistoryView.js

"use strict";

// TODO:
// - pagination if too many versions

define([
  "jquery",
  "backbone",
  "models/sysmeta/VersionTracker",
  "collections/DataONEObjects",
  "models/SolrResult",
  "collections/versionHistory/VersionTimelineGroups",
  "views/versionHistory/VersionTimelineGroupsView",
  "views/CitationView",
  "views/uiElements/ToggleView",
  "common/Utilities",
  "common/DateUtility",
  "semantic",
  // CSS
  `text!${MetacatUI.root}/css/version-history/version-history.css`,
], (
  $,
  Backbone,
  VersionTracker,
  DataONEObjects,
  SolrResult,
  VersionTimelineGroups,
  VersionTimelineGroupsView,
  CitationView,
  ToggleView,
  Utilities,
  DateUtility,
  Semantic,
  VersionHistoryCSS,
) => {
  const SPINNER = `<i class="icon-spinner icon-spin icon-large loading icon"></i>`;
  const REFRESH_ICON = `<i class="icon icon-refresh icon-on-left"></i>`;
  const SEM_VARIATIONS = Semantic.CLASS_NAMES.variations;

  // Friendly text to explain common server errors
  const ERROR_TEXT = {
    401: "You do not have permission to view the full version history. This means some versions may be private. Please log in with an account that has access rights.",
    All404: "No versions in the history were found in this data repository.",
    Some404:
      "Some versions in the history were not found in this data repository.",
    Next404:
      "At least one newer version was not found in this data repository.",
    Prev404:
      "At least one older version was not found in this data repository.",
    nextMaxHopsReached:
      "The search for newer versions was stopped after reaching the maximum steps allowed for a single search. To see more newer versions, please view the version history for the newest version found.",
    prevMaxHopsReached:
      "The search for older versions was stopped after reaching the maximum steps allowed for a single search. To see more older versions, please view the version history for the oldest version found.",
    bothMaxHopsReached:
      "The search for newer and older versions was stopped after reaching the maximum steps allowed for a single search. To see more versions, please view the version history for the newest and oldest versions found.",
    500: "An internal server error occurred while trying to retrieve the version history. Please try again later.",
    DEFAULT: "An unknown error occurred while retrieving the version history.",
  };

  const BASE_CLASS = "version-history";

  /**
   * Resolve singular/plural label for version counts.
   * @param {number} num Count to evaluate.
   * @returns {string} "version" or "versions".
   */
  const VERSIONS = (num) => (num === 1 ? "version" : "versions");

  /**
   * Build a summary string for a version count and direction.
   * @param {number} num Number of versions.
   * @param {boolean} [next] True for newer direction, false for older.
   * @returns {string} HTML string summarizing the version count and direction,
   * e.g. "<strong>3</strong> newer versions".
   */
  const VERSION_STR = (num, next = true) => {
    const numStr = num === 0 ? "no" : num;
    const direction = next ? "newer" : "older";
    return `<strong>${numStr}</strong> ${direction} ${VERSIONS(num)}`;
  };

  /**
   * CSS class names used throughout the VersionHistoryView.
   * @enum {string}
   */
  const CLASS_NAMES = {
    base: BASE_CLASS,
    header: `${BASE_CLASS}__header`,
    subtitle: `${BASE_CLASS}__subtitle`,
    status: `${BASE_CLASS}__status alert alert-info`,
    history: `${BASE_CLASS}__history`,
    controls: `${BASE_CLASS}__controls`,
    toggle: `${BASE_CLASS}__toggle`,
    refreshButton: `${BASE_CLASS}__refresh-button`,
  };

  /**
   * Backbone view that orchestrates fetching the version chain for a PID and
   * visualizing it as a grouped timeline with citations and status updates.
   * @class VersionHistoryView
   * @classdesc A view that renders the version history for a given PID, showing
   * progress as it is fetched and summarizing the results once complete.
   * @classcategory Views/VersionHistory
   * @augments Backbone.View
   * @screenshot views/versionHistory/VersionHistoryView.png
   * @since 2.37.0
   */
  const VersionHistoryView = Backbone.View.extend(
    /** @lends VersionHistoryView.prototype */ {
      /**
       * Identifier used when the application inspects view types.
       * @type {string}
       */
      type: "VersionHistoryView",

      /**
       * Root element tag for the view.
       * @type {string}
       */
      tagName: "section",

      /**
       * CSS class names applied to the view's root element.
       * @type {string}
       */
      className: CLASS_NAMES.base,

      /**
       * Settings for the toggle and refresh button tooltips. Can be set to
       * false to disable tooltips entirely.
       * @type {object|boolean}
       */
      tooltipSettings: {
        variation: `${SEM_VARIATIONS.mini} ${SEM_VARIATIONS.inverted}`,
        position: "top center",
        on: "hover",
        hoverable: true,
        delay: {
          show: 250,
          hide: 40,
        },
      },

      /**
       * Initializes the VersionHistoryView and prepares the version tracker.
       * @param {object} [options] - Configuration options for the view.
       * @param {string} [options.pid] - The PID whose history should be shown.
       */
      initialize(options = {}) {
        this.pid = options.pid?.trim() || "";
        this.collection = new DataONEObjects();
        this.timelineGroups = new VersionTimelineGroups();
        this.chainAbortController = null;
        this.completed = false;
        MetacatUI.appModel.addCSS(VersionHistoryCSS, "versionHistoryView");
      },

      /**
       * Template for the static layout of the view.
       * @returns {string} HTML string representing the view skeleton.
       */
      template() {
        return `
        <header class="${CLASS_NAMES.header}" data-role="header">
          <h1>Version History</h1>
        </header>
        <div class="${CLASS_NAMES.status}" data-role="status" role="status"></div>
        <div class="${CLASS_NAMES.controls}">
          <button class="${CLASS_NAMES.refreshButton}" data-role="refresh-button"></button>
          <div class="${CLASS_NAMES.toggle}" data-role="toggle"></div>
        </div>
        <div class="${CLASS_NAMES.history}" data-role="list"></div>
        `.trim();
      },

      /**
       * Renders the base markup and loads version history for the current PID.
       * @returns {VersionHistoryView} The current view instance.
       */
      async render() {
        // Clean up any previous listeners & subviews
        this.onClose();

        // Begin with the toggle defaulted to "all" so all versions are shown as
        // they are found
        this.showDOIOnly = false;

        if (!MetacatUI.appUserModel.get("loggedIn")) {
          this.listenToOnce(MetacatUI.appUserModel, "change:loggedIn", () =>
            this.render(),
          );
        }

        if (!this.versionTracker) {
          const metaServiceUrl = await Utilities.awaitMetacatUI({
            property: "metaServiceUrl",
          });
          this.versionTracker = new VersionTracker({
            metaServiceUrl,
          });
        }

        this.el.innerHTML = this.template();
        this.statusEl = this.el.querySelector('[data-role="status"]');
        this.listEl = this.el.querySelector('[data-role="list"]');
        this.pidEl = this.el.querySelector('[data-role="pid"]');
        this.headerEl = this.el.querySelector(`[data-role="header"]`);
        this.toggleEl = this.el.querySelector(`[data-role="toggle"]`);
        this.refreshButtonEl = this.el.querySelector(
          `[data-role="refresh-button"]`,
        );

        this.timelineGroupsView?.remove();
        this.timelineGroupsView = new VersionTimelineGroupsView({
          el: this.listEl,
          collection: this.timelineGroups,
          referencePid: this.pid,
        }).render();

        if (!this.pid) {
          this.showError(
            'No document ID ("PID") was provided. Search for a dataset to see its version history.',
          );
          return this;
        }

        this.renderToggle();
        this.showLoading();
        this.renderHeader();
        this.listenToVersionsFound();
        this.findVersions();
        this.renderRefreshButton();

        return this;
      },

      /**
       * Fetches additional Solr metadata so the citation header can be
       * rendered.
       */
      renderHeader() {
        // temporarily show the PID until Solr data is fetched
        const pidHeader = document.createElement("h4");
        pidHeader.className = CLASS_NAMES.subtitle;
        // Avoid injecting unsanitized string into DOM (users could create
        // malicious versionHistory/<payload> URL)
        const htmlPid = Utilities.encodeHTML(this.pid);
        pidHeader.textContent = `ID: ${htmlPid}`;
        this.headerEl.appendChild(pidHeader);
        this.solrResultModel = new SolrResult({
          id: this.pid,
        });
        this.citationView = new CitationView({
          model: this.solrResultModel,
          createLink: false,
          createTitleLink: true,
        });

        this.listenToOnce(this.solrResultModel, "sync", () => {
          // remove the temporary PID header
          this.headerEl.removeChild(pidHeader);
          // render the citation view
          this.headerEl.appendChild(this.citationView.el);
          this.citationView.render();
        });
        this.solrResultModel.getInfo();
      },

      /**
       * Create the toggle view for showing all versions vs. DOI-only versions,
       * and listen for changes to update the timeline groups accordingly.
       */
      renderToggle() {
        this.toggle = new ToggleView({
          disabled: true, // start disabled until versions are loaded
          selected: "all",
          tooltipSettings: this.tooltipSettings,
          options: [
            {
              value: "all",
              label: "All Versions",
              tooltip: "Show all versions regardless of DOI status",
              icon: "list",
            },
            {
              value: "doi",
              label: "DOI Only",
              tooltip: "Only show versions published with a DOI",
              icon: "star",
            },
          ],
        }).render();
        this.toggleEl.appendChild(this.toggle.el);
        this.listenTo(this.toggle, "toggle:change", this.onToggle);
      },

      /**
       * When the toggle is changed, update the "hiddenByUI" attribute on each object
       * model to control whether it is shown in the timeline groups.
       * @param {"all"|"doi"} value The currently selected toggle value.
       */
      onToggle(value) {
        const showDOIOnly = value === "doi";
        if (this.showDOIOnly === showDOIOnly) return; // No change
        this.showDOIOnly = showDOIOnly;
        let anyChanged = false;
        this.collection.each((model) => {
          const hidden = showDOIOnly && !model.isDOI();
          if (model.get("hiddenByUI") !== hidden) {
            model.set("hiddenByUI", hidden, { silent: true });
            anyChanged = true;
          }
        });
        if (!anyChanged) return;
        this.timelineGroupsView.updateVisualState();
      },

      /**
       * Renders the refresh button, which allows users to clear stored versions
       * and re-fetch the version history from the server.
       */
      renderRefreshButton() {
        const refreshButton = this.refreshButtonEl;
        if (!refreshButton) return;
        // refreshButton.className = "btn btn-secondary";
        refreshButton.innerHTML = `${REFRESH_ICON} Refresh`;
        refreshButton.title =
          "Clear all stored versions and re-fetch the version history from the server.";
        refreshButton.addEventListener("click", () => this.refresh());
        // add a tooltip to the refresh button
        $(refreshButton).tooltip({
          ...this.tooltipSettings,
          content: refreshButton.title,
        });
      },

      /**
       * Refresh the version history by clearing the VersionTracker cache and
       * re-rendering the view (which will trigger a new fetch).
       */
      async refresh() {
        try {
          await this.versionTracker.clearCache();
          await this.render();
        } catch (error) {
          this.showError(
            `Failed to clear version history cache: ${error.message || error}`,
          );
        }
      },

      /**
       * Sets up listeners to respond to VersionTracker updates and to mirror
       * the fetched collection into the timeline groups collection.
       */
      listenToVersionsFound() {
        const { pid, versionTracker } = this;
        // when the version tracker finds a new version, update collection & UI
        this.stopListening(versionTracker.events, `versionFound:${pid}`);
        this.listenTo(
          versionTracker.events,
          `versionFound:${pid}`,
          (sysMeta) => {
            this.onVersionFound(sysMeta);
          },
        );
        // When the collection updates, keep the timeline collection in sync
        this.stopListening(this.collection, "update reset");
        this.listenTo(this.collection, "update reset", () =>
          this.timelineGroups?.fromDataONEObjects(this.collection, {
            remove: true,
            referencePid: this.pid,
          }),
        );
      },

      /**
       * Requests the full version chain for the view's PID.
       * @returns {Promise<void>}
       */
      async findVersions() {
        this.completed = false;
        if (this.toggle) {
          this.toggle.disable();
        }
        let controller = null;
        try {
          const { pid, versionTracker } = this;
          // Stop any ongoing fetches
          this.abortChainFetch(
            "VersionHistoryView: Starting new version fetch",
          );
          // Make a new abort controller for this fetch
          controller =
            typeof AbortController === "function"
              ? new AbortController()
              : null;
          this.chainAbortController = controller;
          const signal = controller?.signal;

          // Mock a record update for the current PID to show initial progress
          const thisSysMeta = new VersionTracker.SysMeta({
            identifier: pid,
            versionHistory: { [pid]: 0 },
          });
          this.onVersionFound(thisSysMeta);

          const record = await versionTracker.getAllVersions(pid, { signal });

          // If the fetch was aborted, don't update the view with any results
          const isCurrent = this.chainAbortController === controller;
          if (!isCurrent) return;

          this.completed = true;

          if (!record) {
            throw new Error(
              `No version history found for the document with ID: <strong>${pid}</strong>.`,
            );
          }

          // Once all versions are found, show summary status
          const errorMessage = this.getErrorMessage(record);
          const { next, prev } = record;

          this.numNext = next.completedSteps || 0;
          this.numPrev = Math.abs(prev.completedSteps || 0);

          // If there were no errors and the full chain was traversed
          if (next.chainComplete && prev.chainComplete) {
            this.showComplete(errorMessage);
          } else {
            this.showPartial(errorMessage);
          }

          // Apply any detected out-of-order date notes to individual versions
          const dateConflicts = [
            ...record.next.dateConflicts,
            ...record.prev.dateConflicts,
          ];
          this.applyDateNotes(dateConflicts);
        } catch (error) {
          if (error?.name === "AbortError") {
            return;
          }

          this.showError(error.message || error);
        } finally {
          const isCurrent = this.chainAbortController === controller;
          if (isCurrent) {
            this.completed = true;
            this.chainAbortController = null;
            // Enable the toggle now that versions have loaded (even if there was
            // an error, some versions may have been found)
            if (this.toggle) this.toggle.enable();
            this.addTooltips();
          }
        }
      },

      /** Add tooltips to Version views once they're rendered and in the DOM. */
      addTooltips() {
        this.timelineGroupsView?.addTooltips();
      },

      /**
       * Presents a loading message in the status region.
       */
      showLoading() {
        this.updateStatus(
          `${SPINNER} Getting versions for the document with ID: <strong>${this.pid}</strong>...`,
        );
      },

      /**
       * Updates the status region to show progress as versions are found.
       * @param {string} [message] - Custom progress message. If not provided, a
       * default message based on the number of versions found will be shown.
       */
      showProgress(message) {
        // Don't update the progress message if we've already found all
        // versions, or else we might overwrite the status message set in
        // showComplete or showPartial (or error)
        if (this.completed) return;
        const defaultMessage =
          `Found ${this.numNext} newer versions and ${this.numPrev} older ` +
          `versions for the document with ID: <strong>${this.pid}</strong>. Still searching...`;
        this.updateStatus(`${SPINNER} ${message || defaultMessage}`);
      },

      /**
       * Displays an error banner in the status region.
       * @param {string} message The error message to display.
       */
      showError(message) {
        this.updateStatus(
          `<p class="text-danger">❌ Error: ${message}</p>`,
          "danger",
        );
      },

      /**
       * Displays a warning banner in the status region.
       * @param {string} message The warning message to display.
       */
      showWarning(message) {
        this.updateStatus(`<b>⚠️ Warning:</b> ${message}`, "warning");
      },

      /**
       * Displays a success banner once the VersionTracker finishes traversing.
       * @param {string} [error] An optional error message to append.
       */
      showComplete(error) {
        let type = "success";
        let message = this.getVersionSummaryMessage();
        if (this.numNext === 0 && this.numPrev === 0) {
          message += ` This document is the first and only version so far.`;
        }
        if (error) {
          message += `. ${error}`;
          type = "warning";
        }
        this.updateStatus(message, type);
      },

      /**
       * Displays a warning banner if the VersionTracker could not fully
       * traverse the version chain.
       * @param {string} [error] An optional error message to append.
       */
      showPartial(error) {
        let message = "Incomplete version history. ";
        if (error) {
          message += ` ${error}`;
        }
        if (this.numNext === 0 && this.numPrev === 0) {
          message += `No other versions of the document with ID <strong>${this.pid}</strong> were found.`;
        } else {
          message += " ";
          message += this.getVersionSummaryMessage();
        }
        this.showWarning(message);
      },

      /**
       * Generates HTML strings for the number of next and previous versions.
       * @returns {string} A string summarizing the number of versions found for
       * the current PID.
       */
      getVersionSummaryMessage() {
        const { numNext, numPrev, pid } = this;
        const htmlPid = Utilities.encodeHTML(pid);
        const nextStr = VERSION_STR(numNext, true);
        const prevStr = VERSION_STR(numPrev, false);
        const dateRange = this.collection.getDateRange();
        let dateStr = "";
        if (dateRange) {
          dateStr = DateUtility.getRelativeDateString(
            dateRange.minDate,
            dateRange.maxDate,
            { newerWord: "", olderWord: "", currentWord: "" },
          ).trim();
          if (dateStr) {
            dateStr = ` spanning <b>${dateStr}</b>`;
          }
        }
        return `Found ${nextStr} and ${prevStr}${dateStr} for the document with ID: <strong>${htmlPid}</strong>.`;
      },

      /**
       * Updates the status alert with the provided HTML payload.
       * @param {string} message - HTML string.
       * @param {"info"|"danger"|"success"|"warning"} [type] Type of alert to
       * show.
       */
      updateStatus(message, type = "info") {
        this.statusEl.innerHTML = message;
        this.statusEl.classList.remove(
          "alert-info",
          "alert-danger",
          "alert-success",
          "alert-warning",
        );
        this.statusEl.classList.add(`alert-${type}`);
      },

      /** Remove any row-level date note flags from version models. */
      clearDateNotes() {
        if (this.collection) {
          this.collection.each((model) => {
            if (model.has("versionDateNote")) {
              model.unset("versionDateNote");
            }
          });
        }
      },

      /**
       * Apply row-level notes for detected version date conflicts, i.e.
       * cases where the date a obsoleting version was uploaded is *earlier*
       * than the date of the version it obsoletes.
       * @param {VersionTracker.DateConflict[]} conflicts An array of
       * detected version date conflicts. If not provided, any existing row
       * notes will be cleared.
       */
      applyDateNotes(conflicts = []) {
        this.clearDateNotes();
        if (
          !this.collection ||
          !Array.isArray(conflicts) ||
          !conflicts.length
        ) {
          return;
        }
        conflicts.forEach((conflict) => {
          // Mark the earlier-in-chain version because that is the one with the
          // out-of-order upload date (uploaded after the version that obsoletes
          // it)
          const { prevPid } = conflict;
          const prevModel = this.collection.get(prevPid);
          prevModel?.set("versionDateNote", conflict);
        });
      },

      /**
       * Runs when the VersionTracker notifies the view that a new version
       * of the PID has been found.
       * @param {object} sysMeta The system metadata object for the found
       * version.
       */
      onVersionFound(sysMeta) {
        if (!sysMeta || !sysMeta?.data?.identifier) {
          return;
        }

        // When requesting sysMeta for a series ID, Metacat will return the latest
        // version in that series.
        if (sysMeta.seriesId === this.pid) {
          // Delete the record with the series ID because we don't want it to
          // appear as a separate version in the timeline
          this.collection.remove(this.pid);
          if (this.pid !== sysMeta.data.identifier) {
            this.pid = sysMeta.data.identifier;
            this.render();
          }
          return;
        }

        const index = sysMeta.versionHistory?.[this.pid];
        if (index > 0) {
          this.numNext = index;
        } else if (index < 0) {
          this.numPrev = Math.abs(index);
        }
        const sysMetaData = sysMeta.toJSON(true, ["versionHistory", "errors"]);
        // For merging purposes, ensure id is set to identifier
        sysMetaData.id = sysMetaData.identifier;
        this.collection.add(sysMetaData, { merge: true });
        this.showProgress();
      },

      /**
       * Generates an error message based on the obsolescence chain record.
       * @param {object} record The version tracker record for the current PID.
       * @returns {string} A concatenated error message based on the record's
       * properties.
       */
      getErrorMessage(record) {
        const messages = [];
        const { next, prev } = record;

        // If versions in the obsolesence chain are private
        const nextPrivate = next.endIsPrivate || false;
        const prevPrivate = prev.endIsPrivate || false;
        if (nextPrivate || prevPrivate) {
          messages.push(ERROR_TEXT["401"]);
        }

        // If versions in the obsolesence chain dont have associated sysmeta
        const next404 = next.endNotFound || false;
        const prev404 = prev.endNotFound || false;
        if (next404 && prev404) {
          if (next.completedSteps === 0 && prev.completedSteps === 0) {
            return ERROR_TEXT.All404;
          }
          messages.push(ERROR_TEXT.Some404);
        } else if (next404) {
          messages.push(ERROR_TEXT.Next404);
        } else if (prev404) {
          messages.push(ERROR_TEXT.Prev404);
        }

        const nextMaxed = next.maxHopsReached || false;
        const prevMaxed = prev.maxHopsReached || false;
        if (nextMaxed && prevMaxed) {
          messages.push(ERROR_TEXT.bothMaxHopsReached);
        } else if (nextMaxed) {
          messages.push(ERROR_TEXT.nextMaxHopsReached);
        } else if (prevMaxed) {
          messages.push(ERROR_TEXT.prevMaxHopsReached);
        }

        return messages.join(" ");
      },

      /**
       * Cleans up listeners, nested views, and collections before re-rendering
       * or destroying the view.
       */
      onClose() {
        this.completed = false;
        this.numNext = 0;
        this.numPrev = 0;
        this.clearDateNotes();
        this.abortChainFetch(
          "VersionHistoryView: Cleaning up before re-render or destroy",
        );
        if (this.versionTracker?.events) {
          this.stopListening(this.versionTracker?.events);
        }
        if (this.solrResultModel) {
          this.stopListening(this.solrResultModel);
        }
        if (this.collection) {
          this.stopListening(this.collection);
        }
        this.citationView?.remove();
        this.timelineGroupsView?.remove();
        this.timelineGroupsView = null;
        this.timelineGroups?.reset();
        this.citationView = null;
        this.solrResultModel = null;
        this.collection?.reset();
        this.refreshButtonEl = null;
      },

      /**
       * Abort the current request to find all versions
       * @param {string} [reason] Optional reason for aborting, useful for
       * debugging.
       */
      abortChainFetch(reason) {
        if (this.chainAbortController) {
          this.chainAbortController.abort(reason);
          this.chainAbortController = null;
        }
      },

      /** @inheritdoc */
      remove() {
        this.onClose();
        return Backbone.View.prototype.remove.call(this);
      },
    },
  );

  return VersionHistoryView;
});