Source: src/js/views/AccessPolicyView.js

define([
  "underscore",
  "jquery",
  "backbone",
  "models/AccessRule",
  "collections/AccessPolicy",
  "views/AccessRuleView",
  "text!templates/accessPolicy.html",
  "text!templates/filters/toggleFilter.html",
], function (
  _,
  $,
  Backbone,
  AccessRule,
  AccessPolicy,
  AccessRuleView,
  Template,
  ToggleTemplate,
) {
  /**
   * @class AccessPolicyView
   * @classdesc A view of an Access Policy of a DataONEObject
   * @classcategory Views
   * @extends Backbone.View
   * @screenshot views/AccessPolicyView.png
   * @constructor
   */
  var AccessPolicyView = Backbone.View.extend(
    /** @lends AccessPolicyView.prototype */
    {
      /**
       * The type of View this is
       * @type {string}
       */
      type: "AccessPolicy",

      /**
       * The type of object/resource that this AccessPolicy is for. This is used for display purposes only.
       * @example "dataset", "portal", "data file"
       * @type {string}
       */
      resourceType: "resource",

      /**
       * The HTML classes to use for this view's element
       * @type {string}
       */
      className: "access-policy-view",

      /**
       * The AccessPolicy collection that is displayed in this View
       * @type {AccessPolicy}
       */
      collection: undefined,

      /**
       * References to templates for this view. HTML files are converted to Underscore.js templates
       * @type {Underscore.Template}
       */
      template: _.template(Template),
      toggleTemplate: _.template(ToggleTemplate),

      /**
       * Used to track the collection of models set on the view in order to handle
       * undoing all changes made when we either hit Cancel or click otherwise
       * hide the modal (such as clicking outside of it).
       * @type {AccessRule[]}
       * @since 2.15.0
       */
      cachedModels: null,

      /**
       * Whether or not changes to the accessPolicy managed by this view will be
       * broadcasted to the accessPolicy of the editor's rootDataPackage's
       * packageModle.
       *
       * This implementation is very likely to change in the future as we iron out
       * how to handle bulk accessPolicy (and other) changes.
       * @type {boolean}
       * @since 2.15.0
       */
      broadcast: false,

      /**
       * A selector for the element in this view that contains the public/private toggle section
       * @type {string}
       * @since 2.15.0
       */
      publicToggleSection: "#public-toggle-section",

      /**
       * The events this view will listen to and the associated function to call.
       * @type {Object}
       */
      events: {
        "change .public-toggle-container input": "togglePrivacy",
        "click .save": "save",
        "click .cancel": "reset",
        "click .access-rule .remove": "handleRemove",
      },

      /**
       * Creates a new AccessPolicyView
       * @param {Object} options - A literal object with options to pass to the view
       */
      initialize: function (options) {
        this.cachedModels = _.clone(this.collection.models);
      },

      /**
       * Renders this view
       */
      render: function () {
        try {
          //If there is no AccessPolicy collection, then exit now
          if (!this.collection) {
            return;
          }

          var dataONEObject = this.collection.dataONEObject;

          if (dataONEObject && dataONEObject.type) {
            switch (dataONEObject.type) {
              case "Portal":
                this.resourceType =
                  MetacatUI.appModel.get("portalTermSingular");
                break;
              case "DataPackage":
                this.resourceType = "dataset";
                break;
              case "EML" || "ScienceMetadata":
                this.resourceType = "metadata record";
                break;
              case "DataONEObject":
                this.resourceType = "data file";
                break;
              case "Collection":
                this.resourceType = "collection";
                break;
              default:
                this.resourceType = "resource";
                break;
            }
          } else {
            this.resourceType = "resource";
          }

          //Insert the template into this view
          this.$el.html(
            this.template({
              resourceType: this.resourceType,
              fileName: dataONEObject.get("fileName"),
            }),
          );

          //If the user is not authorized to change the permissions of this object,
          // then skip rendering the rest of the AccessPolicy.
          if (dataONEObject.get("isAuthorized_changePermission") === false) {
            this.showUnauthorized();
            return;
          }

          //Show the rightsHolder as an AccessRuleView
          this.showRightsholder();

          var modelsToRemove = [];

          //Iterate over each AccessRule in the AccessPolicy and render a AccessRuleView
          this.collection.each(function (accessRule) {
            //Don't display access rules for the public since these are controlled via the public/private toggle
            if (accessRule.get("subject") == "public") {
              return;
            }

            //If this AccessRule is a duplicate of the rightsHolder, remove it from the policy and don't display it
            if (
              accessRule.get("subject") == dataONEObject.get("rightsHolder")
            ) {
              modelsToRemove.push(accessRule);
              return;
            }

            //Create an AccessRuleView
            var accessRuleView = new AccessRuleView();
            accessRuleView.model = accessRule;
            accessRuleView.accessPolicyView = this;

            //Add the AccessRuleView to this view
            this.$(".access-rules-container").append(accessRuleView.el);

            //Render the view
            accessRuleView.render();

            //Listen to changes on the access rule, to check that there is at least one owner
            this.listenTo(
              accessRule,
              "change:read change:write change:changePermission",
              this.checkForOwners,
            );
          }, this);

          //Remove each AccessRule from the AccessPolicy that should be removed.
          // We don't remove these during the collection.each() function because it
          // messes up the .each() iteration.
          this.collection.remove(modelsToRemove);

          //Get the subject info for each subject in the AccessPolicy, so we can display names
          this.collection.getSubjectInfo();

          //Show a blank row at the bottom of the table for adding a new Access Rule.
          this.addEmptyRow();

          //Render various help text for this view
          this.renderHelpText();

          //Render the public/private toggle, if it's enabled in the app config
          this.renderPublicToggle();
        } catch (e) {
          MetacatUI.appView.showAlert(
            "Something went wrong while trying to display the " +
              MetacatUI.appModel.get("accessPolicyName") +
              ". <p>Technical details: " +
              e.message +
              "</p>",
            "alert-error",
            this.$el,
            null,
          );
          console.error(e);
        }
      },

      /**
       * Renders a public/private toggle that toggles the public readability of the given resource.
       */
      renderPublicToggle: function () {
        //Check if the public/private toggle is enabled. Default to enabling it.
        var isEnabled = true,
          enabledSubjects = [];

        //Get the DataONEObject that this AccessPlicy is about
        var dataONEObject = this.collection.dataONEObject;

        //If there is a DataONEObject model found, and it has a type
        if (dataONEObject && dataONEObject.type) {
          //Get the Portal configs from the AppConfig
          if (dataONEObject.type == "Portal") {
            isEnabled = MetacatUI.appModel.get("showPortalPublicToggle");
            enabledSubjects = MetacatUI.appModel.get(
              "showPortalPublicToggleForSubjects",
            );
          }
          //Get the Dataset configs from the AppConfig
          else {
            isEnabled = MetacatUI.appModel.get("showDatasetPublicToggle");
            enabledSubjects = MetacatUI.appModel.get(
              "showDatasetPublicToggleForSubjects",
            );
          }
        }

        //Get the public/private help text
        let helpText = this.getPublicToggleHelpText();

        // Or if the public toggle is limited to a set of users and/or groups, and the current user is
        // not in that list, then display a message instead of the toggle
        if (
          !isEnabled ||
          (Array.isArray(enabledSubjects) &&
            enabledSubjects.length &&
            !_.intersection(
              enabledSubjects,
              MetacatUI.appUserModel.get("allIdentitiesAndGroups"),
            ).length)
        ) {
          let isPublicClass = this.collection.isPublic() ? "public" : "private";
          this.$(".public-toggle-container").html(
            $(document.createElement("p"))
              .addClass("public-toggle-disabled-text " + isPublicClass)
              .text(helpText),
          );
          this.$(this.publicToggleSection).find("p.help").remove();
          return;
        }

        //Render the private/public toggle
        this.$(".public-toggle-container")
          .html(
            this.toggleTemplate({
              label: "",
              id: this.collection.id,
              trueLabel: "Public",
              falseLabel: "Private",
            }),
          )
          .tooltip({
            placement: "top",
            trigger: "hover",
            title: helpText,
            container: this.$(".public-toggle-container"),
            delay: {
              show: 800,
            },
          });

        //If the dataset is public, check the checkbox
        this.$(".public-toggle-container input").prop(
          "checked",
          this.collection.isPublic(),
        );
      },

      /**
       * Constructs and returns a message that explains if this resource is public or private. This message is displayed
       * in the tooltip for the public/private toggle or in place of the toggle when the toggle is disabled. Override this
       * function to create a custom message.
       * @returns {string}
       * @since 2.15.0
       */
      getPublicToggleHelpText: function () {
        if (this.collection.isPublic()) {
          return (
            "Your " +
            this.resourceType +
            " is public. Anyone can see this " +
            this.resourceType +
            " in searches or by a direct link."
          );
        } else {
          return (
            "Your " +
            this.resourceType +
            " is private. Only people you approve can see this " +
            this.resourceType +
            "."
          );
        }
      },

      /**
       * Render a row with input elements for adding a new AccessRule
       */
      addEmptyRow: function () {
        try {
          //Create a new AccessRule model and add to the collection
          var accessRule = new AccessRule({
            read: true,
            dataONEObject: this.collection.dataONEObject,
          });

          //Create a new AccessRuleView
          var accessRuleView = new AccessRuleView();
          accessRuleView.model = accessRule;
          accessRuleView.isNew = true;

          this.listenTo(accessRule, "change", this.addAccessRule);

          //Add the new row to the table
          this.$(".access-rules-container").append(accessRuleView.el);

          //Render the AccessRuleView
          accessRuleView.render();
        } catch (e) {
          console.error(
            "Something went wrong while adding the empty access policy row ",
            e,
          );
        }
      },

      /**
       * Adds the given AccessRule model to the AccessPolicy collection associated with this view
       * @param {AccessRule} accessRule - The AccessRule to add
       */
      addAccessRule: function (accessRule) {
        //If this AccessPolicy already contains this AccessRule, then exit
        if (this.collection.contains(accessRule)) {
          return;
        }

        //If there is no subject set on this AccessRule, exit
        if (!accessRule.get("subject")) {
          return;
        }

        //Add the AccessRule to the AccessPolicy
        this.collection.push(accessRule);

        //Get the name for this new person or group
        accessRule.getSubjectInfo();

        //Render a new empty row
        this.addEmptyRow();
      },

      /**
       * Adds an AccessRuleView that represents the rightsHolder of the object.
       *  The rightsHolder needs to be handled specially because it's not a regular access rule in the system metadata.
       */
      showRightsholder: function () {
        //If the app is configured to hide the rightsHolder, then exit now
        if (!MetacatUI.appModel.get("displayRightsHolderInAccessPolicy")) {
          return;
        }

        //Get the DataONEObject associated with this access policy
        var dataONEObject = this.collection.dataONEObject;

        //If there is no DataONEObject associated with this access policy, then exit
        if (!dataONEObject || !dataONEObject.get("rightsHolder")) {
          return;
        }

        //Create an AccessRule model that represents the rightsHolder
        var accessRuleModel = new AccessRule({
          subject: dataONEObject.get("rightsHolder"),
          read: true,
          write: true,
          changePermission: true,
          dataONEObject: dataONEObject,
        });

        //Create an AccessRuleView
        var accessRuleView = new AccessRuleView();
        accessRuleView.accessPolicyView = this;
        accessRuleView.model = accessRuleModel;
        accessRuleView.allowChanges = MetacatUI.appModel.get(
          "allowChangeRightsHolder",
        );

        //Add the AccessRuleView to this view
        if (this.$(".access-rules-container .new").length) {
          this.$(".access-rules-container .new").before(accessRuleView.el);
        } else {
          this.$(".access-rules-container").append(accessRuleView.el);
        }

        //Render the view
        accessRuleView.render();

        //Get the name for this subject
        accessRuleModel.getSubjectInfo();

        //When the access type is changed, check that there is still at least one owner.
        this.listenTo(
          accessRuleModel,
          "change:read change:write change:changePermission",
          this.checkForOwners,
        );
      },

      /**
       * Checks that there is at least one owner of this resource, and displays a warning message if not.
       * @param {AccessRule} accessRuleModel
       */
      checkForOwners: function (accessRuleModel) {
        try {
          if (!accessRuleModel) {
            return;
          }

          //If changing the rightsHolder is disabled, we don't need to check for owners,
          // since the rightsHolder will always be the owner.
          if (
            !MetacatUI.appModel.get("allowChangeRightsHolder") ||
            !MetacatUI.appModel.get("displayRightsHolderInAccessPolicy")
          ) {
            return;
          }

          //Get the rightsHolder for this resource
          var rightsHolder;
          if (
            this.collection.dataONEObject &&
            this.collection.dataONEObject.get("rightsHolder")
          ) {
            rightsHolder = this.collection.dataONEObject.get("rightsHolder");
          }

          //Check if any priveleges have been removed
          if (
            !accessRuleModel.get("read") ||
            !accessRuleModel.get("write") ||
            !accessRuleModel.get("changePermission")
          ) {
            //If there is no owner of this resource
            if (!this.collection.hasOwner()) {
              //If there is no rightsHolder either, then make this person the rightsHolder
              // or if this is the rightsHolder, keep them the rightsHolder
              if (
                !rightsHolder ||
                rightsHolder == accessRuleModel.get("subject")
              ) {
                //Change this access rule back to an ownership level, since there needs to be at least one owner per object
                accessRuleModel.set({
                  read: true,
                  write: true,
                  changePermission: true,
                });

                this.showOwnerWarning();

                if (!rightsHolder) {
                  this.collection.dataONEObject.set(
                    "rightsHolder",
                    accessRuleModel.get("subject"),
                  );
                  this.collection.remove(accessRuleModel);
                }
              }
              //If there is a rightsHolder, we don't need to do anything
              else {
                return;
              }
            }
            //If the AccessRule model that was just changed was the rightsHolder,
            // demote that subject as the rightsHolder, and replace with another subject
            else if (rightsHolder == accessRuleModel.get("subject")) {
              //Replace the rightsHolder with a different subject with ownership permissions
              this.collection.replaceRightsHolder();

              //Add the old rightsHolder AccessRule to the AccessPolicy
              this.collection.add(accessRuleModel);
            }
          }
        } catch (e) {
          console.error(
            "Could not check that there are owners in this access policy: ",
            e,
          );
        }
      },

      /**
       * Checks that there is at least one owner of this resource, and displays a warning message if not.
       * @param {Event} e
       */
      handleRemove: function (e) {
        var accessRuleModel = $(e.target).parents(".access-rule").data("model");

        //Get the rightsHolder for this resource
        var rightsHolder;
        if (
          this.collection.dataONEObject &&
          this.collection.dataONEObject.get("rightsHolder")
        ) {
          rightsHolder = this.collection.dataONEObject.get("rightsHolder");
        }

        //If the rightsHolder was just removed,
        if (rightsHolder == accessRuleModel.get("subject")) {
          //If changing the rightsHolder is disabled, we don't need to check for owners,
          // since the rightsHolder will always be the owner.
          if (
            !MetacatUI.appModel.get("allowChangeRightsHolder") ||
            !MetacatUI.appModel.get("displayRightsHolderInAccessPolicy")
          ) {
            return;
          }

          //If there is another owner of this resource
          if (this.collection.hasOwner()) {
            //Replace the rightsHolder with a different subject with ownership permissions
            this.collection.replaceRightsHolder();

            var accessRuleView = $(e.target)
              .parents(".access-rule")
              .data("view");
            if (accessRuleView) {
              accessRuleView.remove();
            }
          }
          //If there are no other owners of this dataset, keep this person as the rightsHolder
          else {
            this.showOwnerWarning();
          }
        } else {
          //Remove the AccessRule from the AccessPolicy
          this.collection.remove(accessRuleModel);
        }
      },

      /**
       * Displays a warning message in this view that the object needs at least one owner.
       */
      showOwnerWarning: function () {
        //Show warning message
        var msgContainer = this.$(".modal-body").length
          ? this.$(".modal-body")
          : this.$el;
        MetacatUI.appView.showAlert(
          "At least one person or group needs to be an owner of this " +
            this.resourceType +
            ".",
          "alert-warning",
          msgContainer,
          2000,
          { remove: true },
        );
      },

      /**
       * Renders help text for the form in this view
       */
      renderHelpText: function () {
        try {
          //Create HTML that shows the access policy help text
          var accessExplanationEl = $(document.createElement("div")),
            listEl = $(document.createElement("ul")).addClass("unstyled");

          accessExplanationEl.append(listEl);

          //Get the AccessRule options names
          var accessRuleOptionNames = MetacatUI.appModel.get(
            "accessRuleOptionNames",
          );
          if (
            typeof accessRuleOptionNames !== "object" ||
            !Object.keys(accessRuleOptionNames).length
          ) {
            accessRuleOptionNames = {};
          }

          //Create HTML that shows an explanation of each enabled access rule option
          _.mapObject(
            MetacatUI.appModel.get("accessRuleOptions"),
            function (isEnabled, accessType) {
              //If this access type is disabled, exit
              if (!isEnabled) {
                return;
              }

              var accessTypeExplanation = "",
                accessTypeName = accessRuleOptionNames[accessType];

              //Get explanation text for the given access type
              switch (accessType) {
                case "read":
                  accessTypeExplanation =
                    " - can view this content, even when it's private.";
                  break;
                case "write":
                  accessTypeExplanation =
                    " - can view and edit this content, even when it's private.";
                  break;
                case "changePermission":
                  accessTypeExplanation =
                    " - can view and edit this content, even when it's private. In addition, can add and remove other people from these " +
                    MetacatUI.appModel.get("accessPolicyName") +
                    ".";
                  break;
              }

              //Add this to the list
              listEl.append(
                $(document.createElement("li")).append(
                  $(document.createElement("h5")).text(accessTypeName),
                  $(document.createElement("span")).text(accessTypeExplanation),
                ),
              );
            },
          );

          //Add a popover to the Access column header to give more help text about the access types
          this.$(".access-icon.popover-this").popover({
            title: 'What does "Access" mean?',
            delay: {
              show: 800,
            },
            placement: "top",
            trigger: "hover focus click",
            container: this.$el,
            html: true,
            content: accessExplanationEl,
          });
        } catch (e) {
          console.error("Could not render help text", e);
        }
      },

      /**
       * Toggles the public-read AccessRule for this resource
       */
      togglePrivacy: function () {
        //If this AccessPolicy is public already, make it private
        if (this.collection.isPublic()) {
          this.collection.makePrivate();
        }
        //Otherwise, make it public
        else {
          this.collection.makePublic();
        }
      },

      /**
       * Saves the AccessPolicy associated with this view
       */
      save: function () {
        //Remove any alerts that are currently displayed
        this.$(".alert-container").remove();

        //Get the DataONE Object that this Access Policy is for
        var dataONEObject = this.collection.dataONEObject;

        if (!dataONEObject) {
          return;
        }

        // Broadcast the change across the package if appropriate
        if (this.broadcast) {
          MetacatUI.rootDataPackage.broadcastAccessPolicy(this.collection);
        }

        // Don't trigger a save if the item is new and just close the modal
        if (dataONEObject.isNew()) {
          $(this.$el).modal("hide");

          return;
        }

        //Show the save progress as it is in progress, complete, in error, etc.
        this.listenTo(
          dataONEObject,
          "change:uploadStatus",
          this.showSaveProgress,
        );

        //Update the SystemMetadata for this object
        dataONEObject.updateSysMeta();
      },

      /**
       * Show visual cues in this view to show the user the status of the system metadata update.
       * @param {DataONEObject} dataONEObject - The object being updated
       */
      showSaveProgress: function (dataONEObject) {
        if (!dataONEObject) {
          return;
        }

        var status = dataONEObject.get("uploadStatus");

        //When the status is "in progress"
        if (status == "p") {
          //Disable the Save button and change the text to say, "Saving..."
          this.$(".save.btn").text("Saving...").attr("disabled", "disabled");
          this.$(".cancel.btn").attr("disabled", "disabled");

          return;
        }
        //When the status is "complete"
        else if (status == "c") {
          //Create a checkmark icon
          var icon = $(document.createElement("i")).addClass(
              "icon icon-ok icon-on-left",
            ),
            cancelBtn = this.$(".cancel.btn");
          saveBtn = this.$(".save.btn");

          //Disable the Save button and change the text to say, "Saving..."
          cancelBtn.text("Saved").removeAttr("disabled");
          saveBtn.text("Saved").prepend(icon).removeAttr("disabled");

          setTimeout(function () {
            saveBtn.empty().text("Save");
          }, 2000);

          this.cachedModels = _.clone(this.collection.models);

          // Hide the modal only on a successful save
          $(this.$el).modal("hide");
        }
        //When the status is "error"
        else if (status == "e") {
          var msgContainer = this.$(".modal-body").length
            ? this.$(".modal-body")
            : this.$el;

          MetacatUI.appView.showAlert(
            "Your changes could not be saved.",
            "alert-error",
            msgContainer,
            0,
            { remove: true },
          );

          //Reset the save button
          this.$(".save.btn").text("Save").removeAttr("disabled");
        }

        //Remove the listener for this function
        this.stopListening(
          dataONEObject,
          "change:uploadStatus",
          this.showSaveProgress,
        );
      },

      /**
       * Resets the state of the models stored in the view's collection to the
       * latest cached copy. Triggered either when the Cancel button is hit or
       * the modal containing this view is hidden.
       * @since 2.15.0
       */
      reset: function () {
        if (!this.collection || !this.cachedModels) {
          return;
        }

        this.collection.set(this.cachedModels);
      },

      /**
       * Adds messaging to this view to tell the user they are unauthorized to change the AccessPolicy
       * of this object(s)
       */
      showUnauthorized: function () {
        //Get the container element for the message
        var msgContainer = this.$(".modal-body").length
          ? this.$(".modal-body")
          : this.$el;

        //Empty the container element
        msgContainer.empty();

        //Show the info message
        MetacatUI.appView.showAlert(
          "The person who owns this " +
            this.resourceType +
            " has not given you permission to change the " +
            MetacatUI.appModel.get("accessPolicyName") +
            ". Contact the owner to be added " +
            " as another owner of this " +
            this.resourceType +
            ".",
          "alert-info subtle",
          msgContainer,
          null,
          { remove: false },
        );

        //Add an unauthorized class to this view for further styling options
        this.$el.addClass("unauthorized");
      },
    },
  );

  return AccessPolicyView;
});