DriverMgr.js

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

/**
 * @module nmodule/driver/rc/wb/mgr/DriverMgr
 */
define([
  'baja!',
  'bajaux/mixin/mixinUtils',
  'bajaux/mixin/subscriberMixIn',
  'Promise',
  'jquery',
  'underscore',
  '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/model/columns/NameMgrColumn',
  'nmodule/webEditors/rc/wb/mgr/model/columns/PathMgrColumn',
  'nmodule/webEditors/rc/wb/mixin/ComponentHyperlinkColumnMixin',
  'css!nmodule/driver/rc/driver' ], function (
  baja,
  mixinUtils,
  subscribable,
  Promise,
  $,
  _,
  DepthSubscriber,
  typeUtils,
  componentStatusUtils,
  Manager,
  addFolderSupport,
  NameMgrColumn,
  PathMgrColumn,
  addComponentHyperlinkSupport) {

  'use strict';

  var FOLDER_TYPE = baja.lt('baja:Folder'),
      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 hasSubjectsAndAllEditable(mgr, subjects) {
    if (!subjects.length) { return false; }

    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 {module:nmodule/webEditors/rc/wb/util/subscriptionUtil~SubscriptionFilter} [params.subscriptionFilter]
   * Starting in Niagara 4.13, if the optional subscriptionFilter function is provided, it will be called for each
   * potentially subscribable BComponent with its current depth. By returning true only for the desired components,
   * the subscription will subscribe to what is needed.
   * @param {module:nmodule/webEditors/rc/wb/util/subscriptionUtil~SubscribeCallback} [params.subscribeCallback]
   * Starting in Niagara 4.13, if the optional subscribeCallback function is provided, it will receive a callback when a component is subscribed.
   * This can allow you to add additional subscriptions outside the normal depth and filter results.
   * @param {String|Type} [params.folderType] optional parameter indicating the folder type
   * used for the manager view. This will be used by the NewFolder command.
   * @param {Array.<Type|String>} [params.editableTypes] specifies which types of values can be
   * edited in the database table. If omitted, the Edit command will not be enabled.
   * @param {function(baja.Component): baja.Status} [params.getComponentStatus] to enable status
   * coloring of values in the database table, provide a function to be used to get a Status
   * instance for each one.
   *
   * @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);

    that.$editableTypes = params.editableTypes || [];
    that.$getComponentStatus =
      params.getComponentStatus || _.constant(baja.Status.ok);

    that.$subscriptionDepth = params.subscriptionDepth || 1;
    that.$subscriber = new DepthSubscriber({
      depth: that.$subscriptionDepth,
      subscriptionFilter: params.subscriptionFilter,
      subscribeCallback: params.subscribeCallback
    });

    subscribable(that);

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

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

  /**
   * 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);
  };

  /**
   * 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.
   * @param {baja.Property} prop The property that changed
   */
  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);
  };

  /**
   * 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;

    return Promise.try(function () {
      if ((baja.hasType(row.getSubject(), FOLDER_TYPE)) && (!that.$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;');
    });
  };

  /**
   * The name, path, and icon columns are visible for Folders by default. Override this method if your
   * Manager subclass would like to make other columns suitable for Folders.
   * @private
   * @since Niagara 4.14
   * @param {module:nmodule/webEditors/rc/wb/table/model/Column} col a table column instance
   */
  DriverMgr.prototype.$isSuitableColumnForFolder = function (col) {
    return _.contains([ '__name', '__path', 'icon' ], col.getName());
  };

  /**
   * 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);
  };

  /**
   * Provides the default implementation of the call back function for double-click of rows.
   * In the callback 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.
   * @since Niagara 4.14
   * @param {JQuery.Event} event the double click event
   * @param {Array.<*>} subjects the selected subject of the table being clicked
   * @return {Promise|*}
   */
  DriverMgr.prototype.onMainTableDblClicked = function (event, subjects) {
    const editCmd = this.$getEditCommand();

    if (subjects && subjects.length) {
      if (hasSingleFolderSubject(subjects)) {
        return Promise.resolve($(event.target).closest('tr').find('td.driver-mgr-link > a').first().trigger('click'));
      } else if (editCmd && hasSubjectsAndAllEditable(this, subjects)) {
        return editCmd.invoke();
      }
    }

    return Promise.resolve();
  };

  /**
   * This provides the default function of enabling or disabling the edit command based on what
   * is currently selected in the main table. We typically won't enable the edit command if a
   * folder is included in the current selection.
   * @since Niagara 4.14
   * @param {Object} selectedSubjects an object that holds the array of the selected subjects from the tables
   *  supported by the manager view.
   * @param {Array.<*>} selectedSubjects.mainTableSelection the current selected subjects
   *  in the main table.
   * @param {Array.<*>|undefined} selectedSubjects.learnTableSelection the current selected subjects
   *  in the learn or discovery table.  This will be undefined if Manager does not support a learn
   *  table or the getLearnTable() function returns undefined.
   * @return {Promise|*}
   */
  DriverMgr.prototype.onTableSelectionChanged = function (selectedSubjects) {
    return Manager.prototype.onTableSelectionChanged.call(this, selectedSubjects)
      .then(() => {
        const editCmd = this.$getEditCommand();
        const { mainTableSelection } = selectedSubjects;

        if (editCmd) {
          editCmd.setEnabled(hasSubjectsAndAllEditable(this, mainTableSelection));
        }
      });
  };

  return (DriverMgr);
});