src/EditSubscriptionsDialog.js

import CdError from './CdError';
import MultilineTextInputWidget from './MultilineTextInputWidget';
import ProcessDialog from './ProcessDialog';
import cd from './cd';
import controller from './controller';
import { getPageIds, getPageTitles } from './utils-api';
import { sleep, unique } from './utils-general';
import { tweakUserOoUiClass } from './utils-oojs';

/**
 * Class used to create an "Edit subscriptions" dialog.
 *
 * @augments ProcessDialog
 */
class EditSubscriptionsDialog extends ProcessDialog {
  static name = 'editSubscriptionsDialog';
  static title = cd.s('ewsd-title');
  static actions = [
    {
      action: 'close',
      modes: ['edit'],
      flags: ['safe', 'close'],
      disabled: true,
    },
    {
      action: 'save',
      modes: ['edit'],
      label: cd.s('ewsd-save'),
      flags: ['primary', 'progressive'],
      disabled: true,
    },
  ];
  static size = 'large';
  static cdKey = 'ewsd';

  /**
   * Create an "Edit subscriptions" dialog.
   */
  constructor() {
    super();

    this.subscriptions = controller.getSubscriptionsInstance();
  }

  /**
   * OOUI native method to get the height of the window body.
   *
   * @returns {number}
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getBodyHeight
   * @ignore
   */
  getBodyHeight() {
    return (
      (this.$errorItems ? this.$errors.prop('scrollHeight') : this.$body.prop('scrollHeight')) +

      // Fixes double scrollbar with some system font settings.
      1
    );
  }

  /**
   * OOUI native method that initializes window contents.
   *
   * @param {...*} [args]
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#initialize
   * @see https://www.mediawiki.org/wiki/OOUI/Windows#Window_lifecycle
   * @ignore
   */
  initialize(...args) {
    super.initialize(...args);

    this.pushPending();

    this.initPromise = this.subscriptions.load();

    this.loadingPanel = new OO.ui.PanelLayout({
      padded: true,
      expanded: false,
    });
    this.loadingPanel.$element.append($('<div>').text(cd.s('loading-ellipsis')));

    this.sectionsPanel = new OO.ui.PanelLayout({
      padded: false,
      expanded: false,
    });

    this.stackLayout = new OO.ui.StackLayout({
      items: [this.loadingPanel, this.sectionsPanel],
    });

    this.$body.append(this.stackLayout.$element);
  }

  /**
   * OOUI native method that returns a "setup" process which is used to set up a window for use in a
   * particular context, based on the `data` argument.
   *
   * @param {object} [data] Dialog opening data
   * @returns {external:OO.ui.Process}
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getSetupProcess
   * @see https://www.mediawiki.org/wiki/OOUI/Windows#Window_lifecycle
   * @ignore
   */
  getSetupProcess(data) {
    return super.getSetupProcess(data).next(() => {
      this.stackLayout.setItem(this.loadingPanel);
      this.actions.setMode('edit');
    });
  }

  /**
   * OOUI native method that returns a "ready" process which is used to ready a window for use in a
   * particular context, based on the `data` argument.
   *
   * @param {object} data Window opening data
   * @returns {external:OO.ui.Process}
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getReadyProcess
   * @see https://www.mediawiki.org/wiki/OOUI/Windows#Window_lifecycle
   * @ignore
   */
  getReadyProcess(data) {
    return super.getReadyProcess(data).next(async () => {
      let pages;
      try {
        await this.initPromise;
        pages = await getPageTitles(this.subscriptions.getPageIds());
      } catch (e) {
        this.handleError(e, 'ewsd-error-processing', false);
        return;
      }

      // Logically, there should be no coinciding titles between pages, so we don't need a
      // separate "return 0" condition.
      pages.sort((page1, page2) => page1.title > page2.title ? 1 : -1);

      const value = pages
        // Filter out deleted pages
        .filter((page) => page.title)

        .map((page) => (
          this.subscriptions.getForPageId(page.pageid)
            .map((section) => `${page.title}#${section}`)
            .join('\n')
        ))
        .join('\n');

      this.input = new MultilineTextInputWidget({
        value,
        rows: 30,
        classes: ['cd-editSubscriptions-input'],
      });
      this.input.on('change', (newValue) => {
        this.actions.setAbilities({ save: newValue !== value });
      });

      this.sectionsPanel.$element.append(this.input.$element);

      this.stackLayout.setItem(this.sectionsPanel);
      this.input.focus();
      this.actions.setAbilities({ close: true });

      // A dirty workaround to avoid a scrollbar appearing when the window is loading. Couldn't
      // figure out a way to do this out of the box.
      this.$body.css('overflow', 'hidden');
      sleep(500).then(() => {
        this.$body.css('overflow', '');
      });

      this.updateSize();
      this.popPending();

      controller.addPreventUnloadCondition('dialog', () => this.isUnsaved());
    });
  }

  /**
   * OOUI native method that returns a process for taking action.
   *
   * @param {string} action Symbolic name of the action.
   * @returns {external:OO.ui.Process}
   * @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getActionProcess
   * @ignore
   */
  getActionProcess(action) {
    if (action === 'save') {
      return new OO.ui.Process(this.save.bind(this));
    } else if (action === 'close') {
      return new OO.ui.Process(async () => {
        await this.confirmClose();
      });
    }
    return super.getActionProcess(action);
  }

  /**
   * Save the subscriptions list.
   *
   * @protected
   */
  async save() {
    this.updateSize();
    this.pushPending();

    const sections = {};
    const pageTitles = [];
    this.input
      .getValue()
      .split('\n')
      .forEach((section) => {
        const match = section.match(/^(.+?)#(.+)$/);
        if (match) {
          const pageTitle = match[1].trim();
          const sectionTitle = match[2].trim();
          if (!sections[pageTitle]) {
            sections[pageTitle] = [];
            pageTitles.push(pageTitle);
          }
          sections[pageTitle].push(sectionTitle);
        }
      });

    let normalized;
    let redirects;
    let pages;
    try {
      ({ normalized, redirects, pages } = await getPageIds(pageTitles) || {});
    } catch (e) {
      this.handleError(e, 'ewsd-error-processing', true);
      return;
    }

    // Correct to normalized titles && redirect targets, add to the collection.
    normalized
      .concat(redirects)
      .filter((page) => sections[page.from])
      .forEach((page) => {
        sections[page.to] ||= [];
        sections[page.to].push(...sections[page.from]);
        delete sections[page.from];
      });

    const titleToId = {};
    pages
      .filter((page) => page.pageid !== undefined)
      .forEach((page) => {
        titleToId[page.title] = page.pageid;
      });

    const allPagesData = {};
    Object.keys(sections)
      .filter((key) => titleToId[key])
      .forEach((key) => {
        allPagesData[titleToId[key]] = this.subscriptions.itemsToKeys(sections[key].filter(unique));
      });

    try {
      this.subscriptions.save(allPagesData);
    } catch (e) {
      if (e instanceof CdError) {
        const { type, code } = e.data;
        if (type === 'internal' && code === 'sizeLimit') {
          this.handleError(e, 'ewsd-error-maxsize', false);
        } else {
          this.handleError(e, 'ewsd-error-processing', true);
        }
      } else {
        this.handleError(e);
      }
      this.actions.setAbilities({ save: true });
      return;
    }

    this.popPending();
    this.close();
    mw.notify(cd.s('ewsd-saved'));
  }
}

tweakUserOoUiClass(EditSubscriptionsDialog);

export default EditSubscriptionsDialog;