DriverMgr.js

/**
 * @copyright 2016 Tridium, Inc. All Rights Reserved.
 */

/* jshint browser : true */

/**
 * @module nmodule/driver/rc/wb/mgr/DriverMgr
 */
define([ 'baja!',
        'lex!driver,webEditors',
        'Promise',
        'jquery',
        'underscore',
        'nmodule/js/rc/tinyevents/tinyevents',
        'nmodule/webEditors/rc/fe/baja/util/DepthSubscriber',
        'nmodule/webEditors/rc/fe/baja/util/typeUtils',
        'nmodule/webEditors/rc/wb/mgr/componentStatusUtils',
        'nmodule/webEditors/rc/wb/mgr/Manager',
        'nmodule/webEditors/rc/wb/mgr/MgrFolderSupport',
        'nmodule/webEditors/rc/wb/mgr/mgrUtils',
        'nmodule/webEditors/rc/wb/mgr/commands/AllDescendantsCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/EditCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/NewCommand',
        'nmodule/webEditors/rc/wb/mgr/commands/NewFolderCommand',
        'nmodule/webEditors/rc/wb/mgr/model/columns/NameMgrColumn',
        'nmodule/webEditors/rc/wb/mgr/model/columns/PathMgrColumn',
        'nmodule/webEditors/rc/wb/mixin/ComponentHyperlinkColumnMixin',
        'nmodule/webEditors/rc/wb/mixin/mixinUtils',
        'nmodule/webEditors/rc/wb/mixin/ContextMenuSupport',
        'bajaux/commands/CommandGroup',
        'bajaux/mixin/subscriberMixIn',
        'css!nmodule/driver/rc/driver' ], function (
        baja,
        lexs,
        Promise,
        $,
        _,
        tinyevents,
        DepthSubscriber,
        typeUtils,
        componentStatusUtils,
        Manager,
        addFolderSupport,
        mgrUtils,
        AllDescendantsCommand,
        EditCommand,
        NewCommand,
        NewFolderCommand,
        NameMgrColumn,
        PathMgrColumn,
        addComponentHyperlinkSupport,
        mixinUtils,
        addContextMenuSupport,
        CommandGroup,
        subscribable) {

  'use strict';

  var FOLDER_TYPE = baja.lt('baja:Folder'),
      getMainTableSelection = mgrUtils.getMainTableSelection,
      getMainTableSelectedSubjects = mgrUtils.getMainTableSelectedSubjects,
      findCommand = mgrUtils.findCommand,
      hasMixin = mixinUtils.hasMixin,
      addStatusCss = componentStatusUtils.addComponentStatusCss;


  function tryMixinHyperlinkSupport(col) {

    // For now, the manager will just automatically mix in hyperlink support on the name
    // and/or path columns. Concrete driver managers can apply the mixin to other columns
    // if they wish.

    if (col instanceof NameMgrColumn || col instanceof PathMgrColumn) {
      if (!hasMixin(col, 'HYPERLINK_COLUMN')) {
        addComponentHyperlinkSupport(col, { cssClass: 'driver-mgr-link' });
      }
    }
  }

  function isEditable(mgr, subject) {
    return _.any(mgr.$editableTypes, function (type) {
      return subject.getType().is(type);
    });
  }

  function isEveryItemEditable(mgr, subjects) {
    return _.every(subjects, function (subject) {
      return isEditable(mgr, subject);
    });
  }

  function hasSingleFolderSubject(subjects) {
    return (subjects.length === 1) && (baja.hasType(subjects[0], FOLDER_TYPE));
  }

  function hasFlattenedFolderComponentSource(source) {
    return (hasMixin(source, 'FOLDER_SOURCE') && (source.isFlattened()));
  }

  /**
   * Function to find a component instance providing the subject for a table row
   * from either the component itself or one of its descendants. This will, for instance,
   * find the ControlPoint that is the parent of a ProxyExt that we have received a change
   * event for.
   *
   * @inner
   *
   * @param {module:nmodule/webEditors/rc/wb/table/model/ComponentTableModel} model the main
   * table model for the manager view.
   * @param {baja.Component} comp the component that has received a changed event.
   *
   * @returns {baja.Component} the row subject component that is the ancestor of
   * the changed descendant, or null if the ancestor could not be determined.
   */
  function getAncestorInTable(model, comp) {
    var subjects = _.invoke(model.getRows(), 'getSubject');

    while (comp && typeUtils.isComplex(comp)) {
      if (_.contains(subjects, comp)) {
        return comp;
      } else {
        comp = comp.getParent();
      }
    }

    return null;
  }

  /**
   * Checks whether an event from the given component should be propagated to
   * emit a 'rowsChanged' event on the model. This is intended for changes that
   * we have received from the depth subscriber. This function will test whether
   * the parent of the changed property is equal the the subject of one of the
   * rows in the table. If so, the component source will handle raising the model's
   * 'rowsChanged' event itself. If it is from a property on a descendant component of
   * a row's subject (e.g. a proxy ext), then we will ask the source to emit a changed
   * event.
   *
   * @param {module:nmodule/driver/rc/wb/mgr/DriverMgr} mgr
   * @param {baja.Component} comp - the changed component
   */
  function tryEmitRowChangeEvent(mgr, comp) {
    var model = mgr.getModel(),
        subject = getAncestorInTable(model, comp),
        source;

    if ((subject) && (subject !== comp)) {
      source = model.getComponentSource();
      source.handleChanged(subject);
    }
  }

  /**
   * API Status: **Development**
   * 
   * DriverMgr constructor. Contains functionality for working with components
   * within a driver network.
   * 
   * There is usually no reason to extend this directly; extend `DeviceMgr` or
   * `PointMgr` instead.
   *
   * @class
   * @alias module:nmodule/driver/rc/wb/mgr/DriverMgr
   * @extends module:nmodule/webEditors/rc/wb/mgr/Manager
   *
   * @param {String} [params.keyName] the key name used for lexicon entries for this view.
   * @param {String} [params.moduleName] the module name used for lexicon entries for this view.
   * @param {Number} [params.subscriptionDepth] the depth to subscribe the component tree.
   * @param {String|Type} [params.folderType] optional parameter indicating the folder type
   * used for the manager view. This will be used by the NewFolder command.
   *
   * @see module:nmodule/driver/rc/wb/mgr/DeviceMgr
   * @see module:nmodule/driver/rc/wb/mgr/PointMgr
   */
  var DriverMgr = function DriverMgr(params) {
    var that = this;

    if (!params.moduleName || !params.keyName) {
      throw new Error('\'moduleName\' or \'keyName\' parameters not specified.');
    }

    Manager.call(that, params);

    tinyevents(that);
    addContextMenuSupport(that);

    that.$editableTypes = params.editableTypes || [];
    that.$getComponentStatus =
      params.getComponentStatus || _.constant(baja.Status.ok);
    
    that.$subscriptionDepth = params.subscriptionDepth || 1;
    that.$subscriber = new DepthSubscriber(that.$subscriptionDepth);

    subscribable(that);

    if (params.folderType) {
      addFolderSupport(that, { folderType: params.folderType });
    }
  };

  DriverMgr.prototype = Object.create(Manager.prototype);
  DriverMgr.prototype.constructor = DriverMgr;

  /**
   * Manager initialization. In addition to the base manager initialization, this will configure
   * double click handling on the table rows and ensure that the basic commands are in the
   * appropriate default state.
   *
   * @param {JQuery} dom
   * @returns {*}
   */
  DriverMgr.prototype.doInitialize = function (dom) {
    var that = this,
        newCmd,
        editCmd;

    CommandGroup.prototype.add.apply(that.getCommandGroup(), that.makeCommands());

    newCmd = that.$getNewCommand();
    editCmd = that.$getEditCommand();

    if (newCmd)  { newCmd.setEnabled(true); }
    if (editCmd) { editCmd.setEnabled(false); }

    //
    // Configure double click handling for table rows. In the handler
    // we'll look at the row's subject; if the row being clicked is of
    // an editable type, then the edit command action can be invoked.
    // Otherwise, if the point is a folder, the double click action will
    // be to do a hyperlink to that folder's ord.
    //

    dom.on('dblclick', '.mainTable tr', function (e) {
      var subjects = that.getSubject($(e.target));
      if (subjects && subjects.length) {
        if (editCmd && isEveryItemEditable(that, subjects)) {
          editCmd.invoke().catch(baja.error);
        } else if (hasSingleFolderSubject(subjects)) {
          $(e.target).closest('tr').find('td.driver-mgr-link > a').first().trigger('click');
        }
      }
      return false;
    });

    return Manager.prototype.doInitialize.apply(that, arguments);
  };

  /**
   * Show context menus upon right clicks of a table row.
   *
   * @private
   * @override
   * @see module:nmodule/webEditors/rc/wb/menu/CommandGroupContextMenu
   * @returns {string}
   */
  DriverMgr.prototype.getContextMenuSelector = function () {
    return 'tr.driver-mgr-row';
  };

  /**
   * Get the subject via the manager's main table.
   *
   * @param elem
   * @returns {*}
   */
  DriverMgr.prototype.getSubject = function (elem) {
    return this.getMainTable().getSubject(elem);
  };

  /**
   * Set the selection change event handler on the main table.
   * This will enable or disable the edit command based on what
   * is currently selected in the main table. We typically wont
   * enable the edit command if a folder is included in the
   * current selection.
   *
   * @param mgr
   */
  function initializeMainTableSelection(mgr) {
    var selection = getMainTableSelection(mgr);

    selection.on('changed', function () {
      var editCmd = mgr.$getEditCommand(),
          selected = getMainTableSelectedSubjects(mgr);

      if (editCmd) {
        editCmd.setEnabled(isEveryItemEditable(mgr, selected));
      }
    });
  }

  /**
   * Load the widget from the component. This will hook up the event handlers to the
   * depth subscriber used by this type.
   *
   * @param {baja.Component} comp
   * @returns {Promise}
   */
  DriverMgr.prototype.doLoad = function (comp) {
    var that = this,
        subscriber = that.getSubscriber(),
        model = this.getModel(),
        source = model.getComponentSource();

    if (subscriber instanceof DepthSubscriber && hasMixin(source, 'FOLDER_SOURCE')) {
      source.setComponentDepth(subscriber.getDepth());
    }

    _.each(model.getColumns(), tryMixinHyperlinkSupport);

    // Set up handlers on the subscriber for this manager. The Attachable on the
    // component source will cause events to be emitted for things at the first
    // level under a subject. With the depth subscription supported by this
    // type, the 'changed' handler will cause events to be emitted only for changes
    // at levels deeper than a row subject's direct slots, for example a property
    // on a point's proxy extension, leaving the other events to be emitted by
    // the component source itself.

    that.$descendantChangedHandler = function (prop) {
      var comp = this;
      that.componentChanged(comp, prop);
    };

    that.$descendantRenamedHandler = function (prop) {
      var comp = this;
      that.componentRenamed(comp, prop);
    };

    that.$descendantAddedHandler = function (prop) {
      var comp = this.get(prop);
      that.componentAdded(this, comp);
    };

    that.$descendantRemovedHandler = function (prop, value) {
      that.componentRemoved(value);
    };

    subscriber.attach('changed', that.$descendantChangedHandler);
    subscriber.attach('renamed', that.$descendantRenamedHandler);
    subscriber.attach('added',   that.$descendantAddedHandler);
    subscriber.attach('removed', that.$descendantRemovedHandler);

    return Manager.prototype.doLoad.call(that, comp)
      .then(function () {
        initializeMainTableSelection(that);
      });
  };

  /**
   * Function called when a property of one of the row's subjects or descendants
   * changes. This is used to update the table when, for example, a property
   * on a point's proxy extension is changed.
   *
   * @param {baja.Component} comp The component notifying the changed property.
   */
  DriverMgr.prototype.componentChanged = function (comp, prop) {
    tryEmitRowChangeEvent(this, comp);
  };

  /**
   * Function called when the depth subscriber notifies a renamed component. This will
   * try to emit a 'changed' event on the component source.
   *
   * @param comp
   */
  DriverMgr.prototype.componentRenamed = function (comp) {
    tryEmitRowChangeEvent(this, comp);
  };

  /**
   * Function called when a new component is added. If the
   * component is a folder and the all descendants command is selected,
   * we want to subscribe to that folder to the correct depth.
   *
   * @param {baja.Component} parent
   * @param {baja.Component} child
   */
  DriverMgr.prototype.componentAdded = function (parent, child) {
    var model = this.getModel(),
        source = model.getComponentSource();

    if (child.getType().is(FOLDER_TYPE) && hasFlattenedFolderComponentSource(source)) {
      this.resubscribeForNewFolderDepth()
        .catch(baja.error);
    }
  };

  /**
   * Function called when a subscribed component is removed. If the
   * component was a folder and the all descendants command is selected,
   * we want to unsubscribe to that folder.
   *
   * @param {baja.Component} value
   */
  DriverMgr.prototype.componentRemoved = function (value) {
    var model = this.getModel(),
        source = model.getComponentSource();

    if (value.getType().is(FOLDER_TYPE) && hasFlattenedFolderComponentSource(source)) {
      this.resubscribeForNewFolderDepth()
        .catch(baja.error);
    }
  };

  /**
   * Destroy the widget. This will clean up the event handler we have attached
   * for listening to descendant changes.
   *
   * @returns {*}
   */
  DriverMgr.prototype.doDestroy = function () {
    var that = this,
        subscriber = that.getSubscriber();

    that.getCommandGroup().removeAll();

    if (that.$descendantChangedHandler) {
      subscriber.detach('changed', that.$descendantChangedHandler);
      delete that.$descendantChangedHandler;
    }

    if (that.$descendantRenamedHandler) {
      subscriber.detach('renamed', that.$descendantRenamedHandler);
      delete that.$descendantRenamedHandler;
    }

    if (that.$descendantAddedHandler) {
      subscriber.detach('added', that.$descendantAddedHandler);
      delete that.$descendantAddedHandler;
    }

    if (that.$descendantRemovedHandler) {
      subscriber.detach('removed', that.$descendantRemovedHandler);
      delete that.$descendantRemovedHandler;
    }

    return Manager.prototype.doDestroy.apply(that, arguments);
  };

  /**
   * Return the default set of `Command` instances for a device manager. The
   * basic set are commands for creating a new folder (if a folder type was
   * specified in the constructor's parameters), creating a new point type
   * and editing an existing point type.
   *
   * Concrete point manager types may override this function to append extra
   * commands and/or remove the default ones.
   *
   * @returns {Array.<module:bajaux/commands/Command>}
   */
  DriverMgr.prototype.makeCommands = function () {
    var that = this,
        cmds = [],
        newCommand = new NewCommand(that),
        editCommand = new EditCommand(that);

    editCommand.setAllowedOffline(true);

    if (hasMixin(that, 'MGR_FOLDER')) {
      cmds.push(new NewFolderCommand(that));
      cmds.push(new AllDescendantsCommand(that));
    }

    cmds.push(newCommand);
    cmds.push(editCommand);

    if (hasMixin(that, 'MGR_LEARN')) {

      // Add the discovery commands if the learn mixin has been applied.
      // This will add several commands - discover, cancel discovery, and
      // hide/show learn pane.

      cmds = cmds.concat(that.makeDiscoveryCommands());
    }

    return cmds;
  };

  /**
   * Get the `NewCommand` instance from the command group or undefined if the command was not
   * added in the call to makeCommands.
   *
   * @private
   * @returns {module:bajaux/commands/Command}
   */
  DriverMgr.prototype.$getNewCommand = function () {
    return findCommand(this, NewCommand);
  };

  /**
   * Get the `EditCommand` instance from the command group or undefined if the command was not
   * added in the call to makeCommands.
   *
   * @private
   * @returns {module:bajaux/commands/Command}
   */
  DriverMgr.prototype.$getEditCommand = function () {
    return findCommand(this, EditCommand);
  };

  /**
   * Get the configured component subscription depth for the driver manager.
   * This value is specified by the 'subscriptionDepth' parameter property
   * in the constructor.
   *
   * @returns {Number}
   */
  DriverMgr.prototype.getSubscriptionDepth = function () {
    return this.$subscriptionDepth;
  };

  /**
   * Override of the base manager's build cell function.
   *
   * @param {module:nmodule/webEditors/rc/wb/table/model/Column} column The column for the cell
   * @param {module:nmodule/webEditors/rc/wb/table/model/Row} row The row for the cell
   * @param {JQuery} dom
   * @returns {Promise}
   */
  DriverMgr.prototype.buildMainTableCell = function (column, row, dom) {
    var that = this;

    // TODO : needs to provide a way for property columns to work better with folders
    // the proposed values shouldn't automatically return a new type unless asked for?
    // a parameter on the column constructor to indicate whether it should be used for
    // a folder?

    function isSuitableColumnForFolder(col) {
      return _.contains([ '__name', 'path', 'icon' ], col.getName());
    }

    return Promise.try(function () {

      if ((baja.hasType(row.getSubject(), FOLDER_TYPE)) && (!isSuitableColumnForFolder(column))) {
        dom.html('&nbsp;'); // Just put an empty space in the column for this particular folder row
        return Promise.resolve();
      }

      return Manager.prototype.buildMainTableCell.apply(that, [ column, row, dom ]);
    })
    .catch(function (ignore) {

      // This is intended to provide the same empty string fallback behavior of
      // the equivalent Java manager view. In the case where an error occurs
      // synchronously in the buildCell call, the result is a cell with empty content.

      dom.html('&nbsp;');
    });
  };

  /**
   * Overrides the basic manager `#finishMainTableRow` function with some extra css information
   * specified on the dom for the table row.
   *
   * @param {module:nmodule/webEditors/rc/wb/table/model/Row} row a table row instance
   * @param {JQuery} dom
   */
  DriverMgr.prototype.finishMainTableRow = function (row, dom) {
    var subject = row.getSubject(),
        status = this.$getComponentStatus(subject);

    dom.addClass('driver-mgr-row');
    addStatusCss(status, dom);

    if ((this.$folderType) && (baja.hasType(subject, this.$folderType))) {
      dom.addClass('driver-mgr-folder');
    }

    return Promise.resolve(dom);
  };

  return (DriverMgr);
});