Home Manual Reference Source

source/SignalHandlerSingleton.js

import makeCustomError from "./makeCustomError";
import PrettyFormat from "./utils/PrettyFormat";

/**
 * Exception raised by {@link HttpCookies#getStrict} when the cookie is not found.
 *
 * @type {Error}
 */
export let DuplicateReceiverNameForSignal = makeCustomError('DuplicateReceiverNameForSignal');


/**
 * Represents information about the received signal.
 *
 * An object of this class is sent to the ``callback``
 * of all signal receivers.
 *
 * The data sent by the signal is available in
 * {@link ReceivedSignalInfo.data}.
 */
export class ReceivedSignalInfo {
  constructor(data, signalName, receiverName) {
    /**
     * The data sent by {@link SignalHandlerSingleton#send}.
     */
    this.data = data;

    /**
     * The signal name.
     *
     * @type {string}
     */
    this.signalName = signalName;

    /**
     * The receiver name.
     *
     * @type {string}
     */
    this.receiverName = receiverName;
  }

  /**
   * Get a string with information about the received signal.
   * Includes signal name and receiver name.
   *
   * @returns {string}
   */
  toString() {
    return `ReceivedSignalInfo: signalName="${this.signalName}" receiverName="${this.receiverName}"`;
  }

  /**
   * Get the data pretty formatted as a string.
   *
   * @returns {string}
   */
  getPrettyFormattedData() {
    return new PrettyFormat(this.data).toString(2);
  }

  /**
   * Get a string with debug information about the received signal.
   * Includes signal name, receiver name, and pretty formatted data.
   *
   * @returns {string}
   */
  toDebugString() {
    return `${this.toString} data=${this.getPrettyFormattedData()}`;
  }
}


/**
 * Private class used by {@link _SignalReceivers} to represent
 * a single receiver listening for a single signal.
 */
class _SignalReceiver {
  constructor(signal, name, callback) {
    this.signal = signal;
    this.name = name;
    this.callback = callback;
  }

  /**
   * Asynchronously trigger the receiver callback.
   * @param data The signal data (the data argument provided for
   *    {@link SignalHandlerSingleton#send}.
   */
  trigger(data) {
    setTimeout(() => {
      this.callback(new ReceivedSignalInfo(data, this.signal.name, this.name));
    }, 0);
  }
}


/**
 * Object containing debugging information about a sent
 * signal.
 */
export class SentSignalInfo {
  constructor(signalName) {
    /**
     * The signal name.
     *
     * @type {string}
     */
    this.signalName = signalName;

    /**
     * Array of triggered receiver names.
     *
     * @type {Array}
     */
    this.triggeredReceiverNames = [];
  }

  _addReceiverName(receiverName) {
    this.triggeredReceiverNames.push(receiverName);
  }

  /**
   * Get a string representation of the sent signal info.
   *
   * @returns {string}
   */
  toString() {
    let receivers = this.triggeredReceiverNames.join(', ');
    if(receivers === '') {
      receivers = 'NO RECEIVERS';
    }
    return `Signal: ${this.signalName} was sent to: ${receivers}`;
  }
}


/**
 * Private class used by {@link SignalHandlerSingleton}
 * to represent all receivers for a single signal.
 */
class _SignalReceivers {
  constructor(name) {
    this.name = name;
    this.receiverMap = new Map();
  }

  /**
   * Add a receiver.
   *
   * @throw DuplicateReceiverNameForSignal If the receiver is already registered for the signal.
   */
  addReceiver(receiverName, callback) {
    if(this.receiverMap.has(receiverName)) {
      throw new DuplicateReceiverNameForSignal(
        `The "${receiverName}" receiver is already registered for the "${this.name}" signal`);
    }
    this.receiverMap.set(
      receiverName,
      new _SignalReceiver(this, receiverName, callback));
  }

  /**
   * Remove a receiver.
   *
   * If the receiver is not registered for the signal,
   * nothing happens.
   */
  removeReceiver(receiverName) {
    if(this.receiverMap.has(receiverName)) {
      this.receiverMap.delete(receiverName);
    }
  }

