Source: src/js/views/notifications/NotificationModalView.js

define([
  "jquery",
  "backbone",
  "models/notifications/ObjectNotification",
  `text!${MetacatUI.root}/css/notification-modal.css`,
], ($, Backbone, ObjectNotification, NotificationModalViewCSS) => {
  "use strict";

  const BASE_CLASS = "notification-modal";
  const CSS_ID = "notificationModalView";

  const BOOTSTRAP_CLASS_NAMES = {
    button: "btn",
    buttonPrimary: "btn-primary",
    buttonSecondary: "btn-secondary",
    close: "close",
    disabled: "disabled",
    fade: "fade",
    hide: "hide",
    modal: "modal",
    modalBody: "modal-body",
    modalContent: "modal-content",
    modalDialog: "modal-dialog",
    modalFooter: "modal-footer",
    modalHeader: "modal-header",
    modalTitle: "modal-title",
  };

  const HTML_ATTRS = {
    disabled: "disabled",
  };

  const CLASS_NAMES = {
    modalDialog: `${BASE_CLASS}__dialog`,
    modalContent: `${BASE_CLASS}__content`,
    modalHeader: `${BASE_CLASS}__header`,
    modalTitle: `${BASE_CLASS}__title`,
    modalTitlePrefix: `${BASE_CLASS}__title-prefix`,
    modalTitleDataset: `${BASE_CLASS}__title-dataset`,
    modalBody: `${BASE_CLASS}__body`,
    status: `${BASE_CLASS}__status`,
    statusInfo: `${BASE_CLASS}__status--info`,
    statusError: `${BASE_CLASS}__status--error`,
    form: `${BASE_CLASS}__form`,
    typeRow: `${BASE_CLASS}__type-row`,
    typeRowDisabled: `${BASE_CLASS}__type-row--disabled`,
    typeCheckbox: `${BASE_CLASS}__type-checkbox`,
    typeContent: `${BASE_CLASS}__type-content`,
    typeLabel: `${BASE_CLASS}__type-label`,
    typeDescription: `${BASE_CLASS}__type-description`,
    modalFooter: `${BASE_CLASS}__footer`,
    footerActions: `${BASE_CLASS}__footer-actions`,
    footerMessage: `${BASE_CLASS}__footer-message`,
    closeButton: `${BASE_CLASS}__close-button`,
    cancelButton: `${BASE_CLASS}__cancel-button`,
    saveButton: `${BASE_CLASS}__save-button`,
  };

  const IDS = {
    MODAL: "notificationModal",
    TITLE: "notificationModalLabel",
  };

  const { ERROR_CODES } = ObjectNotification;
  const MAX_DATASET_TITLE_LENGTH = 120;

  const MESSAGES = {
    ACCOUNT_SETTINGS_LINK_TEXT: "account settings page",
    CANCEL: "Cancel",
    CLIENT_UNAVAILABLE: "The notification client failed to load.",
    FEATURE_DISABLED:
      "The Notification Service feature is not available on this repository.",
    FOOTER_EMAIL_PREFIX: "Notifications will be sent to ",
    LOAD_FAILED: "Failed to load your notification settings.",
    LOADING: "Loading your current notification settings...",
    MISSING_EMAIL: "An email address is required to manage notifications.",
    MISSING_EMAIL_PROMPT_PREFIX: "Enter an email address on your ",
    MISSING_EMAIL_PROMPT_SUFFIX: " before managing notifications.",
    MODAL_TITLE_DEFAULT: "Manage notifications",
    MODAL_TITLE_PREFIX: "Manage notifications for ",
    PID_REQUIRED: "A dataset identifier is required to manage notifications.",
    RESOURCE_TYPES_UNSUPPORTED:
      "This repository does not support notifications.",
    SAVE: "Save",
    SAVE_FAILED: "Failed to save your notification settings.",
    SAVING: "Saving notification settings...",
    SAVING_BUTTON: "Saving...",
    SERVICE_UNAVAILABLE_PREFIX:
      "The notification service is unavailable. Please try again later or contact support if the issue persists.",
    SERVICE_URL_REQUIRED:
      "A Notification Service URL must be configured before sending requests.",
    SIGN_IN_REQUIRED: "Sign in to manage notifications.",
    STILL_LOADING: "Notification settings are still loading.",
  };

  /**
   * @class NotificationModalView
   * @classdesc Modal for subscribing and unsubscribing to notifications for a
   * single DataONE object identifier.
   * @classcategory Views
   * @augments Backbone.View
   * @since 2.37.0
   * @screenshot views/notifications/NotificationModalView.png
   */
  const NotificationModalView = Backbone.View.extend(
    /** @lends NotificationModalView.prototype */ {
      /**
       * Classes to add to the modal.
       * @type {string}
       */
      className: `${BOOTSTRAP_CLASS_NAMES.modal} ${BOOTSTRAP_CLASS_NAMES.fade} ${BOOTSTRAP_CLASS_NAMES.hide} ${BASE_CLASS}`,

      /**
       * Checkbox elements keyed by resource type.
       * @type {Object<string, HTMLInputElement>}
       */
      checkboxes: {},

      /**
       * DOM attributes for the modal root element.
       * @returns {object} Modal attributes.
       */
      attributes() {
        return {
          id: `${IDS.MODAL}-${this.cid}`,
          tabindex: "-1",
          role: "dialog",
          "aria-labelledby": `${IDS.TITLE}-${this.cid}`,
          "aria-hidden": "true",
        };
      },

      /**
       * Render the modal shell.
       * @param {object} options Template options.
       * @param {string} options.title Modal title.
       * @param {string} [options.datasetTitle] Full dataset title for tooltip
       * display.
       * @param {string} [options.displayDatasetTitle] Dataset title for display
       * in the modal header.
       * @returns {HTMLElement} Modal element.
       */
      template({ title, datasetTitle, displayDatasetTitle }) {
        const template = document.createElement("template");
        const BC = BOOTSTRAP_CLASS_NAMES;
        const C = CLASS_NAMES;
        const titleId = `${IDS.TITLE}-${this.cid}`;
        template.innerHTML = `
          <div class="${BC.modalDialog} ${C.modalDialog}" role="document">
            <div class="${BC.modalContent} ${C.modalContent}">
              <div class="${BC.modalHeader} ${C.modalHeader}">
                <button type="button" class="${BC.close} ${C.closeButton}" aria-label="Close">
                  <span aria-hidden="true">&times;</span>
                </button>
                <h5 class="${BC.modalTitle} ${C.modalTitle}" id="${titleId}"></h5>
              </div>
              <div class="${BC.modalBody} ${C.modalBody}">
                <div class="${C.status}" role="status" aria-live="polite"></div>
                <form class="${C.form}"></form>
              </div>
              <div class="${BC.modalFooter} ${C.modalFooter}">
                <div class="${C.footerMessage}"></div>
                <div class="${C.footerActions}">
                  <button type="button" class="${BC.button} ${BC.buttonSecondary} ${C.cancelButton}">${MESSAGES.CANCEL}</button>
                  <button type="button" class="${BC.button} ${BC.buttonPrimary} ${C.saveButton}">${MESSAGES.SAVE}</button>
                </div>
              </div>
            </div>
          </div>
        `;
        const titleEl = template.content.querySelector(`#${titleId}`);
        this.renderModalTitle(titleEl, {
          title,
          datasetTitle,
          displayDatasetTitle,
        });
        return template.content.querySelector(`.${C.modalDialog}`);
      },

      /**
       * The events this view listens for.
       * @returns {object} Backbone events hash.
       */
      events() {
        const events = {};
        events[`click .${CLASS_NAMES.saveButton}`] = "saveChanges";
        events[`click .${CLASS_NAMES.cancelButton}`] = "cancelChanges";
        events[`click .${CLASS_NAMES.closeButton}`] = "cancelChanges";
        events[`click .${CLASS_NAMES.typeRow}`] = "toggleNotificationType";
        return events;
      },

      /**
       * @param {object} [options] View options.
       * @param {ObjectNotification} [options.model] Model for this modal.
       * @param {string} [options.prefixUrl] Override for the Notification
       * Service URL.
       * @param {string|false} [options.apiVersion] Override for the API
       * version.
       * @param {object[]} [options.resourceTypes] Override for available
       * resource types.
       * @param {string} [options.pid] PID to subscribe or unsubscribe from.
       * @param {string} [options.title] Dataset title to display in the modal.
       * @param {Backbone.Model} [options.metadataModel] Metadata model used to
       * get a dataset title.
       * @param {HTMLElement} [options.buttonEl] Button that opens the modal.
       */
      initialize(options) {
        this.options = options || {};
        this.model =
          this.options.model ||
          new ObjectNotification({
            apiVersion: this.options.apiVersion,
            metadataModel: this.options.metadataModel || null,
            pid: this.options.pid,
            prefixUrl: this.options.prefixUrl,
            resourceTypes: this.options.resourceTypes,
            title: this.options.title,
          });

        this.modalInitialized = false;
        this.rendered = false;

        MetacatUI.appModel.addCSS(NotificationModalViewCSS, CSS_ID);

        this.stopListening();
        this.listenTo(
          this.model,
          "subscriptions:saved",
          this.handleSubscriptionsSaved,
        );
        this.listenTo(this.model, "notifications:reset", this.handleModelReset);
      },

      /**
       * Render the notification subscription modal.
       * @returns {NotificationModalView} The view instance.
       */
      render() {
        this.model.refreshResourceTypes();
        this.el.innerHTML = "";
        this.el.appendChild(this.template(this.getModalTitleOptions()));

        this.statusEl = this.el.querySelector(`.${CLASS_NAMES.status}`);
        this.form = this.el.querySelector(`.${CLASS_NAMES.form}`);
        this.footerMessageEl = this.el.querySelector(
          `.${CLASS_NAMES.footerMessage}`,
        );
        this.saveButton = this.el.querySelector(`.${CLASS_NAMES.saveButton}`);
        this.cancelButton = this.el.querySelector(
          `.${CLASS_NAMES.cancelButton}`,
        );

        this.renderNotificationTypes();
        this.updateEmailMessage();

        const setupError = this.model.getSetupError();
        if (setupError) {
          this.showError(setupError);
        } else {
          this.removeError();
        }

        this.setCheckboxes(this.model.get("savedResourceTypes"));
        this.updateControlState();
        this.initializeModal();
        this.rendered = true;

        return this;
      },

      /**
       * Make the modal visible and fetch the user's current subscriptions.
       * @returns {NotificationModalView} The view instance.
       */
      show() {
        this.render();
        this.$el.modal("show");
        this.loadSubscriptions();
        return this;
      },

      /** Initialize Bootstrap modal behavior and modal lifecycle events */
      initializeModal() {
        if (!this.modalInitialized) {
          this.$el.on(
            "hidden.notificationSubscriptionModal",
            this.handleHidden.bind(this),
          );
          this.$el.modal({ show: false });
          this.modalInitialized = true;
        }
      },

      /**
       * Fetch the current subscriptions for this PID and update the checkboxes.
       * @returns {Promise<void>} Resolves when subscriptions have loaded.
       */
      async loadSubscriptions() {
        const setupError = this.model.getSetupError();
        if (setupError) {
          this.showError(setupError);
          this.updateControlState();
          return;
        }

        this.showStatus(MESSAGES.LOADING);
        const loadPromise = this.model.loadSubscriptions();
        const { loadRequestId } = this.model;
        const isCurrentLoad = () => loadRequestId === this.model.loadRequestId;
        this.updateControlState();

        try {
          await loadPromise;
          if (!isCurrentLoad()) return;
          this.setCheckboxes(this.model.get("savedResourceTypes"));
          this.removeError();
        } catch (error) {
          if (!isCurrentLoad()) return;
          this.showError(error, "LOAD_FAILED");
        } finally {
          if (isCurrentLoad()) {
            this.updateControlState();
          }
        }
      },

      /** Render all configured resource type checkboxes */
      renderNotificationTypes() {
        this.checkboxes = {};
        if (!this.form) return;

        this.form.innerHTML = "";
        this.model.get("resourceTypeOptions").forEach((option) => {
          this.form.appendChild(this.renderType(option));
        });
      },

      /**
       * Renders a checkbox, label, and description for a notification type.
       * @param {object} options Notification type options.
       * @param {string} options.type The notification type identifier.
       * @param {string} options.label A user-friendly label for the type.
       * @param {string} options.description A description of the notification
       * type.
       * @returns {HTMLElement} The rendered notification type row.
       */
      renderType({ type, label, description }) {
        const id = `${BASE_CLASS}-${this.cid}-${type}`;

        const checkbox = document.createElement("input");
        checkbox.type = "checkbox";
        checkbox.id = id;
        checkbox.name = "notification-resource-type";
        checkbox.value = type;
        checkbox.className = CLASS_NAMES.typeCheckbox;

        this.checkboxes[type] = checkbox;

        const labelEl = document.createElement("label");
        labelEl.htmlFor = id;
        labelEl.className = CLASS_NAMES.typeLabel;
        labelEl.textContent = label;

        const content = document.createElement("div");
        content.className = CLASS_NAMES.typeContent;
        content.appendChild(labelEl);

        if (description) {
          const descriptionEl = document.createElement("p");
          descriptionEl.className = CLASS_NAMES.typeDescription;
          descriptionEl.textContent = description;
          content.appendChild(descriptionEl);
        }

        const container = document.createElement("div");
        container.className = CLASS_NAMES.typeRow;
        container.appendChild(checkbox);
        container.appendChild(content);

        return container;
      },

      /**
       * Toggle a notification type when its row is clicked.
       * @param {Event} event Click event.
       */
      toggleNotificationType(event) {
        const row = event.currentTarget;
        const checkbox = row?.querySelector(`.${CLASS_NAMES.typeCheckbox}`);
        if (!checkbox || checkbox.disabled) return;

        const label = event.target.closest?.(`.${CLASS_NAMES.typeLabel}`);
        if (event.target !== checkbox && !label) {
          checkbox.checked = !checkbox.checked;
        }
      },

      /**
       * Save the selected notification settings.
       * @param {Event} event The click event.
       * @returns {Promise<void>} Resolves when save completes.
       */
      async saveChanges(event) {
        if (event) event.preventDefault();
        if (this.model.get("savingSubscriptions")) return;

        const setupError = this.model.getSetupError();
        if (setupError) {
          this.showError(setupError);
          this.updateControlState();
          return;
        }

        this.showStatus(MESSAGES.SAVING);
        const savePromise = this.model.saveSubscriptions(
          this.getSelectedResourceTypes(),
        );
        this.updateControlState();

        try {
          const result = await savePromise;
          if (result.stale) return;
          this.setCheckboxes(this.model.get("savedResourceTypes"));
          this.removeError();
          this.hide();

          if (!result.changed) {
            this.resetFormToSavedState();
          }
        } catch (error) {
          this.showError(error, "SAVE_FAILED");
        } finally {
          this.updateControlState();
        }
      },

      /**
       * Cancel changes, restore the last saved state, and close the modal.
       * @param {Event} event The click event.
       */
      cancelChanges(event) {
        if (event) event.preventDefault();
        this.resetFormToSavedState();
        this.hide();
      },

      /** Hide the modal */
      hide() {
        if (this.modalInitialized) {
          this.$el.modal("hide");
        }
      },

      /** Reset unsaved form edits after the modal closes */
      handleHidden() {
        this.resetFormToSavedState();
      },

      /** Re-render after model configuration or auth changes */
      handleModelReset() {
        if (!this.rendered) return;

        this.render();
        if (this.isVisible()) {
          this.loadSubscriptions();
        }
      },

      /**
       * Re-emit the model saved event for parent views.
       * @param {object} payload Saved subscription payload.
       */
      handleSubscriptionsSaved(payload) {
        this.trigger("subscriptions:saved", payload);
      },

      /** Reset checkboxes to the last loaded or saved subscription state */
      resetFormToSavedState() {
        this.setCheckboxes(this.model.get("savedResourceTypes"));
        if (!this.model.getSetupError()) this.removeError();
        this.updateControlState();
      },

      /**
       * Set checkbox values from resource type names.
       * @param {string[]} resourceTypes Resource type names to check.
       */
      setCheckboxes(resourceTypes) {
        const selectedTypeKeys = new Set(
          Array.isArray(resourceTypes) ? resourceTypes : [],
        );

        this.model.get("resourceTypeOptions").forEach((option) => {
          const checkbox = this.checkboxes[option.type];
          if (checkbox) {
            checkbox.checked = selectedTypeKeys.has(option.type);
          }
        });
      },

      /**
       * Get selected resource type names in configured display order.
       * @returns {string[]} Selected resource types.
       */
      getSelectedResourceTypes() {
        return this.model
          .get("resourceTypeOptions")
          .filter((option) => {
            const checkbox = this.checkboxes[option.type];
            return checkbox?.checked;
          })
          .map((option) => option.type);
      },

      /** Enable or disable controls based on loading and setup state */
      updateControlState() {
        const busy =
          this.model.get("loadingSubscriptions") ||
          this.model.get("savingSubscriptions");
        const setupError = this.model.getSetupError();
        const canSave =
          !busy && this.model.get("loadedSubscriptions") && !setupError;
        const disableCheckboxes =
          busy || !this.model.get("loadedSubscriptions") || !!setupError;

        Object.values(this.checkboxes).forEach((checkbox) => {
          const row = checkbox.closest(`.${CLASS_NAMES.typeRow}`);
          row?.classList.toggle(CLASS_NAMES.typeRowDisabled, disableCheckboxes);

          if (disableCheckboxes) {
            checkbox.setAttribute(HTML_ATTRS.disabled, HTML_ATTRS.disabled);
          } else {
            checkbox.removeAttribute(HTML_ATTRS.disabled);
          }
        });

        if (this.saveButton) {
          this.saveButton.disabled = !canSave;
          this.saveButton.classList.toggle(
            BOOTSTRAP_CLASS_NAMES.disabled,
            !canSave,
          );
          this.saveButton.textContent = this.model.get("savingSubscriptions")
            ? MESSAGES.SAVING_BUTTON
            : MESSAGES.SAVE;
        }

        if (this.cancelButton) {
          const saving = this.model.get("savingSubscriptions");
          this.cancelButton.disabled = saving;
          this.cancelButton.classList.toggle(
            BOOTSTRAP_CLASS_NAMES.disabled,
            saving,
          );
        }
      },

      /**
       * Display a status message inside the modal.
       * @param {string|HTMLElement} message Status message.
       * @param {string} [classes] Alert classes.
       */
      showStatus(message, classes = CLASS_NAMES.statusInfo) {
        if (!this.statusEl) return;

        if (!message) {
          this.statusEl.innerHTML = "";
          this.statusEl.style.display = "none";
          return;
        }

        this.statusEl.className = `${CLASS_NAMES.status} ${classes}`;
        this.statusEl.innerHTML = "";
        if (typeof message === "string") {
          this.statusEl.textContent = message;
        } else {
          this.statusEl.appendChild(message);
        }
        this.statusEl.style.display = "";
      },

      /**
       * Display an error message in the view.
       * @param {Error|string} error Error or error code.
       * @param {string} [fallbackCode] Fallback message code.
       */
      showError(error, fallbackCode) {
        const code = this.getErrorCode(error, fallbackCode);
        const message = this.getErrorMessage(code);

        if (code === ERROR_CODES.SIGN_IN_REQUIRED) {
          this.showStatus(message, CLASS_NAMES.statusInfo);
          this.updateTooltip(message);
          return;
        }

        if (code === ERROR_CODES.MISSING_EMAIL) {
          this.showMissingEmailPrompt();
          return;
        }

        const statusMessage = MESSAGES[code]
          ? message
          : `${MESSAGES.SERVICE_UNAVAILABLE_PREFIX} (${message})`;
        this.showStatus(statusMessage, CLASS_NAMES.statusError);
        this.updateTooltip(message);
      },

      /** Prompt users without an email address to update account settings */
      showMissingEmailPrompt() {
        const prompt = document.createElement("span");
        const link = document.createElement("a");
        link.href = this.model.getAccountSettingsUrl();
        link.textContent = MESSAGES.ACCOUNT_SETTINGS_LINK_TEXT;

        prompt.append(
          MESSAGES.MISSING_EMAIL_PROMPT_PREFIX,
          link,
          MESSAGES.MISSING_EMAIL_PROMPT_SUFFIX,
        );

        this.showStatus(prompt, CLASS_NAMES.statusInfo);
        this.updateTooltip(MESSAGES.MISSING_EMAIL);
      },

      /** Remove the active status/error message */
      removeError() {
        this.showStatus("");
        this.updateTooltip("");
      },

      /**
       * Update the opener button title with the current error when possible.
       * @param {string} message Tooltip text.
       */
      updateTooltip(message) {
        const { buttonEl } = this.options;
        if (!buttonEl) return;

        if (message) {
          buttonEl.setAttribute("title", message);
        } else {
          buttonEl.removeAttribute("title");
        }
      },

      /** Show the notification email address in the modal footer */
      updateEmailMessage() {
        if (!this.footerMessageEl) return;

        const emailAddress = this.model.getEmailAddress();
        this.footerMessageEl.textContent = emailAddress
          ? `${MESSAGES.FOOTER_EMAIL_PREFIX}${emailAddress}`
          : "";
      },

      /**
       * Render the modal title as a prefix plus emphasized dataset title.
       * @param {HTMLElement} titleEl Title element.
       * @param {object} options Title options.
       * @param {string} options.title Full fallback title.
       * @param {string} [options.datasetTitle] Full dataset title.
       * @param {string} [options.displayDatasetTitle] Truncated dataset title.
       */
      renderModalTitle(titleEl, { title, datasetTitle, displayDatasetTitle }) {
        if (!titleEl) return;
        const titleElEdited = titleEl;

        titleElEdited.innerHTML = "";
        if (!datasetTitle) {
          titleElEdited.textContent = title;
          return;
        }

        const prefix = document.createElement("span");
        prefix.className = CLASS_NAMES.modalTitlePrefix;
        prefix.textContent = MESSAGES.MODAL_TITLE_PREFIX.trim();

        const dataset = document.createElement("span");
        dataset.className = CLASS_NAMES.modalTitleDataset;
        dataset.textContent = displayDatasetTitle;
        dataset.setAttribute("title", datasetTitle);

        titleElEdited.append(prefix, dataset);
      },

      /**
       * Build template data for the modal title.
       * @returns {{title: string, datasetTitle: string, displayDatasetTitle: string}}
       * Modal title template data.
       */
      getModalTitleOptions() {
        const datasetTitle = this.model.getDatasetTitle();
        return {
          title: this.getModalTitle(),
          datasetTitle,
          displayDatasetTitle: this.truncateDatasetTitle(datasetTitle),
        };
      },

      /**
       * Get the modal title.
       * @returns {string} Modal title.
       */
      getModalTitle() {
        const datasetTitle = this.model.getDatasetTitle();
        return datasetTitle
          ? `${MESSAGES.MODAL_TITLE_PREFIX}${datasetTitle}`
          : MESSAGES.MODAL_TITLE_DEFAULT;
      },

      /**
       * Truncate long dataset titles in the modal header.
       * @param {string} title Dataset title.
       * @returns {string} Display title.
       */
      truncateDatasetTitle(title) {
        if (!title || title.length <= MAX_DATASET_TITLE_LENGTH) return title;
        return `${title.slice(0, MAX_DATASET_TITLE_LENGTH - 3).trimEnd()}...`;
      },

      /**
       * Get an error code from an Error, string, or fallback.
       * @param {Error|string} error Error or code.
       * @param {string} [fallbackCode] Fallback code.
       * @returns {string} Error code.
       */
      getErrorCode(error, fallbackCode) {
        if (error?.message) return error.message;
        if (typeof error === "string") return error;
        return fallbackCode;
      },

      /**
       * Get a display message for an error code.
       * @param {string} code Error code.
       * @returns {string} Display message.
       */
      getErrorMessage(code) {
        return MESSAGES[code] || code || MESSAGES.SERVICE_UNAVAILABLE_PREFIX;
      },

      /**
       * Check whether the modal is visible.
       * @returns {boolean} True when visible.
       */
      isVisible() {
        return this.$el.is(":visible") || this.$el.hasClass("in");
      },

      /**
       * Cleanup listeners when the view is removed.
       * @returns {NotificationModalView} View instance.
       */
      onClose() {
        this.stopListening();
        this.$el.off(".notificationSubscriptionModal");
        if (this.modalInitialized) {
          this.$el.modal("hide");
          this.$el.data("modal", null);
        }
        return Backbone.View.prototype.remove.call(this);
      },
    },
  );

  return NotificationModalView;
});