commands/Command.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/**
 * @module bajaux/commands/Command
 */
define([ 'lex!',
  'jquery',
  'Promise',
  'underscore',
  'bajaux/events',
  'bajaux/util/acceleratorUtils',
  'nmodule/js/rc/asyncUtils/asyncUtils' ], function (
  lex,
  $,
  Promise,
  _,
  events,
  acceleratorUtils,
  asyncUtils) {

  'use strict';

  const { isFunction, isString } = _;
  const { doRequire } = asyncUtils;
  const {
    CHANGE_EVENT: COMMAND_CHANGE_EVENT,
    FAIL_EVENT: COMMAND_FAIL_EVENT,
    INVOKE_EVENT: COMMAND_INVOKE_EVENT
  } = events.command;

  const { isAcceleratorMatch, parseAccelerator } = acceleratorUtils;

  // avoid circular dependency
  const requireErrorDetailsWidget = _.once(() => doRequire('bajaux/util/ErrorDetailsWidget'));

  let undoManager;
  let idCounter = 0;

  function toUndoableGetter(undoableParam) {
    return (...args) => {
      return Promise.resolve()
        .then(() => isFunction(undoableParam) ? undoableParam(...args) : undoableParam)
        .then((undoable) => {
          if (!undoable) { return; }

          let { undo, redo, canUndo, canRedo, undoText, redoText } = undoable;
          if (!isFunction(canUndo)) { canUndo = toGetter(true); }
          if (!isFunction(canRedo)) { canRedo = toGetter(true); }
          if (!isFunction(undo)) {
            undo = () => { throw new Error('undo not implemented'); };
            canUndo = () => false;
          }
          if (!isFunction(redo)) {
            redo = () => { throw new Error('redo not implemented'); };
            canRedo = () => false;
          }
          if (isString(undoText)) { undoText = toGetter(undoText); }
          if (isString(redoText)) { redoText = toGetter(redoText); }
          if (!isFunction(undoText)) { undoText = toGetter(''); }
          if (!isFunction(redoText)) { redoText = toGetter(''); }

          return {
            undo: promiseTry(undo),
            redo: promiseTry(redo),
            canUndo: promiseTry(canUndo),
            canRedo: promiseTry(canRedo),
            undoText: promiseTry(undoText),
            redoText: promiseTry(redoText)
          };
        });
    };
  }

  function promiseTry(func) { return () => Promise.resolve().then(func); }

  /**
   * A Command is essentially an asynchronous function with a nicely formatted
   * display name attached.
   *
   * @class
   * @alias module:bajaux/commands/Command
   *
   * @param {String|Object} params An Object Literal or the display name for
   * the Command.
   *
   * @param {String} params.displayName The display name format
   * for the Command. This format will be used in
   * `toDisplayName`. This can also be the first argument, in which
   * case `func` must be the second.
   *
   * @param {Function} [params.func] The function this command will execute. The
   * function can return a promise if it's going to be invoked asynchronously.
   * As of Niagara 4.11 this can be omitted if creating an undoable function.
   *
   * @param {module:bajaux/commands/Command~Undoable|function} [params.undoable] As
   * of Niagara 4.11, any necessary configuration to make this command undoable.
   * This can be either an `undoable` directly, or a function that resolves to one.
   * Please note that an `undoable()` function itself should not do the actual
   * work - the `redo()` function of the returned undoable should do the work.
   * Note that any asynchronous functions may be declared as synchronous when
   * passed to the constructor, for simplicity.
   *
   * @param {String} [params.description] A description of the
   * Command. This format will be used in `toDescription`.
   *
   * @param {Boolean} [params.enabled] The enabled state of the Command.
   * Defaults to `true`.
   *
   * @param {Number} [params.flags] The Command flags. Defaults to
   * `Command.flags.ALL`.
   *
   * @param {String} [params.icon] The Command Icon. This can also be a
   * String encoding an icon will be created from.
   *
   * @param {Function} [invokeFunction] the function to invoke, if using the two-argument
   * constructor
   *
   * @example
   * new Command("baja.Format compatible display name", function () {
   *   alert("I'm a command!");
   * });
   *
   * new Command("Format compatible display name", function () {
   *   return new Promise(function (resolve, reject) {
   *     setTimeout(function () {
   *       wobble.foo();
   *       resolve;
   *     }, 1000);
   *   });
   * });
   *
   * new Command({
   *   displayName: "I'll be converted to a Format: %lexicon(baja:january)%",
   *   description: "I'll be converted to a Format too: %lexicon(baja:true)%",
   *   func: function () {
   *     alert("I'm a command!");
   *   }
   * });
   *
   * new Command({
   *   module: "myModule", // Create a Command that gets its displayName, description                  
   *   lex: "myCommand",   // and icon from a module's lexicon.
   *   func: function () {
   *     alert("I'm a command!");
   *   }
   * });
   *
   * new Command({
   *   undoable: () => {
   *     return promptUser('Are you sure you want to make this change?')
   *       .then((userSaidYes) => {
   *         if (!userSaidYes) { return; }
   *
   *         // redoText/undoText may be strings, getters, or async getters
   *         // canRedo/canUndo may be booleans, getters, or async getters
   *         return {
   *           redo: () => console.log('perform the work of the command'),
   *           undo: () => console.log('revert/back out the work of the command'),
   *           redoText: () => 'Text describing what the command will do',
   *           undoText: () => 'Text describing what undoing the command will do',
   *           canRedo: () => true, // true if doing the work of the command is allowed
   *           canUndo: () => true // true if reverting the work of the command is allowed
   *         }
   *       });
   * });
   */
  var Command = function Command(params, invokeFunction) {
    var that = this,
      loadingPromise;

    params = params && params.constructor === Object ? params
      : { displayName: params, func: invokeFunction };

    let {
      accelerator,
      description,
      displayName,
      enabled = true,
      flags = Command.flags.ALL,
      func,
      icon = "",
      jq,
      undoable
    } = params;

    if (undoable) {
      that.undoable = toUndoableGetter(undoable);
    }

    if (!func) {
      if (that.isToggleCommand()) {
        func = () => that.toggle();
      } else {
        func = (...args) => {
          if (that.isUndoable()) {
            return Promise.resolve(that.undoable(...args))
              .then((undoable) => undoable && undoable.redo());
          }
        };
      }
    }

    that.$accelerator = parseAccelerator(accelerator);
    that.$description = description || "";
    that.$displayName = displayName || "";
    that.$enabled = !!enabled;
    that.$flags = flags;
    that.$func = func;
    that.setIcon(icon || "");
    that.$id = idCounter++;
    that.$jq = jq || null;
    that.$undoable = undoable;
    this.$blankDisplayName = false;
    this.$blankDescription = false;

    // If a module and lexicon is specified then resolve the lexicon
    // and get the value. This asynchronously updates the Command. When
    // the Command is updated, an change event will be fired to update
    // any user interfaces.
    var pending = true;
    if (params.module && params.lex) {

      if (!displayName) {
        this.$displayName = '%lexicon(' + params.module + ':' + params.lex + '.displayName)%';
      }

      if (!description) {
        this.$description = '%lexicon(' + params.module + ':' + params.lex + '.description)%';
      }

      // Always update asynchronously
      loadingPromise = lex.module(params.module)
        .then(function (moduleLex) {
          var res;
          try {

            if (!displayName) {
              that.$blankDisplayName = !moduleLex.get(params.lex + ".displayName");
            }

            if (!description) {
              that.$blankDescription = !moduleLex.get(params.lex + ".description");
            }


            res = moduleLex.get(params.lex + ".icon");
            if (res) {
              that.setIcon(res);
            }

            res = moduleLex.get(params.lex + ".accelerator");
            if (res) {
              that.setAccelerator(res);
            }
          } catch (ignore) {
          } finally {
            pending = false;
          }
        });
    } else {
      pending = false;
      loadingPromise = Promise.resolve();
    }
    loadingPromise.isPending = () => pending;
    that.$loading = loadingPromise;
  };

  /**
   * Namespace containing numbers for comparing flag bits. C&P'ed directly
   * from MgrController in workbench module.
   */
  Command.flags = {};

  /**
   * Match all flags.
   * @type {number}
   */
  Command.flags.ALL = 0xFFFF;

  /**
   * Makes the command be available in the main menu. Not typically used in web profiles, but will
   * signal to Workbench (through bajaux interop) that the command should appear in the Workbench
   * menu.
   * @type {number}
   */
  Command.flags.MENU_BAR = 0x0001;

  /**
   * Makes the command be available in the main toolbar.
   * @type {number}
   */
  Command.flags.TOOL_BAR = 0x0002;

  /**
   * Match no flags.
   * @type {number}
   */
  Command.flags.NONE = 0x0000;

  /**
   * Reserved for use by Command subclasses.
   * @name module:bajaux/commands/Command.USER_DEFINED_1
   * @type {number}
   */
  Object.defineProperty(Command.flags, 'USER_DEFINED_1', { value: 0x0100, enumerable: false });

  /**
   * Reserved for use by Command subclasses.
   * @name module:bajaux/commands/Command.USER_DEFINED_2
   * @type {number}
   */
  Object.defineProperty(Command.flags, 'USER_DEFINED_2', { value: 0x0200, enumerable: false });

  /**
   * Reserved for use by Command subclasses.
   * @name module:bajaux/commands/Command.USER_DEFINED_3
   * @type {number}
   */
  Object.defineProperty(Command.flags, 'USER_DEFINED_3', { value: 0x0400, enumerable: false });

  /**
   * Reserved for use by Command subclasses.
   * @name module:bajaux/commands/Command.USER_DEFINED_4
   * @type {number}
   */
  Object.defineProperty(Command.flags, 'USER_DEFINED_4', { value: 0x0800, enumerable: false });

  /**
   * Return true if the Command is still loading.
   *
   * @returns {Boolean} true if still loading.
   */
  Command.prototype.isLoading = function isLoading() {
    return this.$loading.isPending();
  };

  /**
   * Return the loading promise for the Command.
   *
   * The returned promise will be resolved once the Command
   * has finished loading.
   *
   * @returns {Promise} The promise used for loading a Command.
   */
  Command.prototype.loading = function loading() {
    return this.$loading;
  };

  /**
   * Return the format display name of the command.
   *
   * @returns {String}
   */
  Command.prototype.getDisplayNameFormat = function getDisplayNameFormat() {
    return this.$displayName;
  };

  /**
   * Set the display name format of the command. Triggers a
   * `bajaux:changecommand` event.
   *
   * @param {String} displayName display name - supports baja Format syntax
   */
  Command.prototype.setDisplayNameFormat = function setDisplayNameFormat(displayName) {
    this.$displayName = displayName;
    this.$blankDisplayName = false;
    this.trigger(COMMAND_CHANGE_EVENT);
  };

  /**
   * Access the Command's display name.
   *
   * In order to access the display name, a promise will be returned
   * that will be resolved once the command has been loaded and
   * the display name has been found.
   *
   * @returns {Promise} Promise to be resolved with the display name
   */
  Command.prototype.toDisplayName = function toDisplayName() {
    return this.$loading
      .then(() => {
        if (this.$blankDisplayName) {
          return '';
        }
        return lex.format(this.$displayName);
      })
      .then((formattedDisplayName) => {
        return (this.$formattedDisplayName = formattedDisplayName);
      });
  };

  /**
   * @returns {string}
   * @since Niagara 4.15
   */
  Command.prototype.toString = function () {
    return `Command[${ this.$formattedDisplayName || this.$displayName }]`;
  };

  /**
   * Get the unformatted description of the command.
   *
   * @returns {String}
   */
  Command.prototype.getDescriptionFormat = function getDescriptionFormat() {
    return this.$description;
  };

  /**
   * Set the description format of the command. Triggers a
   * `bajaux:changecommand` event.
   *
   * @param {String} description the command description - supports baja Format
   * syntax
   */
  Command.prototype.setDescriptionFormat = function setDescriptionFormat(description) {
    this.$description = description;
    this.$blankDescription = false;
    this.trigger(COMMAND_CHANGE_EVENT);
  };

  /**
   * Access the Command's description.
   *
   * In order to access the description, a promise will be returned
   * that will be resolved once the command has been loaded and
   * the description has been found.
   *
   * @returns {Promise} Promise to be resolved with the description
   */
  Command.prototype.toDescription = function toDescription() {
    var that = this;

    return that.$loading
      .then(function () {
        if (that.$blankDescription) {
          return '';
        }
        return lex.format(that.$description);
      });
  };


  /**
   * Return the Command's icon URI
   *
   * @returns {String}
   */
  Command.prototype.getIcon = function getIcon() {
    return this.$icon;
  };

  /**
   * Sets the icon for this Command. Triggers a `bajaux:changecommand` event.
   *
   * @param {String} icon The Command's icon (either a URI or a module:// ORD string)
   */
  Command.prototype.setIcon = function setIcon(icon) {
    this.$icon = icon.replace(/^module:\/\//, "/module/");
    this.trigger(COMMAND_CHANGE_EVENT);
  };

  /**
   * Gets this command's enabled status.
   *
   * @returns {Boolean}
   */
  Command.prototype.isEnabled = function isEnabled() {
    return this.$enabled;
  };

  /**
   * Sets this command's enabled status. Triggers a
   * `bajaux:changecommand` event.
   *
   * @param {Boolean} enabled
   */
  Command.prototype.setEnabled = function setEnabled(enabled) {
    this.$enabled = !!enabled;
    this.trigger(COMMAND_CHANGE_EVENT);
  };

  /**
   * Get this command's flags.
   *
   * @returns {Number}
   */
  Command.prototype.getFlags = function getFlags() {
    return this.$flags;
  };

  /**
   * Set this command's flags.
   *
   * @param {Number} flags
   */
  Command.prototype.setFlags = function setFlags(flags) {
    this.$flags = flags;
    this.trigger(COMMAND_CHANGE_EVENT);
  };

  /**
   * Check to see if this command's flags match any of the bits of the
   * input flags.
   *
   * @param {Number} flags The flags to check against
   * @returns {Boolean}
   */
  Command.prototype.hasFlags = function hasFlags(flags) {
    return !!(this.$flags & flags);
  };

  /**
   * Return the raw function associated with this command.
   *
   * @returns {Function}
   */
  Command.prototype.getFunction = function getFunction() {
    return this.$func;
  };

  /**
   * Set the Command's function handler.
   *
   * @param {Function} func The new function handler for the command.
   */
  Command.prototype.setFunction = function setFunction(func) {
    this.$func = func;
    this.trigger(COMMAND_CHANGE_EVENT);
  };

  /**
   * Invoke the Command. Triggers a `bajaux:invokecommand` or
   * `bajaux:failcommand` event, as appropriate.
   *
   * Arguments can be passed into `invoke()` that will be passed into the
   * function's Command Handler.
   *
   * @returns {Promise} A promise object that will be resolved (or rejected)
   * once the Command's function handler has finished invoking.
   */
  Command.prototype.invoke = function invoke() {
    var that = this,
      args = arguments;

    if (undoManager && this.isUndoable()) {
      return undoManager.invoke(this, arguments);
    }

    // eslint-disable-next-line promise/avoid-new
    return Promise.try(function () {
      return that.$func.apply(that, args);
    })
      .then(function (result) {
        var args = Array.prototype.slice.call(arguments);
        that.trigger.apply(that, [ COMMAND_INVOKE_EVENT ].concat(args));
        return result;
      }, function (err) {
        var args = Array.prototype.slice.call(arguments);
        that.trigger.apply(that, [ COMMAND_FAIL_EVENT ].concat(args));
        throw err;
      });
  };

  /**
   * If your Command optionally implements this function, then CommandButton
   * will call it on click instead of simply calling `invoke`. Use this in case
   * your Command needs to respond differently based on where on the screen the
   * user is pointing.
   *
   * @function module:bajaux/commands/Command#invokeFromEvent
   * @param {JQuery.Event} e the DOM event triggered by the user's request to
   * invoke this function
   * @returns {Promise|*}
   * @since Niagara 4.11
   */

  /**
   * Always returns true.
   */
  Command.prototype.isCommand = function isCommand() {
    return true;
  };

  /**
   * Always returns false.
   */
  Command.prototype.isToggleCommand = function isToggleCommand() {
    return false;
  };

  /**
   * @returns {boolean} true if this command is undoable
   * @since Niagara 4.11
   */
  Command.prototype.isUndoable = function () {
    return typeof this.undoable === 'function';
  };

  /**
   * @param {module:bajaux/commands/Command|module:bajaux/commands/Command~Undoable} undoable
   * @returns {boolean} true if the given parameter is an undoable Command, or
   * is an Undoable object
   * @since Niagara 4.11
   */
  Command.isUndoable = function (undoable) {
    if (undoable instanceof Command) {
      return undoable.isUndoable();
    }

    return !!(undoable && typeof undoable === 'object' && isFunction(undoable.undo) && isFunction(undoable.redo));
  };

  /**
   * Return a unique numerical id for the Command.
   *
   * This is id unique to every Command object created.
   */
  Command.prototype.getId = function getId() {
    return this.$id;
  };

  /**
   * Return the accelerator for the Command or null if
   * nothing is defined.
   *
   * @see module:bajaux/commands/Command#setAccelerator
   *
   * @returns {Object} The accelerator or null if nothing is defined.
   */
  Command.prototype.getAccelerator = function getAccelerator() {
    return this.$accelerator;
  };

  /**
   * Check to see if a keyboard event matches the accelerator.
   * The keycode and the modifiers must match for this to return true.
   * The command must also be enabled for this accelerator to match.
   *
   * @param {Event} event
   * @returns {boolean}
   * @since Niagara 4.15
   */
  Command.prototype.isAcceleratorMatch = function (event) {
    return isAcceleratorMatch(this, event);
  };

  /**
   * Set the accelerator information for the Command.
   *
   * @param {Object|String|Number|null|undefined} acc The accelerator keyboard information. This can
   * be a keyCode number, a character (i.e. 'a') or an Object that contains the accelerator information.
   * If no accelerator should be used the null/undefined should be specified.
   * @param {String|Number} acc.keyCode The key code of the accelerator. This can be a character
   * code number or a character (i.e. 'a').
   * @param {Boolean} [acc.ctrl] `true` if the control key needs to be pressed.
   * @param {Boolean} [acc.shift] `true` if the shift key needs to be pressed.
   * @param {Boolean} [acc.alt] `true` if the alt key needs to be pressed.
   * @param {Boolean} [acc.meta] `true` if a meta key needs to be pressed.
   *
   * @see module:bajaux/commands/Command#getAccelerator
   */
  Command.prototype.setAccelerator = function setAccelerator(acc) {
    this.$accelerator = parseAccelerator(acc);

    // Trigger a change event
    this.trigger(COMMAND_CHANGE_EVENT);
  };

  /**
   * Visit this Command with the specified function.
   *
   * @param {Function} func Will be invoked with this
   * Command passed in as an argument.
   */
  Command.prototype.visit = function visit(func) {
    return func(this);
  };

  /**
   * Triggers an event from this Command.
   *
   * @param {String} name
   */
  Command.prototype.trigger = function trigger(name) {
    var that = this,
      passedArgs = Array.prototype.slice.call(arguments, 1),
      args = [ that ].concat(passedArgs);

    if (that.$jq) {
      that.$jq.trigger(name, args);
    }

    if (that.$eh) {
      that.$eh.trigger(name, args);
    }
  };

  /**
   * If a jQuery DOM argument is specified, this will set the DOM.
   * If not specified then no DOM will be set.
   * This method will always return the jQuery DOM associated with this Command.
   *
   * @param {JQuery} [jqDom] If specified, this will set the jQuery DOM.
   * @returns {JQuery} A jQuery DOM object for firing events on.
   */
  Command.prototype.jq = function jq(jqDom) {
    if (jqDom !== undefined) {
      this.$jq = jqDom;
    }
    return this.$jq || null;
  };

  /**
   * Register a function callback handler for the specified event.
   *
   * @param  {String} event The event id to register the function for.
   * @param  {Function} handler The event handler to be called when the event is fired.
   */
  Command.prototype.on = function (event, handler) {
    var that = this;
    that.$eh = that.$eh || $("<div></div>");
    that.$eh.on.apply(that.$eh, arguments);
  };

  /**
   * Unregister a function callback handler for the specified event.
   *
   * @param  {String} [event] The name of the event to unregister.
   * If name isn't specified, all events for the Command will be unregistered.
   * @param  {Function} [handler] The function to unregister. If
   * not specified, all handlers for the event will be unregistered.
   */
  Command.prototype.off = function (event, handler) {
    var that = this;
    if (that.$eh) {
      that.$eh.off.apply(that.$eh, arguments);
    }
  };

  /**
   * Attempt to merge this command with another command, and return a new
   * Command that does both tasks. If the two commands are mutually
   * incompatible, return a falsy value.
   *
   * @param {module:bajaux/commands/Command} cmd
   * @returns {module:bajaux/commands/Command}
   *
   * @example
   * <caption>
   *   Here is an example to show the basic concept. Commands that simply
   *   add two numbers together can easily be merged together thanks to the
   *   associative property.
   * </caption>
   *
   * var AddCommand = function AddCommand(inc) {
   *   this.$inc = inc;
   *   Command.call(this, {
   *     displayName: 'Add ' + inc + ' to the given number',
   *     func: function (num) { return num + inc; }
   *   });
   * };
   * AddCommand.prototype = Object.create(Command.prototype);
   *
   * AddCommand.prototype.merge = function (cmd) {
   *   if (cmd instanceof AddCommand) {
   *     return new AddCommand(this.$inc + cmd.$inc);
   *   }
   * };
   *
   * var addOneCommand = new AddCommand(1),
   *     addFiveCommand = new AddCommand(5),
   *     addSixCommand = addOneCommand.merge(addFiveCommand);
   * addSixCommand.invoke(10)
   *   .then(function (result) {
   *     console.log('is 16? ', result === 16);
   *   });
   */
  Command.prototype.merge = function (cmd) {
    return null;
  };

  function toGetter(o) { return () => o; }


  /**
   * Represents a unit of undoable/redoable work as provided by a Command.
   *
   * @interface module:bajaux/commands/Command~Undoable
   */

  /**
   * Perform the undo work.
   *
   * @function module:bajaux/commands/Command~Undoable#undo
   * @returns {Promise}
   */

  /**
   * Perform the redo work.
   *
   * @function module:bajaux/commands/Command~Undoable#undo
   * @returns {Promise}
   */

  /**
   * Resolves true if it is possible to do the undo work at this time.
   *
   * @function module:bajaux/commands/Command~Undoable#canUndo
   * @returns {Promise.<boolean>}
   */

  /**
   * Resolves true if it is possible to do the redo work at this time.
   *
   * @function module:bajaux/commands/Command~Undoable#canRedo
   * @returns {Promise.<boolean>}
   */

  /**
   * Resolve some text that describes the undo work about to be done.
   *
   * @function module:bajaux/commands/Command~Undoable#undoText
   * @returns {Promise.<string>}
   */

  /**
   * Resolve some text that describes the redo work about to be done.
   *
   * @function module:bajaux/commands/Command~Undoable#redoText
   * @returns {Promise.<string>}
   */

  /**
   * @private
   */
  Command.$installGlobalUndoManager = (mgr) => { undoManager = mgr; };

  /**
   * @private
   */
  Command.$getGlobalUndoManager = () => undoManager;

  /**
   * Provides a default way of notifying the user about a Command invocation
   * failure. Shows a dialog with details about the error.
   *
   * You might override this at runtime with your own error dialog handler.
   *
   * @param {Error|*} err
   * @param {object} [params]
   * @param {string} [params.messageSummary] any additional information you
   * would like to include in the error dialog
   * @returns {Promise}
   * @since Niagara 4.12
   */
  Command.prototype.defaultNotifyUser = function (err, params) {
    return requireErrorDetailsWidget()
      .then((ErrorDetailsWidget) => {
        return ErrorDetailsWidget.dialog(err, Object.assign({ command: this }, params));
      });
  };


  return Command;
});