  /**
   * Check if we have a specific receiver for this signal.
   */
  hasReceiver(receiverName) {
    return this.receiverMap.has(receiverName);
  }

  /**
   * Get the number of receivers registered for the signal.
   */
  receiverCount() {
    return this.receiverMap.size;
  }

  /**
   * Send the signal.
   *
   * @param data The data sent with the signal. Forwarded to
   *      the signal receiver callback.
   * @param {SentSignalInfo} info If this is provided, we add the
   *      name of all receivers the signal was sent to.
   */
  send(data, info) {
    for(let receiver of this.receiverMap.values()) {
      receiver.trigger(data);
      if(info) {
        info._addReceiverName(receiver.name);
      }
    }
  }
}


/**
 * The instance of the {@link SignalHandlerSingleton}.
 */
let _instance = null;

/**
 * Signal handler singleton for global communication.
 *
 * @example <caption>Basic example</caption>
 * let signalHandler = new SignalHandlerSingleton();
 * signalHandler.addReceiver('myapp.mysignal', 'myotherapp.MyReceiver', (receivedSignalInfo) => {
 *     console.log('Signal received. Data:', receivedSignalInfo.data);
 * });
 * signalHandler.send('myapp.mysignal', {'the': 'data'});
 *
 *
 * @example <caption>Recommended signal and receiver naming</caption>
 *
 * // In myapp/menu/MenuComponent.js
 * class MenuComponent {
 *     constructor(menuName) {
 *         this.menuName = menuName;
 *         let signalHandler = new SignalHandlerSingleton();
 *         signalHandler.addReceiver(
 *             `toggleMenu#${this.menuName}`,
 *             'myapp.menu.MenuComponent',
 *             (receivedSignalInfo) => {
 *                  this.toggle();
 *             }
 *         );
 *     }
 *     toggle() {
 *         // Toggle the menu
 *     }
 * }
 *
 * // In myotherapp/widgets/MenuToggle.js
 * class MenuToggle {
 *     constructor(menuName) {
 *         this.menuName = menuName;
 *     }
 *     toggle() {
 *         let signalHandler = new SignalHandlerSingleton();
 *         signalHandler.send(`toggleMenu#${this.menuName}`);
 *     }
 * }
 *
 * @example <caption>Multiple receivers</caption>
 * let signalHandler = new SignalHandlerSingleton();
 * signalHandler.addReceiver('myapp.mysignal', 'myotherapp.MyFirstReceiver', (receivedSignalInfo) => {
 *     console.log('Signal received by receiver 1!');
 * });
 * signalHandler.addReceiver('myapp.mysignal', 'myotherapp.MySecondReceiver', (receivedSignalInfo) => {
 *     console.log('Signal received by receiver 1!');
 * });
 * signalHandler.send('myapp.mysignal', {'the': 'data'});
 *
 *
 * @example <caption>Debugging</caption>
 * let signalHandler = new SignalHandlerSingleton();
 * signalHandler.addReceiver('mysignal', 'MyReceiver', (receivedSignalInfo) => {
 *     console.log('received signal:', receivedSignalInfo.toString());
 * });
 * signalHandler.send('myapp.mysignal', {'the': 'data'}, (sentSignalInfo) => {
 *     console.log('sent signal info:', sentSignalInfo.toString());
 * });
 *
 */
export default class SignalHandlerSingleton {

  constructor() {
    if(!_instance) {
      _instance = this;
      this._signalMap = new Map();
      this._receiverMap = new Map();
    }
    return _instance;
  }

  /**
   * Remove all receivers for all signals.
   *
   * Useful for debugging and tests, but should not be
   * used for production code.
   */
  clearAllReceiversForAllSignals() {
    this._signalMap.clear();
  }

