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",
], (
  _,
  $,
  Backbone,
  AccessRule,
  AccessPolicy,
  AccessRuleView,
  Template,
  ToggleTemplate,
) => {
  const SPINNER_HTML =
    '<i class="icon icon-spinner icon-spin icon-on-left"></i>';

  /**
   * @class AccessPolicyView
   * @classdesc Edits access policies through the legacy Backbone rule views.
   *
   * This view accepts two types of access policy inputs. Legacy `DataONEObject`
   * callers pass a `collections/AccessPolicy` through `options.collection`. The
   * view derives context from `collection.dataONEObject` and uses the existing
   * save path. The newer DataPackage callers pass a typed System Metadata
   * policy, explicit display context, and an apply callback.
   *
   * The editing controls still operate on `collections/AccessPolicy`. In the
   * typed policy mode, the view copies the typed rules into that legacy
   * collection and returns the edited collection through `onApply`. This keeps
   * `PortalEditorView` on its current `DataONEObject` path during the
   * migration. Once portal access policy editing uses typed System Metadata,
   * remove the legacy collection mode and its save and broadcast branches.
   * @classcategory Views
   * @augments Backbone.View
   * @screenshot views/AccessPolicyView.png
   * @class
   */
  const 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 this view edits package level sharing. Package level views show
       * the apply to all control; legacy DataONEObject views also broadcast
       * their policy through the legacy package collection.
       * @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",

      /**
       * Explicit rendering and apply context for this policy editor. This
       * replaces the old assumption that the collection carries a target
       * similar to a DataONEObject on `collection.dataONEObject`.
       * @type {object}
       */
      policyContext: null,

      /**
       * Async apply callback supplied with an explicit policy.
       * @type {Function|null}
       */
      onApply: null,

      /**
       * Whether this view is currently applying changes.
       * @type {boolean}
       */
      isApplying: false,

      /**
       * Cached rightsHolder so cancelling or hiding restores edits made in the
       * view.
       * @type {string|null|undefined}
       */
      cachedRightsHolder: undefined,

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

      /**
       * Creates a view in legacy collection or explicit policy mode.
       * @param {object} options View options
       * @param {AccessPolicy} [options.collection] Legacy Backbone policy
       * collection owned by a `DataONEObject`
       * @param {Array<object>} [options.policy] Typed System Metadata policy
       * copied into an editable legacy collection
       * @param {object} [options.policyContext] Explicit display/editor context
       * @param {Function} [options.onApply] Applies edits from explicit policy
       * mode
       */
      initialize(options = {}) {
        this.hasExplicitPolicy = Object.prototype.hasOwnProperty.call(
          options,
          "policy",
        );
        this.collection = this.hasExplicitPolicy
          ? this.normalizeAccessPolicy(options.policy)
          : options.collection;
        this.onApply = this.hasExplicitPolicy ? options.onApply || null : null;

        if (this.hasExplicitPolicy) {
          this.policyContext = _.extend({}, options.policyContext);
        } else {
          const { dataONEObject } = this.collection;
          this.policyContext = dataONEObject
            ? {
                fileName: dataONEObject.get("fileName"),
                rightsHolder: dataONEObject.get("rightsHolder"),
                canChangePermission: dataONEObject.get(
                  "isAuthorized_changePermission",
                ),
                type: dataONEObject.type,
              }
            : {};
        }

        this.cachedRightsHolder = this.getRightsHolder();
        this.cachedModels = this.collection.map((model) => model.clone());
      },

      /**
       * Adapt a typed System Metadata policy to the Backbone collection
       * consumed by the existing `AccessRuleView` editing UI.
       * @param {Array<object>} policy System Metadata access policy
       * @returns {AccessPolicy} Legacy access policy collection
       * @since 2.39.0
       */
      normalizeAccessPolicy(policy) {
        const collection = new AccessPolicy();

        _.each(policy, (rule) => {
          _.each(rule.subjects, (subject) => {
            collection.add({
              subject,
              read: rule.permissions.includes("read"),
              write: rule.permissions.includes("write"),
              changePermission: rule.permissions.includes("changePermission"),
            });
          });
        });

        return collection;
      },

      /**
       * Get the rightsHolder for the policy target.
       * @returns {string|null|undefined} Current rightsHolder subject
       * @since 2.39.0
       */
      getRightsHolder() {
        return this.policyContext.rightsHolder;
      },

      /**
       * Update the current rightsHolder and the legacy model when present.
       * @param {string|null|undefined} subject New rightsHolder subject
       * @since 2.39.0
       */
      setRightsHolder(subject) {
        this.policyContext.rightsHolder = subject || null;
        if (this.collection.dataONEObject) {
          this.collection.dataONEObject.set("rightsHolder", subject || null);
        }
      },

      /**
       * Get the resource label used in modal copy.
       * @returns {string} Display resource type
       * @since 2.39.0
       */
      getResourceType() {
        switch (this.policyContext?.type) {
          case "Portal":
            return MetacatUI.appModel.get("portalTermSingular");
          case "DataPackage":
            return "dataset";
          case "EML":
          case "ScienceMetadata":
            return "metadata record";
          case "DataONEObject":
            return "data file";
          case "Collection":
            return "collection";
          default:
            return "resource";
        }
      },

      /**
       * Promote another owner access rule into the rightsHolder slot.
       * @since 2.39.0
       */
      replaceRightsHolder() {
        const owner = this.collection.findWhere({ changePermission: true });

        if (!owner) {
          return;
        }

        this.setRightsHolder(owner.get("subject"));
        this.collection.remove(owner);
      },

      /** @inheritdoc */
      render() {
        try {
          this.resourceType = this.getResourceType();

          const showApplyToAll = this.broadcast && this.hasExplicitPolicy;
          const disableApplyToAll =
            showApplyToAll &&
            MetacatUI.rootDataPackage?.getNestedResourceMapMembers?.().length >
              0;

          // Insert the template into this view
          this.$el.html(
            this.template({
              resourceType: this.resourceType,
              fileName: this.policyContext?.fileName,
              showApplyToAll,
              disableApplyToAll,
              applyToDraft: this.hasExplicitPolicy,
            }),
          );

          if (disableApplyToAll) {
            this.$(".apply-to-all-members-option").popup({
              content:
                "Apply to all members is unavailable because this dataset contains a nested package, which is read-only in the editor.",
              position: "top center",
            });
          }

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

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

          const modelsToRemove = [];

          // Iterate over each AccessRule in the AccessPolicy and render a
          // AccessRuleView
          this.collection.each((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") === this.getRightsHolder()) {
              modelsToRemove.push(accessRule);
              return;
            }

            // Create an AccessRuleView
            const accessRuleView = new 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,
            );
          });

          // 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() {
        // Check if the public/private toggle is enabled. Default to enabling
        // it.
        let isEnabled = true;
        let enabledSubjects = [];

        // Get the DataONEObject that this AccessPlicy is about If there is a
        // target type, use it to choose the right app config.
        if (this.policyContext?.type) {
          // Get the Portal configs from the AppConfig
          if (this.policyContext.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",
            );
          }
        }

        // 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)
        ) {
          const isPublicClass = this.collection.isPublic()
            ? "public"
            : "private";
          this.$(".public-toggle-container").html(
            $(document.createElement("p"))
              .addClass(`public-toggle-disabled-text ${isPublicClass}`)
              .text(this.getPublicToggleHelpText()),
          );
          this.$(this.publicToggleSection).find("p.help").remove();
          return;
        }

        // Render the private/public toggle
        this.$(".public-toggle-container")
          .html(
            this.toggleTemplate({
              label: "",
              id: this.cid,
              trueLabel: "Public",
              falseLabel: "Private",
            }),
          )
          .tooltip({
            placement: "top",
            trigger: "hover",
            title: () => this.getPublicToggleHelpText(),
            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} Public or private status message
       * @since 2.15.0
       */
      getPublicToggleHelpText() {
        // DataPackage stages changes through onApply; Portal saves System Metadata directly.
        if (this.onApply) {
          return this.collection.isPublic()
            ? `Public selected. Select Done and submit the dataset to make this ${this.resourceType} visible to anyone in searches or by a direct link.`
            : `Private selected. Select Done and submit the dataset so only people you approve can see this ${this.resourceType}.`;
        }

        if (this.collection.isPublic()) {
          return `Your ${this.resourceType} is public. Anyone can see this ${this.resourceType} in searches or by a direct link.`;
        }
        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() {
        try {
          // Create a new AccessRule model and add to the collection
          const accessRule = new AccessRule({
            read: true,
          });

          // Create a new AccessRuleView
          const accessRuleView = new AccessRuleView({ model: accessRule });
          accessRuleView.accessPolicyView = this;
          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(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() {
        // If the app is configured to hide the rightsHolder, then exit now
        if (!MetacatUI.appModel.get("displayRightsHolderInAccessPolicy")) {
          return;
        }

        // If there is no rightsHolder associated with this access policy, then
        // exit
        if (!this.getRightsHolder()) {
          return;
        }

        // Create an AccessRule model that represents the rightsHolder
        const accessRuleModel = new AccessRule({
          subject: this.getRightsHolder(),
          read: true,
          write: true,
          changePermission: true,
        });

        // Create an AccessRuleView
        const accessRuleView = new AccessRuleView({ model: accessRuleModel });
        accessRuleView.accessPolicyView = this;
        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 Rule whose permissions changed
       */
      checkForOwners(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
          const rightsHolder = this.getRightsHolder();

          // 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.setRightsHolder(accessRuleModel.get("subject"));
                  this.collection.remove(accessRuleModel);
                }
              } else {
                // If there is a rightsHolder, we don't need to do anything
              }
            }
            // 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.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} event Click event
       */
      handleRemove(event) {
        event.preventDefault();
        if (this.isApplying) {
          return;
        }

        const accessRuleRow = $(event.target).parents(".access-rule");
        const accessRuleModel = accessRuleRow.data("model");
        const accessRuleView = accessRuleRow.data("view");

        // Get the rightsHolder for this resource
        const rightsHolder = this.getRightsHolder();

        // 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.replaceRightsHolder();

            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() {
        // Show warning message
        const 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() {
        try {
          // Create HTML that shows the access policy help text
          const accessExplanationEl = $(document.createElement("div"));
          const listEl = $(document.createElement("ul")).addClass("unstyled");

          accessExplanationEl.append(listEl);

          // Get the AccessRule options names
          let 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"),
            (isEnabled, accessType) => {
              // If this access type is disabled, exit
              if (!isEnabled) {
                return;
              }

              let accessTypeExplanation = "";
              const 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;
                default:
                  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
       * @param {Event} event Toggle change event
       */
      togglePrivacy(event) {
        if (this.isApplying) {
          return;
        }

        this.syncPublicToggle(event.currentTarget);
      },

      /**
       * Sync the public/private toggle state into the access policy collection.
       * @param {HTMLElement|jQuery} [input] Public toggle input
       * @since 2.39.0
       */
      syncPublicToggle(input) {
        const publicToggle = input
          ? $(input)
          : this.$(".public-toggle-container input");
        if (!publicToggle.length) {
          return;
        }

        if (publicToggle.is(":checked")) {
          this.collection.makePublic();
        } else {
          this.collection.makePrivate();
        }
      },

      /**
       * Apply the edited policy through the explicit callback or legacy target.
       * @returns {Promise<void>} Resolves when the apply operation completes
       * @since 2.39.0
       */
      async apply() {
        if (this.isApplying) {
          return;
        }

        // Remove any alerts that are currently displayed
        this.$(".alert-container").remove();

        const { dataONEObject } = this.collection;

        if (!this.onApply && !dataONEObject) {
          return;
        }

        try {
          this.setApplying(true);
          this.syncPublicToggle();
          const propagate = this.$(".apply-to-all-members").is(":checked");
          const rightsHolder = this.getRightsHolder();
          const applyOptions = { propagate };
          if (rightsHolder !== this.cachedRightsHolder) {
            applyOptions.rightsHolder = rightsHolder;
          }
          if (propagate) {
            applyOptions.onProgress = ({ completed, total }) => {
              if (!this.isApplying) {
                return;
              }

              const message =
                total > 0
                  ? `Updating ${completed}/${total} files...`
                  : "Updating files...";
              this.$(".apply.btn").html(`${SPINNER_HTML}${message}`);
            };
          }
          if (typeof this.onApply === "function") {
            await this.onApply(this.collection, applyOptions);
          } else {
            await this.applyDataONEObjectPolicy(dataONEObject);
            return;
          }
          this.cachedRightsHolder = this.getRightsHolder();
          this.cachedModels = this.collection.map((model) => model.clone());
          this.setApplying(false);
          this.$el.modal("hide");
          MetacatUI.appView.showAlert(
            "Sharing changes have been applied to this draft. Submit the dataset to save them.",
            "alert-success",
            null,
            null,
            { remove: true },
          );
        } catch (error) {
          this.showApplyError(error?.message || String(error));
        }
      },

      /**
       * Enable or disable the permission modal while changes are applied.
       * @param {boolean} isApplying Whether apply work is in progress
       * @since 2.39.0
       */
      setApplying(isApplying) {
        this.isApplying = isApplying;
        this.$el.toggleClass("applying", this.isApplying);

        if (this.isApplying) {
          this.$el.attr("aria-busy", "true");
          this.$(".apply.btn")
            .html(`${SPINNER_HTML}Updating files...`)
            .attr("disabled", "disabled")
            .addClass("disabled");
          this.$(".cancel.btn, .modal-header .close")
            .attr("disabled", "disabled")
            .addClass("disabled")
            .removeAttr("data-dismiss");
          this.$(".modal-body :input, .modal-footer :input")
            .filter(":disabled")
            .attr("data-disabled-before-applying", "true")
            .end()
            .attr("disabled", "disabled");
          return;
        }

        this.$el.removeAttr("aria-busy");
        this.$(".apply.btn")
          .text(this.hasExplicitPolicy ? "Done" : "Save")
          .removeAttr("disabled")
          .removeClass("disabled");
        this.$(".cancel.btn")
          .removeAttr("disabled")
          .removeClass("disabled")
          .attr("data-dismiss", "modal");
        this.$(".modal-header .close")
          .removeAttr("disabled")
          .removeClass("disabled")
          .attr("data-dismiss", "modal");
        this.$(".modal-body :input, .modal-footer :input")
          .not("[data-disabled-before-applying]")
          .removeAttr("disabled")
          .end()
          .removeAttr("data-disabled-before-applying");
      },

      /**
       * Apply this policy through the legacy DataONEObject system metadata
       * path.
       * @param {Backbone.Model} dataONEObject Object whose policy is being
       * edited
       * @returns {Promise<void>} Resolves when the save completes
       * @since 2.39.0
       */
      applyDataONEObjectPolicy(dataONEObject) {
        if (this.broadcast) {
          MetacatUI.rootDataPackage.broadcastAccessPolicy(this.collection);
        }

        if (dataONEObject.isNew()) {
          this.cachedRightsHolder = this.getRightsHolder();
          this.cachedModels = this.collection.map((model) => model.clone());
          this.setApplying(false);
          this.$el.modal("hide");
          return Promise.resolve();
        }

        return new Promise((resolve, reject) => {
          const handleProgress = (changedModel) => {
            const status = changedModel.get("uploadStatus");
            if (status === "p") {
              this.$(".apply.btn")
                .html(`${SPINNER_HTML}Saving...`)
                .attr("disabled", "disabled");
              return;
            }

            this.stopListening(
              dataONEObject,
              "change:uploadStatus",
              handleProgress,
            );

            if (status === "e") {
              reject(new Error("Your changes could not be saved."));
              return;
            }

            if (status !== "c") {
              return;
            }

            this.cachedRightsHolder = this.getRightsHolder();
            this.cachedModels = this.collection.map((model) => model.clone());
            this.setApplying(false);
            this.$el.modal("hide");
            MetacatUI.appView.showAlert(
              "Sharing changes have been saved.",
              "alert-success",
              null,
              null,
              { remove: true },
            );
            resolve();
          };

          this.listenTo(dataONEObject, "change:uploadStatus", handleProgress);
          try {
            dataONEObject.updateSysMeta();
          } catch (error) {
            this.stopListening(
              dataONEObject,
              "change:uploadStatus",
              handleProgress,
            );
            reject(error);
          }
        });
      },

      /**
       * Show an apply failure message and restore the modal controls.
       * @param {string} message Error message to display
       * @since 2.39.0
       */
      showApplyError(message) {
        this.setApplying(false);

        const msgContainer = this.$(".modal-body").length
          ? this.$(".modal-body")
          : this.$el;

        MetacatUI.appView.showAlert(message, "alert-error", msgContainer, 0, {
          remove: true,
        });
      },

      /**
       * Reset edits when the modal closes, unless an apply operation is active.
       * @param {Event} event Bootstrap modal hide event
       * @returns {boolean} Whether the hide can continue
       * @since 2.39.0
       */
      handleHide(event) {
        if (event.target !== this.el) {
          return true;
        }

        if (this.isApplying) {
          event.preventDefault();
          return false;
        }

        this.reset();
        return true;
      },

      /**
       * 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() {
        if (this.isApplying) {
          return;
        }

        this.stopListening();
        this.collection.reset(this.cachedModels.map((model) => model.clone()));
        this.setRightsHolder(this.cachedRightsHolder);
        this.render();
      },

      /**
       * Adds messaging to this view to tell the user they are unauthorized to
       * change the AccessPolicy of this object(s)
       */
      showUnauthorized() {
        // Get the container element for the message
        const 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;
});