wb/table/model/ComponentTableModel.js

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

/**
 * @module nmodule/webEditors/rc/wb/table/model/ComponentTableModel
 */
define([ 'baja!',
        'log!nmodule.webEditors.rc.wb.table.model.ComponentTableModel',
        'Promise',
        'underscore',
        'nmodule/js/rc/asyncUtils/promiseMux',
        'nmodule/webEditors/rc/fe/baja/util/typeUtils',
        'nmodule/webEditors/rc/wb/table/model/ComponentSource',
        'nmodule/webEditors/rc/wb/table/model/Row',
        'nmodule/webEditors/rc/wb/table/model/TableModel',
        'nmodule/webEditors/rc/wb/table/model/columns/PropertyColumn',
        'nmodule/webEditors/rc/wb/table/model/source/ContainerComponentSource' ], function (
         baja,
         log,
         Promise,
         _,
         promiseMux,
         typeUtils,
         ComponentSource,
         Row,
         TableModel,
         PropertyColumn,
         ContainerComponentSource) {

  'use strict';

  const { find, noop, throttle, uniq, without } = _;
  const { getSlotNames, isComponent } = typeUtils;
  const logError = log.severe.bind(log);

  const DEFAULT_CHANGE_EVENT_THROTTLE_MS = 250;

  /**
   * API Status: **Development**
   *
   * Table model where each row in the table represents a `Component`.
   *
   * A `ComponentTableModel` is backed by a `ComponentSource`, which provides
   * the list of `Components` to build into table rows.
   *
   * @class
   * @extends module:nmodule/webEditors/rc/wb/table/model/TableModel
   * @alias module:nmodule/webEditors/rc/wb/table/model/ComponentTableModel
   * @param {Object|baja.Component} params parameters object, or a `Component`
   * if no parameters required.
   * @param {baja.Component|module:nmodule/webEditors/rc/wb/table/model/ComponentSource} params.componentSource
   * the source of components to build into table rows.
   * If a `Component` is given it will just be wrapped in a `ComponentSource`
   * with no parameters.
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} params.columns
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>|Array.<baja.Component>} [params.rows=params.componentSource.getComponents()] optionally,
   * specify the TableModel's initial set of rows. By default, the rows will be all the components
   * contained within the ComponentSource.
   */
  const ComponentTableModel = function ComponentTableModel(params) {
    params = baja.objectify(params, 'componentSource');

    const { columns, rows } = params;
    let { componentSource, rowsChangedEventDelay } = params;

    if (isComponent(componentSource)) {
      componentSource = new ContainerComponentSource(componentSource);
    }

    if (!(componentSource instanceof ComponentSource)) {
      throw new Error('Component or ComponentSource required');
    }

    if (rowsChangedEventDelay === undefined) {
      rowsChangedEventDelay = DEFAULT_CHANGE_EVENT_THROTTLE_MS;
    }

    this.$componentSource = componentSource;
    this.$rowsChangedEventDelay = rowsChangedEventDelay;

    this.$emitThrottledRowsChangedEvent = promiseMux({
      exec: (rowArguments) => {
        const comps = _.map(rowArguments, (rowArg) => rowArg.comp);
        const allRows = this.$getRowsUnsafe();
        const existingRows = [];
        // may be assigned from an async MgrModel
        const processRow = this.$processRowAsync || noop;

        uniq(comps).forEach((comp) => {
          const existingRow = find(allRows, (row) => row.getSubject() === comp);
          if (existingRow) {
            existingRows.push(existingRow);
          }
        });

        if (!existingRows.length) {
          return comps;
        }

        return Promise.all(existingRows.map(processRow))
          .then(() => {
            this.emit('rowsChanged', existingRows);
            return comps;
          });
      },
      delay: rowsChangedEventDelay
    });

    const rowsReordered = throttle(() => {
      this.$resortRows();
    }, rowsChangedEventDelay, { leading: false });

    const addAndRemoveRows = promiseMux({
      /**
       * This throttles the addition / removal of rows in the table.
       *
       * @inner
       * @param {Array.<Object>} updates where each object has an `add` and `remove` array that
       * contain the components to add or remove
       * @returns {Promise} resolves when all rows have been added / removed
       */
      exec: (updates) => {
        const allAddComps = [];
        const allRemoveComps = [];

        // Combine all add / remove calls into two arrays.
        updates.forEach((update) => {
          const { add = [], remove = [] } = update;
          allAddComps.push(...add);
          allRemoveComps.push(...remove);
        });

        // Remove any components that are in both the add and remove arrays.
        const toAddComps = without(allAddComps, ...allRemoveComps);
        const toRemoveComps = without(allRemoveComps, ...allAddComps);

        // Turn the components to remove into the corresponding rows
        const toRemoveRows = [];
        toRemoveComps.forEach((comp) => {
          const obj = this.$getRowForSubject(comp);
          if (obj) { toRemoveRows.push(obj.row); }
        });

        return Promise.all([
          toAddComps.length && this.insertRows(toAddComps).catch(logError),
          toRemoveRows.length && this.removeRows(toRemoveRows).catch(logError)
        ])
          .then(() => {
            return updates; //ensures that the expected number of items in the array are resolved for promiseMux.
          });
      },
      delay: rowsChangedEventDelay,
      coalesce: false
    });

    componentSource
      .on('added', (addedComps) => addAndRemoveRows({ add: addedComps }))
      .on('removed', (removedComps) => addAndRemoveRows({ remove: removedComps }))
      .on('changed', (comp) => {
        this.$emitThrottledRowsChangedEvent(new RowsChangedArgument(comp));
      })
      .on('reordered', () => {
        rowsReordered();
      });

    TableModel.call(this, {
      columns,
      rows: rows || componentSource.getComponents()
    });
  };
  ComponentTableModel.prototype = Object.create(TableModel.prototype);
  ComponentTableModel.prototype.constructor = ComponentTableModel;

  /**
   * @private
   * @param {baja.Component} comp
   * @param {String|Type} childType
   * @returns {Promise.<module:nmodule/webEditors/rc/wb/table/model/ComponentTableModel>} a table
   * model showing one column for every frozen property of the specified type, and one row for every
   * child of the given component matching that type
   */
  ComponentTableModel.$makeBasic = function (comp, childType) {
    return baja.importTypes([ childType ])
      .then(([ type ]) => {
        return new ComponentTableModel({
          componentSource: new ContainerComponentSource({
            container: comp,
            filter: [ type ]
          }),
          columns: getSlotNames(type).map((slotName) => new PropertyColumn(slotName, { type }))
        });
      });
  };

  /**
   * Find the first row that has the given subject (identity equal).
   *
   * @private
   * @param {*} subject
   * @returns {Object} object with `row` (the actual Row) and `index`
   * properties, or undefined if not found
   */
  ComponentTableModel.prototype.$getRowForSubject = function (subject) {
    const rows = this.getRows();
    for (let i = 0; i < rows.length; i++) {
      if (rows[i].getSubject() === subject) {
        return { row: rows[i], index: i };
      }
    }
  };

  /**
   * Re-sort the rows in this TableModel to match the current ordering of component slots.
   * @private
   */
  ComponentTableModel.prototype.$resortRows = function () {
    const comps = this.getComponentSource().getComponents();
    this.sort((row1, row2) => {
      const index1 = comps.indexOf(row1.getSubject());
      const index2 = comps.indexOf(row2.getSubject());
      return index1 - index2;
    });
  };

  /**
   * Get the `ComponentSource` backing this table model.
   *
   * @returns {module:nmodule/webEditors/rc/wb/table/model/ComponentSource}
   */
  ComponentTableModel.prototype.getComponentSource = function () {
    return this.$componentSource;
  };

  /**
   * Return the delay (in milliseconds) used by throttled 'rowsChanged' event
   * emission function.
   *
   * @private
   */
  ComponentTableModel.prototype.$getRowsChangedEventDelay = function () {
    return this.$rowsChangedEventDelay;
  };

  /**
   * Ensures that the row's icon is set to the component's icon.
   * @param {baja.Component|module:nmodule/webEditors/rc/wb/table/model/Row} comp
   * @returns {module:nmodule/webEditors/rc/wb/table/model/Row}
   * @since Niagara 4.14
   */
  ComponentTableModel.prototype.makeRow = function (comp) {
    return comp instanceof Row ? comp : new Row(comp, comp.getIcon());
  };

  /*
   * promiseMux coalescing keys on the `toString()` of the object passed in and a Component's toString defaults to just its Type.
   * If available, this class uses the component's handle for the toString so the coalescing from station side changes works properly.
   * @class
   * @inner
   */
  class RowsChangedArgument {
    constructor(comp) {
      this.comp = comp;
    }

    /**
     * @returns {string}
     */
    toString() {
      const comp = this.comp;
      if (baja.hasType(comp, 'baja:Component')) {
        const handle = comp.getHandle();
        if (handle) {
          return handle;
        }
      }
      return comp.toString();
    }
  }


  return ComponentTableModel;
});