  /**
   * Add a receiver for a specific signal.
   *
   * @param {string} signalName The name of the signal.
   *      Typically something like ``toggleMenu`` or ``myapp.toggleMenu``.
   *
   *      What if we have multiple objects listening for this ``toggleMenu``
   *      signal, and we only want to toggle a specific menu? You need
   *      to ensure the signalName is unique for each menu. We recommend
   *      that you do this by adding ``#<context>``. For example
   *      ``toggleMenu#mainmenu``. This is shown in one of the examples
   *      above.
   * @param {string} receiverName The name of the receiver.
   *      Must be unique for the signal.
   *      We recommend that you use a very explicit name for your signals.
   *      It should normally be the full path to the method or function receiving
   *      the signal. So if you have a class named ``myapp/menu/MenuComponent.js``
   *      that receives a signal to toggle the menu, the receiverName should be
   *      ``myapp.menu.MenuComponent``.
   * @param callback The callback to call when the signal is sent.
   *      The callback is called with a single argument - a
   *      {@link ReceivedSignalInfo} object.
   */
  addReceiver(signalName, receiverName, callback) {
    if(typeof callback === 'undefined') {
      throw new TypeError('The callback argument for addReceiver() is required.');
    }
    if(!this._signalMap.has(signalName)) {
      this._signalMap.set(signalName, new _SignalReceivers(signalName));
    }
    if(this._receiverMap.has(receiverName)) {
      this._receiverMap.get(receiverName).add(signalName);
    } else {
      this._receiverMap.set(receiverName, new Set([signalName]));
    }
    let signal = this._signalMap.get(signalName);
    signal.addReceiver(receiverName, callback)
  }

  /**
   * Remove a receiver for a signal added with {@link SignalHandlerSingleton#addReceiver}.
   *
   * @param {string} signalName The name of the signal.
   * @param {string} receiverName The name of the receiver.
   */
  removeReceiver(signalName, receiverName) {
    if(this._signalMap.has(signalName)) {
      let signal = this._signalMap.get(signalName);
      signal.removeReceiver(receiverName);
      if(signal.receiverCount() == 0) {
        this._signalMap.delete(signalName);
      }
      let receiverSignalSet = this._receiverMap.get(receiverName);
      if(receiverSignalSet != undefined) {
        if(receiverSignalSet.has(signalName)) {
          receiverSignalSet.delete(signalName);
        }
        if(receiverSignalSet.size == 0) {
          this._receiverMap.delete(receiverName);
        }
      }
    }
  }

  /**
   * Remove all signals registered for a receiver.
   *
   * @param {string} receiverName The name of the receiver.
   */
  removeAllSignalsFromReceiver(receiverName) {
    if(this._receiverMap.has(receiverName)) {
      for(let signalName of this._receiverMap.get(receiverName)) {
        this.removeReceiver(signalName, receiverName);
      }
    }
  }

  /**
   * Check if a signal has a specific receiver.
   *
   * @param {string} signalName The name of the signal.
   * @param {string} receiverName The name of the receiver.
   */
  hasReceiver(signalName, receiverName) {
    if(this._signalMap.has(signalName)) {
      let signal = this._signalMap.get(signalName);
      return signal.hasReceiver(receiverName);
    } else {
      return false;
    }
  }


  /**
   * Remove all receivers for a specific signal.
   *
   * @param {string} signalName The name of the signal to remove.
   */
  clearAllReceiversForSignal(signalName) {
    if(this._signalMap.has(signalName)) {
      this._signalMap.delete(signalName);
    }
  }

  /**
   * Send a signal.
   *
   * @param {string} signalName The name of the signal to send.
   * @param data Data to send to the callback of all receivers registered
   *      for the signal.
   * @param infoCallback An optional callback that receives information
   *      about the signal. Useful for debugging what actually received
   *      the signal. The ``infoCallback`` is called with a single
   *      argument - a {@link SentSignalInfo} object.
   */
  send(signalName, data, infoCallback) {
    let info = null;
    if(infoCallback) {
      info = new SentSignalInfo(signalName);
    }
    if(this._signalMap.has(signalName)) {
      let signal = this._signalMap.get(signalName);
      signal.send(data, info);
    }
    if(infoCallback) {
      infoCallback(info);
    }
  }
}