src/utils-oojs.js

/**
 * Helpers for heavily used OOUI widgets and dialogs.
 *
 * @module utilsOoui
 */

import cd from './cd';
import controller from './controller';

/**
 * OOjs namespace.
 *
 * @external OO
 * @global
 * @see https://doc.wikimedia.org/oojs/master/OO.html
 */

/**
 * Namespace for all classes, static methods and static properties of OOUI.
 *
 * @namespace ui
 * @memberof external:OO
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui
 */

/**
 * OOjs event emitter.
 *
 * @namespace EventEmitter
 * @memberof external:OO
 * @see https://doc.wikimedia.org/oojs/master/OO.EventEmitter.html
 */

/**
 * OOUI window manager.
 *
 * @class WindowManager
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.WindowManager
 */

/**
 * OOUI field layout.
 *
 * @class FieldLayout
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.FieldLayout
 */

/**
 * OOUI checkbox input widget.
 *
 * @class CheckboxInputWidget
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.CheckboxInputWidget
 */

/**
 * OOUI radio select widget.
 *
 * @class RadioSelectWidget
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.RadioSelectWidget
 */

/**
 * OOUI radio option widget.
 *
 * @class RadioOptionWidget
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.RadioOptionWidget
 */

/**
 * OOUI copy text layout.
 *
 * @class CopyTextLayout
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.CopyTextLayout
 */

/**
 * OOUI text input widget.
 *
 * @class TextInputWidget
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.TextInputWidget
 */

/**
 * OOUI process dialog.
 *
 * @class ProcessDialog
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ProcessDialog
 */

/**
 * OOUI process.
 *
 * @class Process
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.Process
 */

/**
 * OOUI page layout.
 *
 * @class PageLayout
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PageLayout
 */

/**
 * OOUI horizontal layout.
 *
 * @class HorizontalLayout
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.HorizontalLayout
 */

/**
 * OOUI button widget.
 *
 * @class ButtonWidget
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ButtonWidget
 */

/**
 * OOUI popup button widget.
 *
 * @class PopupButtonWidget
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PopupButtonWidget
 */

/**
 * OOUI popup widget.
 *
 * @class PopupWidget
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.PopupWidget
 */

/**
 * OOUI button menu select widget.
 *
 * @class ButtonMenuSelectWidget
 * @memberof external:OO.ui
 * @see https://doc.wikimedia.org/oojs-ui/master/js/#!/api/OO.ui.ButtonMenuSelectWidget
 */

/**
 * Display an OOUI message dialog where user is asked to confirm something. Compared to
 * {@link https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.html#.confirm OO.ui.confirm}, returns an
 * action string, not a boolean (which helps to differentiate between more than two types of answer
 * and also a window close by pressing Esc).
 *
 * @param {external:jQuery|string} message
 * @param {object} [options={}]
 * @returns {Promise.<Array>}
 */
export async function showConfirmDialog(message, options = {}) {
  const dialog = new OO.ui.MessageDialog({ classes: ['cd-dialog-confirm'] });
  controller.getWindowManager().addWindows([dialog]);
  const win = controller.getWindowManager().openWindow(
    dialog,
    Object.assign(
      // Default options
      {
        message,

        // OO.ui.MessageDialog standard
        actions: [
          {
            action: 'accept',
            label: OO.ui.deferMsg('ooui-dialog-message-accept'),
            flags: 'primary',
          },
          {
            action: 'reject',
            label: OO.ui.deferMsg('ooui-dialog-message-reject'),
            flags: 'safe',
          },
        ],
      },
      options
    )
  );
  win.opened.then(() => {
    if (message instanceof $) {
      mw.hook('wikipage.content').fire(message);
    }
  });

  return (await win.closed)?.action;
}

/**
 * @typedef {object} CreateTextFieldReturn
 * @property {external:OO.ui.FieldLayout} field
 * @property {external:OO.ui.TextInputWidget} input
 */

/**
 * Create a text input field.
 *
 * @param {object} options
 * @param {string} [options.value]
 * @param {string} options.label
 * @param {string} [options.required]
 * @param {string} [options.classes]
 * @param {string} [options.maxLength]
 * @param {string} [options.help]
 * @returns {CreateTextFieldReturn}
 */
export function createTextField({
  value,
  maxLength,
  required,
  classes,
  label,
  help,
}) {
  const input = new (require('./TextInputWidget').default)({ value, maxLength, required, classes });
  const field = new OO.ui.FieldLayout(input, {
    label,
    align: 'top',
    help,
    helpInline: true,
  });
  return { field, input };
}

/**
 * @typedef {object} CreateNumberFieldReturn
 * @property {external:OO.ui.FieldLayout} field
 * @property {external:OO.ui.TextInputWidget} input
 */

/**
 * Create a number input field.
 *
 * @param {object} options
 * @param {string} options.value
 * @param {string} options.label
 * @param {string} [options.min]
 * @param {string} [options.max]
 * @param {string} [options.buttonStep]
 * @param {string} [options.help]
 * @param {string[]} [options.classes]
 * @returns {CreateNumberFieldReturn}
 */
