Source: src/js/views/EditorView.js

define([
  "underscore",
  "jquery",
  "backbone",
  "views/SignInView",
  "text!templates/editorSubmitMessage.html",
], function (_, $, Backbone, SignInView, EditorSubmitMessageTemplate) {
  /**
   * @class EditorView
   * @classdesc A basic shell of a view, primarily meant to be extended for views that allow editing capabilities.
   * @classcategory Views
   * @name EditorView
   * @extends Backbone.View
   * @constructs
   */
  var EditorView = Backbone.View.extend(
    /** @lends EditorView.prototype */ {
      /**
       * References to templates for this view. HTML files are converted to Underscore.js templates
       */
      editorSubmitMessageTemplate: _.template(EditorSubmitMessageTemplate),

      /**
       * The element this view is contained in. A jQuery selector or the element itself.
       * @type {string|DOMElement}
       */
      el: "#Content",

      /**
       * The text to use in the editor submit button
       * @type {string}
       */
      submitButtonText: "Save",

      /**
       * The text to use in the editor submit button
       * @type {string}
       */
      accessPolicyModalID: "editor-access-policy-modal",

      /**
       * The selector for the HTML element that will contain a button/link/control for
       * opening the AccessPolicyView modal window. If this element doesn't exist on the page,
       * then the AccessPolicyView will be inserted into the `accessPolicyViewContainer` directly, rather than a modal window.
       * @type {string}
       */
      accessPolicyControlContainer: ".access-policy-control-container",

      /**
       * The selector for the HTML element that will contain the AccessPolicyView.
       * If this element doesn't exist on the page, then the AccessPolicyView will not be inserted into the page.
       * If a `accessPolicyControlContainer` element is on the page, then this element will
       * contain the modal window element.
       * @type {string}
       */
      accessPolicyViewContainer: ".access-policy-view-container",
      /**
       * The events this view will listen to and the associated function to call
       * @type {Object}
       */
      events: {
        "click #save-editor": "save",
        "click .access-policy-control": "showAccessPolicyModal",
        "keypress input:not(.ignore-changes)": "showControls",
        "keypress textarea:not(.ignore-changes)": "showControls",
        "keypress [contenteditable]:not(.ignore-changes)": "showControls",
        "click .image-uploader": "showControls",
        "change .access-policy-view": "showControls",
        "click .access-policy-view .remove": "showControls",
      },

      /**
       * Renders this view
       */
      render: function () {
        //Style the body as an Editor
        $("body").addClass("Editor rendering");

        this.delegateEvents();

        //If there is no active alternate repository, set one
        if (
          !MetacatUI.appModel.getActiveAltRepo() &&
          MetacatUI.appModel.get("alternateRepositories").length
        ) {
          MetacatUI.appModel.setActiveAltRepo();
        }
      },

      /**
       * Set listeners on the view's model.
       * This function centralizes all the listeners so that when/if the view's
       * model is replaced, the listeners can be reset.
       */
      setListeners: function () {
        //Stop listening first
        this.stopListening(this.model, "errorSaving", this.saveError);
        this.stopListening(this.model, "successSaving", this.saveSuccess);
        this.stopListening(this.model, "invalid", this.showValidation);

        //Set listeners
        this.listenTo(this.model, "errorSaving", this.saveError);
        this.listenTo(this.model, "successSaving", this.saveSuccess);
        this.listenTo(this.model, "invalid", this.showValidation);

        // //Set a beforeunload event only if there isn't one already
        // if( !this.beforeunloadCallback ){
        //   var view = this;
        //   //When the Window is about to be closed, show a confirmation message
        //   this.beforeunloadCallback = function(e){
        //     if( !view.canClose() ){
        //       //Browsers don't support custom confirmation messages anymore,
        //       // so preventDefault() needs to be called or the return value has to be set
        //       e.preventDefault();
        //       e.returnValue = "";
        //     }
        //     return;
        //   }
        //   window.addEventListener("beforeunload", this.beforeunloadCallback);
        // }
      },

      /**
       * Show Sign In buttons
       */
      showSignIn: function () {
        var container = $(document.createElement("div")).addClass(
          "container center",
        );
        this.$el.html(container);
        var signInButtons = new SignInView().render().el;
        $(container).append("<h1>Sign in to submit data</h1>", signInButtons);
      },

      /**
       * Saves the model
       */
      save: function () {
        this.showSaving();
        this.model.save();
      },

      /**
       * Cancel all edits in the editor by simply re-rendering the view
       */
      cancel: function () {
        this.render();
      },

      /**
       * Trigger a save error with a message that the save was cancelled
       */
      handleSaveCancel: function () {
        if (this.model.get("uploadStatus") == "e") {
          this.saveError("Your submission was cancelled due to an error.");
        }
      },

      /**
       * Adds top-level control elements to this editor.
       */
      renderEditorControls: function () {
        //If the AccessPolicy editor is enabled, add a button for opening it
        if (MetacatUI.appModel.get("allowAccessPolicyChanges")) {
          this.renderAccessPolicyControl();
        }
      },

      /**
       * Adds a Share button for editing the access policy
       */
      renderAccessPolicyControl: function () {
        //If the AccessPolicy editor is enabled, add a button for opening it
        if (this.isAccessPolicyEditEnabled()) {
          var isHiddenBehindControl =
            this.$(this.accessPolicyControlContainer).length > 0;

          //Render the AccessPolicy control, if the container element is on the page
          if (isHiddenBehindControl) {
            //If it isn't, then add it to the page.
            //Create an anchor tag with an icon and the text "Share" and add it to the editor controls container
            this.$(this.accessPolicyControlContainer).prepend(
              $(document.createElement("a"))
                .attr("href", "#")
                .addClass("access-policy-control btn")
                .append(
                  $(document.createElement("i")).addClass(
                    "icon-group icon icon-on-left",
                  ),
                  "Share",
                ),
            );
          }

          //If the authorization has already been checked
          if (this.model.get("isAuthorized_changePermission") === true) {
            //Render the AccessPolicyView
            this.renderAccessPolicy();
          } else {
            //When the user's changePermission authority has been checked, edit their
            //  access to the AccessPolicyView
            this.listenToOnce(
              this.model,
              "change:isAuthorized_changePermission",
              function () {
                //If there is an AccessPolicy control, disable it
                if (isHiddenBehindControl) {
                  if (
                    this.model.get("isAuthorized_changePermission") === false
                  ) {
                    //Disable the button for the AccessPolicyView if the user is not authorized
                    this.$(".access-policy-control")
                      .attr("disabled", "disabled")
                      .attr(
                        "title",
                        "You do not have access to change the " +
                          MetacatUI.appModel.get("accessPolicyName"),
                      )
                      .addClass("disabled");
                  }
                } else {
                  //Render the AccessPolicyView
                  this.renderAccessPolicy();
                }
              },
            );

            //Check the user's authority to change permissions on this object
            this.model.checkAuthority("changePermission");
          }
        }
      },

      /**
       * Shows the AccessPolicyView for the object being edited.
       *
       * @param {Event} e - The click event
       * @param {Backbone.Model | null} model - The model to show the view for. If
       *   null, defaults to the model set for the view.
       */
      showAccessPolicyModal: function (e, model) {
        try {
          // If the AccessPolicy editor is disabled in this app, or the specific
          // .access-policy-control has theh class diasbled, then exit now
          if (
            !MetacatUI.appModel.get("allowAccessPolicyChanges") ||
            this.$(".access-policy-control").attr("disabled") == "disabled" ||
            (e.currentTarget && $(e.currentTarget).hasClass("disabled"))
          ) {
            return;
          }

          this.renderAccessPolicy(model);

          this.on("accessPolicyViewRendered", function () {
            //Add modal classes to the access policy view
            this.$(".access-policy-view")
              .addClass("access-policy-view-modal modal")
              .css("height", window.outerHeight * 0.7)
              .modal()
              .modal("show");
          });
        } catch (e) {
          console.error("Error trying to show the AccessPolicyView: ", e);
        }
      },

      /**
       * Renders the AccessPolicyView
       * @param {Backbone.Model} model - Optional. The Model to render the
       *   AccessPolicy of. If not passed, method uses the Editor's model
       */
      renderAccessPolicy: function (model) {
        // Use specified model or default to the editor's model
        model = model || this.model;

        try {
          //If the AccessPolicy editor is disabled in this app, then exit now
          if (!MetacatUI.appModel.get("allowAccessPolicyChanges")) {
            return;
          }

          var thisView = this;
          require(["views/AccessPolicyView"], function (AccessPolicyView) {
            // Create a new AccessPolicyView using the AccessPolicy collection
            var accessPolicyView = new AccessPolicyView({
              collection: model.get("accessPolicy"),
            });

            // Turn on accessPolicy broadcasting for metadata models
            if (model.get("type") === "Metadata") {
              accessPolicyView.broadcast = true;
            }

            //Store a reference to the AccessPolicyView on this view
            thisView.accessPolicyView = accessPolicyView;

            //Add the view to the page
            thisView
              .$(thisView.accessPolicyViewContainer)
              .html(accessPolicyView.el);

            //Render the AccessPolicyView
            accessPolicyView.render();

            thisView.trigger("accessPolicyViewRendered");

            thisView.listenTo(
              accessPolicyView.collection,
              "add remove",
              thisView.showControls,
            );
          });
        } catch (e) {
          console.error("Error trying to render the AccessPolicyView: ", e);
        }
      },

      /**
       * Checks if the Access Policy editor is enabled in this instance of MetacatUI for
       * the type of object being edited.
       * @returns {boolean}
       * @since 2.15.0
       */
      isAccessPolicyEditEnabled: function () {
        if (!MetacatUI.appModel.get("allowAccessPolicyChanges")) {
          return false;
        }
      },

      /**
       * Show the editor footer controls (Save bar)
       */
      showControls: function () {
        var view = this;
        this.$(".editor-controls")
          .removeClass("hidden")
          .slideDown(300, function () {
            if (typeof view.handleScroll === "function") {
              view.handleScroll();
            }
          });
      },

      /**
       * Hide the editor footer controls (Save bar)
       */
      hideControls: function () {
        var view = this;
        this.hideSaving();
        this.$(".editor-controls").slideUp(300, function () {
          if (typeof view.handleScroll === "function") {
            view.handleScroll();
          }
        });
      },

      /**
       * Change the styling of this view to show that the object is in the process of saving
       */
      showSaving: function () {
        //Change the style of the save button
        this.$("#save-editor")
          .html('<i class="icon icon-spinner icon-spin"></i> Submitting ...')
          .addClass("btn-disabled");

        //Remove all the validation messaging
        this.removeValidation();

        //Get all the inputs in the Editor
        var allInputs = this.$("input, textarea, select, button");

        //Mark the disabled inputs so we can re-disable them later
        allInputs
          .filter(":disabled")
          .not(".label-container .label-input-text")
          .addClass("disabled-saving");

        //Remove the latest success or error alert
        this.$el.children(".alert-container").remove();

        //Disable all the inputs
        allInputs.prop("disabled", true);
      },

      /**
       *  Remove the styles set in showSaving()
       */
      hideSaving: function () {
        this.$("input, textarea, select, button")
          .not(".label-container .label-input-text")
          .prop("disabled", false);

        this.$(".disabled-saving, input.disabled")
          .not(".label-container .label-input-text")
          .prop("disabled", true)
          .removeClass("disabled-saving");

        //When the package is saved, revert the Save button back to normal
        this.$("#save-editor")
          .html(this.submitButtonText)
          .removeClass("btn-disabled");
      },

      /**
       * Enable the Save button. Resets any changes made in {@link EditorView#disableControls}
       * @since 2.17.1
       */
      enableControls: function () {
        //When the package is saved, revert the Save button back to normal
        this.$("#save-editor")
          .html(this.submitButtonText)
          .removeClass("btn-disabled")
          .parent()
          .tooltip("destroy");
      },

      /**
       * Disable the Save button and display a message to explain why
       * @param {string} [message] - A short text message to display in the Save button
       * @since 2.17.1
       */
      disableControls: function (message) {
        //When the package is saved, revert the Save button back to normal
        this.$("#save-editor")
          .html(message || "Waiting for files to finish uploading...")
          .addClass("btn-disabled")
          .parent() //Add a tooltip to the parent element since tooltips won't work on a disabled button
          .tooltip({
            placement: "top",
            trigger: "hover focus click",
            html: false,
            title:
              "Saving is disabled while files are uploading. Please wait...",
            container: "body",
            delay: 600,
          });
      },

      /**
       * Style the view to show that it is loading
       * @param {string|DOMElement} container - The element to put the loading styling in. Either a jQuery selector or the element itself.
       * @param {string|DOMElement} message - The message to display next to the loading icon. Either a jQuery selector or the element itself.
       */
      showLoading: function (container, message) {
        if (typeof container == "undefined" || !container)
          var container = this.$el;

        $(container).html(MetacatUI.appView.loadingTemplate({ msg: message }));
      },

      /**
       * Remove the styles set in showLoading()
       * @param {string|DOMElement} container - The element the loading message is conttained in. Either a jQuery selector or the element itself.
       */
      hideLoading: function (container) {
        if (typeof container == "undefined" || !container)
          var container = this.$el;

        $(container).find(".loading").remove();
      },

      /**
       * Called when there is no object found with this ID
       */
      showNotFound: function () {
        //If we haven't checked the logged-in status of the user yet, wait a bit until we show a 404 msg, in case this content is their private content
        if (!MetacatUI.appUserModel.get("checked")) {
          this.listenToOnce(
            MetacatUI.appUserModel,
            "change:checked",
            this.showNotFound,
          );
          return;
        }
        //If the user is not logged in
        else if (!MetacatUI.appUserModel.get("loggedIn")) {
          this.showSignIn();
          return;
        }

        if (!this.model.get("notFound")) return;

        var msg =
          "<h4>Nothing was found for one of the following reasons:</h4>" +
          "<ul class='indent'>" +
          "<li>The ID <span id='editor-view-not-found-pid'></span> does not exist.</li>" +
          '<li>This may be private content. (Are you <a href="<%= MetacatUI.root %>/signin">signed in?</a>)</li>' +
          "<li>The content was removed because it was invalid.</li>" +
          "</ul>";

        //Remove the loading messaging
        this.hideLoading();

        //Show the not found message
        MetacatUI.appView.showAlert(
          msg,
          "alert-error",
          this.$("#editor-body"),
          null,
          { remove: true },
        );

        this.$("#editor-view-not-found-pid").text(this.pid);
      },

      /**
       * Check the validity of this view's model
       */
      checkValidity: function () {
        if (this.model.isValid()) this.model.trigger("valid");
      },

      /**
       * Show validation errors, if there are any
       */
      showValidation: function () {
        this.saveError(
          "Unable to save. Either required information is missing or isn't filled out correctly.",
        );
      },

      /**
       * Removes all the validation error styling and messaging from this view
       */
      removeValidation: function () {
        this.$(".notification.error").removeClass("error").empty();
        this.$(".validation-error-icon").hide();
      },

      /**
       * When the object is saved successfully, tell the user
       * @param {object} savedObject - the object that was successfully saved
       */
      saveSuccess: function (savedObject) {
        var message = this.editorSubmitMessageTemplate({
          messageText: "Your changes have been submitted.",
          viewURL: MetacatUI.appModel.get("baseUrl"),
          buttonText: "Return home",
        });

        MetacatUI.appView.showAlert(message, "alert-success", this.$el, null, {
          remove: true,
        });

        this.hideSaving();
      },

      /**
       * When the object fails to save, tell the user
       * @param {string} errorMsg - The error message resulting from a failed attempt to save
       */
      saveError: function (errorMsg) {
        var messageContainer = $(document.createElement("div")).append(
            document.createElement("p"),
          ),
          messageParagraph = messageContainer.find("p"),
          messageClasses = "alert-error";

        messageParagraph.append(errorMsg);

        //If the model has an error message set on it, show it in a collapseable technical details section
        if (this.model.get("errorMessage")) {
          var errorId = "error" + Math.round(Math.random() * 100);
          messageParagraph.after(
            $(document.createElement("p")).append(
              $(document.createElement("a"))
                .text("See technical details")
                .attr("data-toggle", "collapse")
                .attr("data-target", "#" + errorId)
                .addClass("pointer"),
            ),
            $(document.createElement("div"))
              .addClass("collapse")
              .attr("id", errorId)
              .append(
                $(document.createElement("pre")).text(
                  this.model.get("errorMessage"),
                ),
              ),
          );
        }

        MetacatUI.appView.showAlert(
          messageContainer,
          messageClasses,
          this.$el,
          null,
          {
            emailBody: errorMsg,
            remove: true,
          },
        );

        this.hideSaving();
      },

      /**
       * Shows the required icons for the sections and fields that must be completed in this editor.
       * @param {object} requiredFields - A literal object that specified which fields should be required.
       *  The keys on the object map to model attributes, and the value is true if required, false if optional.
       */
      renderRequiredIcons: function (requiredFields) {
        //If no required fields are given, exit now
        if (typeof requiredFields == "undefined") {
          return;
        }

        _.each(
          Object.keys(requiredFields),
          function (field) {
            if (requiredFields[field]) {
              var reqEl = this.$(
                ".required-icon[data-category='" + field + "']",
              );

              //Show the required icon for this category/field
              reqEl.show();

              //Show the required icon for the section
              var sectionName = reqEl
                .parents(".section[data-section]")
                .attr("data-section");
              this.$(
                ".required-icon[data-section='" + sectionName + "']",
              ).show();
            }
          },
          this,
        );

        //When new inputs have been added to this Editor, re-render these required icons.
        // This is helpful when new questions are added to the editor after the intial rendering.
        this.off("editorInputsAdded");
        this.on(
          "editorInputsAdded",
          function () {
            this.renderRequiredIcons(requiredFields);
          },
          this,
        );
      },

      /**
       * Gets a list of required fields for this editor, or an empty object if there are none.
       * @returns {object}
       * @since 2.19.0
       */
      getRequiredFields: function () {
        return {};
      },

      /**
       * Checks if there are unsaved changes in this Editor that should prevent closing of this view.
       * This function is also executed by the AppView, which controls the top-level navigation.
       * @returns {boolean} Returns true if this view should be closed. False if it should remain opened and active.
       */
      canClose: function () {
        //If the user isn't logged in, we can leave this view without confirmation
        if (!MetacatUI.appUserModel.get("loggedIn")) return true;

        //If there are no unsaved changes, we can leave this view without confirmation
        if (!this.hasUnsavedChanges()) {
          return true;
        }

        return false;
      },

      /**
       * This function is called whenever the user is about to leave this view.
       * @returns {string} The message that asks the user if they are sure they want to close this view
       */
      getConfirmCloseMessage: function () {
        //Return a confirmation message
        return "Leave this page? All of your unsaved changes will be lost.";
      },

      /**
       * Returns true if there are unsaved changes in this Editor
       * This function should be extended by each subclass of EditorView to check for unsaved changes for that model type
       * @returns {boolean}
       */
      hasUnsavedChanges: function () {
        return true;
      },

      /**
       * Creates an HTML string to display this error message on the page. Errors can be
       * strings, arrays of strings, arrays of literal objects with string values, or a literal object with strings as the values.
       * @param {string|string[]|object} error A single error message in string format or a collection of error strings as an array or object
       * @returns {string} The error message HTML
       * @since 2.18.0
       */
      getErrorListItem: function (error) {
        try {
          let errorMessage = "";

          //Strings get added to a list item HTML element
          if (typeof error == "string" && error.trim().length) {
            return `<li>${error}</li>`;
          }
          //If the error is an array, iterate over each error in the array
          else if (Array.isArray(error)) {
            _.each(
              error,
              function (subError) {
                errorMessage += this.getErrorListItem(subError);
              },
              this,
            );
            return errorMessage;
          }
          //If the error is a literal object, iterate over each key in the object
          else if (typeof error == "object") {
            _.each(
              Object.keys(error),
              function (errorKey) {
                errorMessage += this.getErrorListItem(error[errorKey]);
              },
              this,
            );
            return errorMessage;
          }
          //Default to returning an empty string
          else {
            return "";
          }
        } catch (e) {
          console.error(
            "Failed to create the error message to show in the editor: ",
            e,
          );
          return "";
        }
      },

      /**
       *  Perform clean-up functions when this view is about to be removed from the page or navigated away from.
       */
      onClose: function () {
        //Remove the listener on the Window
        if (this.beforeunloadCallback) {
          window.removeEventListener("beforeunload", this.beforeunloadCallback);
          delete this.beforeunloadCallback;
        }

        //Reset the active alternate repository
        MetacatUI.appModel.set("activeAlternateRepositoryId", null);

        //Remove the class from the body element
        $("body").removeClass("Editor rendering");

        //Remove listeners
        this.stopListening();
        this.undelegateEvents();
      },
    },
  );

  return EditorView;
});