Source: src/js/views/DataObjectView.js

"use strict";

define([
  "underscore",
  "backbone",
  "models/SolrResult",
  "views/TableEditorView",
  "text!templates/loading.html",
  "text!templates/alert.html",
], (
  _,
  Backbone,
  SolrResult,
  TableEditorView,
  LoadingTemplate,
  AlertTemplate,
) => {
  // The classes used by this view
  const BASE_CLASS = "object-view";
  const CLASS_NAMES = {
    base: BASE_CLASS,
    well: "well", // Bootstrap class
    downloadButton: ["btn", "download"],
    downloadIcon: ["icon", "icon-cloud-download"],
    loadingContainer: ["notification", "loading"],
  };

  // User-facing text
  const DOWNLOAD_BUTTON_TEXT = "Download";
  const LOADING_MESSAGE = "Downloading data...";
  const PARSE_RESPONSE_MESSAGE = "Parsing data...";
  const ERROR_TITLE = "Uh oh 😕";
  const ERROR_MESSAGE =
    "There was an error displaying the object. Please try again later or send us an email.";
  const MORE_DETAILS_PREFIX = "<strong>More details: </strong>";
  const FILE_TYPE_ERROR = "This file type is not supported.";
  const FILE_SIZE_ERROR = "This file is too large to display.";
  const GETINFO_ERROR =
    "There was an error retrieving metadata for this data object.";

  // Fields for metadata values that we require to display the object
  const REQUIRED_FIELDS = ["formatId", "size"];
  const OPTIONAL_FIELDS = ["fileName"];

  /**
   * @class DataObjectView
   * @classdesc A view that downloads and displays a DataONE object. Currently
   * there is support for displaying CSV files as a table.
   * @classcategory Views
   * @augments Backbone.View
   * @class
   * @since 2.32.0
   * @screenshot views/DataObjectView.png
   */
  const DataObjectView = Backbone.View.extend(
    /** @lends DataObjectView.prototype */
    {
      /** @inheritdoc */
      type: "DataObjectView",

      /** @inheritdoc */
      className: CLASS_NAMES.base,

      /** @inheritdoc */
      tagName: "div",

      /**
       * The template for the loading spinner
       * @type {UnderscoreTemplate}
       */
      loadingTemplate: _.template(LoadingTemplate),

      /**
       * The template for the alert message
       * @type {UnderscoreTemplate}
       */
      alertTemplate: _.template(AlertTemplate),

      /**
       * The file size limit for viewing files, in bytes. If the file is larger
       * than this limit, the object will not be displayed. Default is 20 megabytes.
       * @type {number|null}
       */
      sizeLimit: 20971520,

      /**
       * The data formats that are supported by this view, mapped to the
       * functions that render them, i.e. { renderFunction: [format1, format2]
       * }. Formats should include all relevant DataONE object formats as well
       * as the possible Content-Type values from the headers of the response.
       * @type {object}
       */
      formatMap: {
        renderTable: ["text/csv", "text/tsv", "text/tab-separated-values"],
      },

      /**
       * Initializes the DataObjectView
       * @param {object} options - Options for the view
       * @param {SolrResult} options.model - A SolrResult model
       * @param {string} options.id - The ID of the DataONE object to view. Used
       * to create a SolrResult model if one is not provided.
       * @param {Element} [options.buttonContainer] - The container for the
       * download button (defaults to the view's element)
       */
      initialize(options) {
        this.model = options.model;
        this.buttonContainer = options.buttonContainer || this.el;
        if (!this.model && options.id) {
          this.model = new SolrResult({ id: options.id });
        }
      },

      /**
       * Checks if the size and format of the object are valid
       * @returns {boolean|object} True if the size and format are valid, or an
       * object with error messages if they are not
       */
      isValidSizeAndFormat() {
        const format =
          this.model.get("formatId") || this.model.get("mediaType");
        const size = this.model.get("size");

        const sizeValid = !this.sizeLimit || size <= this.sizeLimit;

        const supportedFormats = Object.values(this.formatMap).flat();
        const formatValid = supportedFormats.includes(format);
        if (sizeValid && formatValid) {
          return true;
        }
        const errors = {};
        if (!sizeValid) {
          errors.sizeValid = FILE_SIZE_ERROR;
        }
        if (!formatValid) {
          errors.formatValid = FILE_TYPE_ERROR;
        }
        return errors;
      },

      /** @inheritdoc */
      render() {
        // If missing formatId, size, filename, query Solr first
        if (!this.hasRequiredMetadata()) {
          this.fetchMetadata(this.render);
          return this;
        }

        const valid = this.isValidSizeAndFormat();

        if (valid !== true) {
          this.showError(Object.values(valid).join(" "));
          return this;
        }
        this.$el.empty();
        this.showLoading(LOADING_MESSAGE);
        this.downloadObject()
          .then((response) => this.handleResponse(response))
          .catch((error) => this.showError(error?.message || error));
        return this;
      },

      /**
       * Checks if the model has the required metadata fields
       * @returns {boolean} True if the model has the required metadata fields
       * and they are not empty
       */
      hasRequiredMetadata() {
        if (!this.model) return false;
        return REQUIRED_FIELDS.every((field) => {
          const val = this.model.get(field);
          return val && val !== 0;
        });
      },

      /**
       * Fetches the metadata for the object
       * @param {Function} callback - The function to call when the metadata is
       * fetched
       */
      fetchMetadata(callback) {
        const view = this;
        const { model } = view;
        view.stopListening(model);
        const fields = REQUIRED_FIELDS.concat(OPTIONAL_FIELDS).join(",");

        view.listenTo(model, "sync", () => {
          if (view.hasRequiredMetadata()) {
            callback.call(view);
          } else {
            view.showError(GETINFO_ERROR);
          }
          view.stopListening(model);
        });

        view.listenTo(model, "getInfoError", () => {
          view.showError(GETINFO_ERROR);
          view.stopListening(model);
        });

        model.getInfo(fields);
      },

      /**
       * Indicate that the data is loading
       * @param {string} [message] - The message to display while loading
       */
      showLoading(message) {
        this.$el.html(
          this.loadingTemplate({
            msg: message || LOADING_MESSAGE,
          }),
        );
        this.el.classList.add(CLASS_NAMES.well);
      },

      /** Remove the loading spinner */
      hideLoading() {
        this.el.classList.remove(CLASS_NAMES.well);
        this.$el.find(`.${CLASS_NAMES.loadingContainer.join(".")}`).remove();
      },

      /**
       * Display an error message to the user
       * @param {string} message - The error message to display
       */
      showError(message) {
        this.hideLoading();
        const alertTitle = `<center><h3>${ERROR_TITLE}</h3></center>`;
        let alertMessage = alertTitle + ERROR_MESSAGE;
        if (message) {
          alertMessage += `<br><br>${MORE_DETAILS_PREFIX}${message}`;
        }
        this.$el.html(
          this.alertTemplate({
            includeEmail: true,
            msg: alertMessage,
            remove: false,
          }),
        );
      },

      /**
       * Handle the response from the DataONE object API. Renders the data and
       * shows the download button if the response is successful.
       * @param {Response} response - The response from the DataONE object API
       */
      handleResponse(response) {
        if (response.ok) {
          this.hideLoading();
          this.$el.html("");
          // Response can only be consumed once (e.g. to text), so keep a copy
          // to convert to a blob for downloading if user requests it.
          this.response = response.clone();
          this.renderObject(response);
          this.renderDownloadButton();
        } else {
          this.showError(response.statusText);
        }
      },

      /**
       * With the already fetched DataONE object, check the format and render
       * the object accordingly.
       * @param {Response} response - The response from the DataONE object API
       */
      renderObject(response) {
        try {
          this.showLoading(PARSE_RESPONSE_MESSAGE);
          const format = response.headers.get("Content-Type");

          // Map format to render function
          const methods = this.formatMap;
          // Find the key which includes the format in the array value
          const method = Object.keys(methods).find((key) =>
            methods[key].includes(format),
          );
          if (!method) {
            throw new Error(FILE_TYPE_ERROR);
          }

          this[method](response);
        } catch (error) {
          this.showError(error?.message || error);
        }
      },

      /** Renders a download button */
      renderDownloadButton() {
        const view = this;
        const downloadButton = document.createElement("a");
        downloadButton.textContent = DOWNLOAD_BUTTON_TEXT;
        downloadButton.classList.add(...CLASS_NAMES.downloadButton);
        const icon = document.createElement("i");
        icon.classList.add(...CLASS_NAMES.downloadIcon);
        downloadButton.appendChild(icon);
        downloadButton.onclick = (e) => {
          e.preventDefault();
          const response = view.response.clone();
          view.model.downloadFromResponse(response);
        };
        this.buttonContainer.appendChild(downloadButton);
      },

      /**
       * Downloads the DataONE object
       * @returns {Promise} Promise that resolves with the Response from DataONE
       */
      downloadObject() {
        return this.model.fetchDataObjectWithCredentials();
      },

      /**
       * Shows the CSV file as a table
       * @param {Response} response - The response from the DataONE object API
       */
      renderTable(response) {
        response.text().then((text) => {
          this.csv = text;
          this.hideLoading();
          this.table = new TableEditorView({
            viewMode: true,
            csv: this.csv,
          });
          this.listenTo(this.table, "error", (message) => {
            this.showError(message);
            requestAnimationFrame(() => this.table.remove());
          });
          this.table.render();
          this.el.appendChild(this.table.el);
        });
      },
    },
  );

  return DataObjectView;
});