export function createNumberField({
  value,
  label,
  min,
  max,
  buttonStep = 1,
  help,
  classes,
}) {
  const input = new OO.ui.NumberInputWidget({
    input: { value },
    step: 1,
    buttonStep,
    min,
    max,
    classes: ['cd-numberInput'],
  });
  const field = new OO.ui.FieldLayout(input, {
    label,
    align: 'top',
    help,
    helpInline: true,
    classes,
  });
  return { field, input };
}

/**
 * @typedef {object} CreateCheckboxFieldReturn
 * @property {external:OO.ui.FieldLayout} field
 * @property {import('./CheckboxInputWidget').default} input
 */

/**
 * Create a checkbox field.
 *
 * @param {object} options
 * @param {string} options.value
 * @param {string} options.label
 * @param {boolean} [options.selected]
 * @param {boolean} [options.disabled]
 * @param {string} [options.help]
 * @param {string} [options.tabIndex]
 * @param {string[]} [options.classes]
 * @returns {CreateCheckboxFieldReturn}
 */
export function createCheckboxField({
  value,
  selected,
  disabled,
  label,
  help,
  tabIndex,
  classes,
}) {
  const input = new (require('./CheckboxInputWidget').default)({
    value,
    selected,
    disabled,
    tabIndex,
  });
  const field = new OO.ui.FieldLayout(input, {
    label,
    align: 'inline',
    help,
    helpInline: true,
    classes,
  });
  return { field, input };
}

/**
 * @typedef {object} CreateRadioFieldReturn
 * @property {external:OO.ui.FieldLayout} field
 * @property {external:OO.ui.RadioSelectWidget} select
 * @property {RadioOptionWidget[]} items
 */

/**
 * Create a radio select field.
 *
 * @param {object} options
 * @param {string} options.label
 * @param {string} [options.selected]
 * @param {string} [options.help]
 * @param {object[]} options.options
 * @returns {CreateRadioFieldReturn}
 */
export function createRadioField({ label, selected, help, options }) {
  const items = options.map((config) => new (require('./RadioOptionWidget').default)(config));
  const select = new OO.ui.RadioSelectWidget({ items });

  // Workarounds for T359920
  select.$element.off('mousedown');
  select.$focusOwner = $();

  const field = new OO.ui.FieldLayout(select, {
    label,
    align: 'top',
    help,
    helpInline: true,
  });

  if (selected !== undefined) {
    select.selectItemByData(selected);
  }

  return { field, select, items };
}

/**
 * Create an action field for copying text from an input.
 *
 * @param {object} options
 * @param {object} options.label
 * @param {object} options.value
 * @param {object} [options.disabled]
 * @param {object} [options.help]
 * @param {object} options.copyCallback
 * @returns {external:OO.ui.CopyTextLayout|external:OO.ui.ActionFieldLayout}
 */
export function createCopyTextField({ label, value, disabled = false, help, copyCallback }) {
  let field;
  if (OO.ui.CopyTextLayout) {
    field = new OO.ui.CopyTextLayout({
      align: 'top',
      label,
      copyText: value,
      button: { disabled },
      textInput: { disabled },
      help,
      helpInline: Boolean(help),
    });
    field.on('copy', (successful) => {
      copyCallback(successful, field);
    });
  } else {
    // Older MediaWiki versions
    const input = new OO.ui.TextInputWidget({ value, disabled });
    const button = new OO.ui.ButtonWidget({
      label: cd.s('copy'),
      icon: 'copy',
      disabled,
    });
    button.on('click', () => {
      copyCallback(input.getValue());
    });
    field = new OO.ui.ActionFieldLayout(input, button, {
      align: 'top',
      label,
      help,
      helpInline: Boolean(help),
    });
  }
  return field;
}

/**
 * Add some properties to the inheritor class that the (ES5)
 * {@link https://www.mediawiki.org/wiki/OOjs/Inheritance OOUI inheritance mechanism} uses. It
 * partly replicates the operations made in
 * {@link https://doc.wikimedia.org/oojs/master/OO.html#.inheritClass OO.inheritClass}.
 *
 * @param {Function} targetClass Inheritor class.
 * @returns {Function}
 */
export function tweakUserOoUiClass(targetClass) {
  const originClass = Object.getPrototypeOf(targetClass);
  OO.initClass(originClass);
  targetClass.static = Object.create(originClass.static);
  Object.keys(targetClass)
    .filter((key) => key !== 'static')
    .forEach((key) => {
      targetClass.static[key] = targetClass[key];
    });
  targetClass.parent = targetClass.super = originClass;
  return targetClass;
}

/**
 * Mix in a user class into a target OOUI class.
 *
 * @param {Function} targetClass
 * @param {Function} originClass
 */
export function mixinUserOoUiClass(targetClass, originClass) {
  OO.mixinClass(targetClass, originClass);

  Object.getOwnPropertyNames(originClass.prototype)
    .filter((key) => key !== 'constructor')
    .forEach((key) => {
      targetClass.prototype[key] = originClass.prototype[key];
    });
}

/**
 * Add {@link external:OO.EventEmitter OO.EventEmitter}'s methods to an arbitrary object itself, not
 * its prototype. Can be used for singletons or classes. In the latter case, the methods will be
 * added as static.
 *
 * @param {object} obj
 */
export function mixEventEmitterInObject(obj) {
  const dummy = { prototype: {} };
  OO.mixinClass(dummy, OO.EventEmitter);
  Object.assign(obj, dummy.prototype);
  OO.EventEmitter.call(obj);
}