Source: src/js/views/metadata/EMLEntityView.js

define([
  "underscore",
  "jquery",
  "backbone",
  "localforage",
  "models/metadata/eml211/EMLEntity",
  "views/DataPreviewView",
  "views/metadata/EMLAttributesView",
  "text!templates/metadata/eml-entity.html",
  "common/XMLUtilities",
], (
  _,
  $,
  Backbone,
  LocalForage,
  EMLEntity,
  DataPreviewView,
  EMLAttributesView,
  EMLEntityTemplate,
  XMLUtilities,
) => {
  /**
   * @class EMLEntityView
   * @classdesc An EMLEntityView shows the basic attributes of a DataONEObject,
   * as described by EML
   * @classcategory Views/Metadata
   * @screenshot views/metadata/EMLEntityView.png
   * @augments Backbone.View
   */
  const EMLEntityView = Backbone.View.extend(
    /** @lends EMLEntityView.prototype */ {
      tagName: "div",

      /** @inheritdoc */
      className: "eml-entity modal hide fade",

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

      /** @inheritdoc */
      events: {
        change: "saveDraft",
        "change input": "updateModel",
        "change textarea": "updateModel",
        "click .entity-container > .nav-tabs a": "showTab",
      },

      /** @inheritdoc */
      initialize(options = {}) {
        this.model = options.model || new EMLEntity();
        this.DataONEObject = options.DataONEObject;
      },

      /** @inheritdoc */
      render() {
        this.renderEntityTemplate();

        this.renderPreview();

        this.renderAttributes();

        this.listenTo(this.model, "invalid", this.showValidation);
        this.listenTo(this.model, "valid", this.showValidation);

        return this;
      },

      /**
       * Render the entity template
       */
      renderEntityTemplate() {
        const view = this;

        // Render the template using the model attributes
        const modelAttr = this.model.toJSON();
        modelAttr.title = modelAttr.entityName || "this data";
        modelAttr.uniqueId = this.model.cid;
        this.el.innerHTML = this.template(modelAttr);

        // Initialize the modal window
        this.$el.modal();

        // Set the menu height
        this.$el.off("shown");
        this.$el.off("hidden");
        this.$el.on("hidden", () => {
          view.onHide();
        });
        this.$el.on("shown", () => {
          view.onShow();
        });
      },

      /**
       * Render the preview of the DataONEObject
       */
      renderPreview() {
        // Get the DataONEObject model
        if (this.DataONEObject) {
          const dataPreview = new DataPreviewView({
            model: this.DataONEObject,
          });
          dataPreview.render();
          this.$(".preview-container").html(dataPreview.el);

          if (dataPreview.$el.children().length) {
            this.$(".description").css("width", "calc(100% - 310px)");
          } else dataPreview.$el.remove();
        }
      },

      /**
       * Render the attributes of the entity
       */
      renderAttributes() {
        const container = this.el.querySelector(
          ".attributes .attribute-container",
        );

        // Render the attributes
        const attributesCollection = this.model.get("attributeList");
        this.attributesView = new EMLAttributesView({
          el: container,
          collection: attributesCollection,
          parentModel: this.model,
        }).render();
      },

      /**
       * Update the model when the user changes an input field
       * @param {Event} e - The change event
       */
      updateModel(e) {
        const changedAttr = $(e.target).attr("data-category");

        if (!changedAttr) return;
        const newValue = XMLUtilities.cleanXMLText($(e.target).val());
        this.model.set(changedAttr, newValue);
        this.model.trickleUpChange();
      },

      /**
       * Will display validation styling and messaging. Should be called after
       * this view's model has been validated and there are error messages to
       * display
       */
      showValidation() {
        // Reset the error messages and styling Only change elements inside the
        // overview-container which contains only the EMLEntity metadata. The
        // Attributes will be changed by the EMLAttributeView.
        this.$(".overview-container .notification").text("");
        this.$(
          ".overview-tab .icon.error, .attributes-tab .icon.error",
        ).remove();
        this.$(
          ".overview-container, .overview-tab a, .attributes-tab a, .overview-container .error",
        ).removeClass("error");

        let overviewTabErrorIcon = false;
        const attributeTabErrorIcon = false;

        _.each(
          this.model.validationError,
          (errorMsg, category) => {
            if (category === "attributeList") {
              // Create an error icon for the Attributes tab
              if (!attributeTabErrorIcon) {
                const errorIcon = $(document.createElement("i"))
                  .addClass("icon icon-on-left icon-exclamation-sign error")
                  .attr("title", "There is missing information in this tab");

                // Add the icon to the Overview tab
                this.$(".attributes-tab a")
                  .prepend(errorIcon)
                  .addClass("error");
              }

              return;
            }

            // Get all the elements for this category and add the error class
            this.$(
              `.overview-container [data-category='${category}']`,
            ).addClass("error");
            // Get the notification element for this category and add the error
            // message
            this.$(
              `.overview-container .notification[data-category='${category}']`,
            ).text(errorMsg);

            // Create an error icon for the Overview tab
            if (!overviewTabErrorIcon) {
              const errorIcon = $(document.createElement("i"))
                .addClass("icon icon-on-left icon-exclamation-sign error")
                .attr("title", "There is missing information in this tab");

              // Add the icon to the Overview tab
              this.$(".overview-tab a").prepend(errorIcon).addClass("error");

              overviewTabErrorIcon = true;
            }
          },
          this,
        );
      },

      /**
       * Show the entity overview or attributes tab depending on the click
       * target
       * @param {Event} e - The click event
       */
      showTab(e) {
        e.preventDefault();

        // Get the clicked link
        const link = $(e.target);

        // Remove the active class from all links and add it to the new active
        // link
        this.$(".entity-container > .nav-tabs li").removeClass("active");
        link.parent("li").addClass("active");

        // Hide all the panes and show the correct one
        this.$(".entity-container > .tab-content > .tab-pane").hide();
        this.$(link.attr("href")).show();
      },

      /**
       * Show the entity in a modal dialog
       */
      show() {
        this.$el.modal("show");
        this.el.display = "grid"; // override the display:block from bootstrap
      },

      /**
       * Hide the entity modal dialog
       */
      onHide() {
        this.showValidation();
        this.attributesView?.onClose();
      },

      /**
       * Handles the logic to be executed when thw modal view is shown. Restarts
       * existing listeners on the attributesView.
       */
      onShow() {
        this.attributesView?.stopAllListeners();
        this.attributesView?.startAllListeners();
        // Since we remove all empty attributes when modal is closed, make sure
        // that there is always an empty attribute to fill in
        this.attributesView?.addNewAttribute();
      },

      /**
       * Save a draft of the parent EML model
       */
      saveDraft() {
        const view = this;
        const model = this.model.getParentEML();
        const draftModel = model.clone();
        const title = model.get("title") || "No title";

        LocalForage.setItem(model.get("id"), {
          id: model.get("id"),
          datetime: new Date().toISOString(),
          title: Array.isArray(title) ? title[0] : title,
          draft: draftModel.serialize(),
        }).then(() => {
          view.clearOldDrafts();
        });
      },

      /**
       * Clear older drafts by iterating over the sorted list of drafts stored
       * by LocalForage and removing any beyond a hardcoded limit.
       */
      clearOldDrafts() {
        let drafts = [];
        LocalForage.iterate((value, key) => {
          // Extract each draft
          drafts.push({
            key,
            value,
          });
        })
          .then(() => {
            // Sort by datetime
            drafts = _.sortBy(drafts, (draft) =>
              draft.value.datetime.toString(),
            ).reverse();
          })
          .then(() => {
            _.each(drafts, (draft, i) => {
              const age = new Date() - new Date(draft.value.datetime);
              const isOld = age / 2678400000 > 1; // ~31days
              // Delete this draft is not in the most recent 100 or if older
              // than 31 days
              const shouldDelete = i > 100 || isOld;
              if (!shouldDelete) {
                return;
              }

              LocalForage.removeItem(draft.key).then(() => {
                // Item should be removed
              });
            });
          });
      },
    },
  );

  return EMLEntityView;
});