util/CommandButton.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Logan Byam
 */

/* eslint-env browser */

/**
 * @module bajaux/util/CommandButton
 */
define([ 'jquery',
        'Promise',
        'underscore',
        'bajaux/events',
        'bajaux/Widget',
        'bajaux/commands/Command',
        'bajaux/commands/ToggleCommand',
        'bajaux/icon/iconUtils',
        'bajaux/util/acceleratorUtils',
        'nmodule/js/rc/log/Log' ], function (
         $,
         Promise,
         _,
         events,
         Widget,
         Command,
         ToggleCommand,
         iconUtils,
         acceleratorUtils,
         Log) {

  'use strict';

  const { contains, debounce, isFunction } = _;
  const COMMAND_CHANGE_EVENT = events.command.CHANGE_EVENT;
  const { DISABLE_EVENT, ENABLE_EVENT } = events;
  const { toHtml } = iconUtils;
  const logError = Log.logMessage.bind(Log,
    'nmodule.bajaux.rc.util.CommandButton', Log.Level.SEVERE);

  const FPS_60 = 1000 / 60;

  let cmdButtonsWaiting = [];

  const updateAll = debounce(function () {
    cmdButtonsWaiting.forEach(function (button) {
      button.$updateDom().catch(logError);
    });
    cmdButtonsWaiting = [];
  }, FPS_60);

  /**
   * When enabling/disabling multiple buttons at once, don't throttle each call
   * separately as they'll resolve at different times and give you multiple
   * repaints. Throttle all commands at once to minimize repaints.
   * @param {module:bajaux/util/CommandButton} cmdButton
   */
  function updateCommandButtonDom(cmdButton) {
    if (!contains(cmdButtonsWaiting, cmdButton)) {
      cmdButtonsWaiting.push(cmdButton);
      updateAll();
    }
  }

  /**
   * A widget for displaying and invoking a Command.
   *
   * @class
   * @extends module:bajaux/Widget
   * @alias module:bajaux/util/CommandButton
   */
  const CommandButton = function CommandButton() {
    Widget.apply(this, arguments);
  };
  CommandButton.prototype = Object.create(Widget.prototype);
  CommandButton.prototype.constructor = CommandButton;

  /**
   * Get the element that will hold the icon `img`s.
   *
   * @private
   * @returns {JQuery}
   */
  CommandButton.prototype.$getIconElement = function () {
    return this.jq().children('.display').children('.icon');
  };

  /**
   * Get the element that will hold the command's display name.
   *
   * @private
   * @returns {JQuery}
   */
  CommandButton.prototype.$getDisplayNameElement = function () {
    return this.jq().children('.display').children('.displayName');
  };

  /**
   * Arms a click handler that will invoke the loaded Command. Adds a
   * `CommandButton` CSS class.
   *
   * Technically, this widget can be initialized in any DOM element, but makes
   * the most sense in a `button` element.
   *
   * @param {JQuery} dom
   */
  CommandButton.prototype.doInitialize = function (dom) {
    dom.on('click', (e) => {
      const cmd = this.value();
      Promise.resolve(this.doInvoke(cmd, e)).catch((err) => {
        logError(err);
        return cmd.defaultNotifyUser(err);
      });
    });

    const el = dom[0];

    if (!el) { return; }

    el.classList.add('CommandButton');

    const displaySpan = document.createElement('span');
    displaySpan.className = 'display';
    const iconSpan = document.createElement('span');
    iconSpan.className = 'icon';
    const displayNameSpan = document.createElement('span');
    displayNameSpan.className = 'displayName';

    displaySpan.appendChild(iconSpan);
    displaySpan.appendChild(displayNameSpan);
    el.append(displaySpan);
  };

  /**
   * Override point to allow customized command invocation from a DOM event. By
   * default, will check that the widget and command are both enabled and then
   * invoke it.
   *
   * @param {module:bajaux/commands/Command} cmd
   * @param {JQuery.TriggeredEvent} e
   * @returns {Promise|undefined}
   * @since Niagara 4.11
   */
  CommandButton.prototype.doInvoke = function (cmd, e) {
    if (!cmd.isEnabled() || !this.isEnabled()) { return; }

    if (isFunction(cmd.invokeFromEvent)) {
      return cmd.invokeFromEvent(e);
    }

    return cmd.invoke();
  };

  /**
   * Updates the display name, description, icon, and enabled status of the
   * button widget. Should be called once on load and whenever the loaded
   * Command changes.
   *
   * @private
   * @returns {Promise} promise to be resolved when the DOM has finished
   * updating
   */
  CommandButton.prototype.$updateDom = function () {
    const that = this,
        jq = that.jq(),
        cmd = that.value();

    if (!jq || !jq.length) {
      return Promise.resolve();
    }

    return Promise.all([ cmd.toDisplayName(), cmd.toDescription() ])
      .then(([ displayName, description ]) => {
        if (!that.isInitialized()) { return; }

        const iconElement = that.$getIconElement(),
            displayNameElement = that.$getDisplayNameElement(),
            isToggled = cmd.isToggleCommand() && cmd.isSelected(),
            icon = cmd.getIcon(),
            canInvoke = that.canInvokeCommand();

        let title = description;

        if (!title || (displayName.startsWith(description) && description.length < displayName.length)) {
          title = displayName;
        } else if (displayName && !(description.startsWith(displayName) || displayName.startsWith(description))  && displayNameElement.css('display') === 'none') {
          title = displayName + "\n" + description;
        }

        if (acceleratorUtils.isProfileCommandGroup(jq) && cmd.getAccelerator()) {
          title += "\n(" + cmd.getAccelerator() + ")";
        }

        jq.attr('title', title);
        displayNameElement.text(displayName);
        jq.toggleClass('ux-toggled', isToggled);
        that.$lookEnabled(canInvoke);
        that.trigger(canInvoke ? ENABLE_EVENT : DISABLE_EVENT);
        
        return icon && toHtml(icon).then((html) => iconElement.html(html));
      });
  };

  /**
   * Loads a Command. Binds an event handler to update the DOM whenever the
   * Command's properties are changed.
   *
   * @param {module:bajaux/commands/Command} cmd
   * @returns {Promise} promise to be resolved when the `Command` is
   * loaded, or rejected if no `Command` given
   */
  CommandButton.prototype.doLoad = function (cmd) {
    if (!(cmd instanceof Command)) {
      throw new Error('Command required');
    }

    const that = this,
        changeHandler = that.$changeHandler;

    cmd.loading().then(function () {
      if (changeHandler) {
        cmd.off(COMMAND_CHANGE_EVENT, changeHandler);
      }
      cmd.on(COMMAND_CHANGE_EVENT, that.$changeHandler = function () {
        updateCommandButtonDom(that);
      });
    })
      .catch(logError);

    return that.$updateDom();
  };

  /**
   * Removes the click handler and CSS class from `doInitialize`.
   */
  CommandButton.prototype.doDestroy = function () {
    this.jq()
      .removeClass('CommandButton')
      .removeClass('ux-disabled');

    var cmd = this.value();

    if (cmd) {
      cmd.off(COMMAND_CHANGE_EVENT, this.$changeHandler);
    }
  };

  /**
   * Updates the DOM to look enabled/disabled depending on whether this widget
   * is enabled and the loaded command can be invoked.
   */
  CommandButton.prototype.doEnabled = function () {
    this.$lookEnabled(this.canInvokeCommand());
  };

  /**
   * @returns {boolean} true if both the CommandButton and the loaded Command
   * are enabled
   * @since Niagara 4.13
   */
  CommandButton.prototype.canInvokeCommand = function () {
    const cmd = this.value();
    return !!cmd && this.isEnabled() && cmd.isEnabled();
  };

  /**
   * Updates the DOM to reflect whether the loaded Command can be invoked
   * @private
   * @param {boolean} enabled
   */
  CommandButton.prototype.$lookEnabled = function (enabled) {
    this.jq().prop('disabled', !enabled).toggleClass('bajaux-disabled ux-disabled', !enabled);
  };

  return CommandButton;
});