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;

});