wb/table/tree/TreeTableModel.js

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

/**
 * @module nmodule/webEditors/rc/wb/table/tree/TreeTableModel
 */
define([ 'Promise',
        'underscore',
        'nmodule/js/rc/switchboard/switchboard',
        'nmodule/webEditors/rc/wb/table/model/Row',
        'nmodule/webEditors/rc/wb/table/model/TableModel',
        'nmodule/webEditors/rc/wb/table/tree/TreeNodeRow',
        'nmodule/webEditors/rc/wb/tree/TreeNode' ], function (
         Promise,
         _,
         switchboard,
         Row,
         TableModel,
         TreeNodeRow,
         TreeNode) {

  'use strict';

  const DEPTH_KEY = 'TreeTableModel.depth';
  const EXPANDED_KEY = 'TreeTableModel.expanded';
  const UNKNOWN_ROW_MSG = 'row not contained in this model';
  const SB_QUEUE_UP = { allow: 'oneAtATime', onRepeat: 'queue' };

  function isDescendantOf(ancestor, node) {
    while ((node = node.getParent())) {
      if (node === ancestor) { return true; }
    }
  }

  /**
   * API Status: **Development**
   *
   * A `TableModel` backed by a `TreeNode`. Each Row in the table must also be
   * backed by a TreeNode.
   *
   * You should not typically call this constructor directly - use the `.make()`
   * method instead.
   *
   * @class
   * @alias module:nmodule/webEditors/rc/wb/table/tree/TreeTableModel
   * @extends module:nmodule/webEditors/rc/wb/table/model/TableModel
   * @param {Object} params
   * @param {module:nmodule/webEditors/rc/wb/tree/TreeNode} [params.node] if given, the child nodes
   * of this node will be used as the initial rows of the table model.
   *
   * @example
   * //when deciding which columns to use in the model, remember that rows
   * //will return the actual values via getSubject(), so ordinary
   * //Columns/MgrColumns can be used. custom column types can call
   * //row.getTreeNode() if necessary.
   * TreeTableModel.make({
   *   columns: [
   *     new PropertyMgrColumn('prop1'),
   *     new PropertyMgrColumn('prop2')
   *   ]
   * });
   *
   * //when inserting, values must be TreeNodes.
   * treeTableModel.insertRows(components.map(function (comp) {
   *   var node = new TreeNode('component:' + comp.getName());
   *   node.value = function () { return comp; };
   *   return node;
   * });
   */
  const TreeTableModel = function TreeTableModel(params) {
    TableModel.apply(this, arguments);
    this.$node = (params && params.node) || new TreeNode('root');

    switchboard(this, {
      expand: _.extend(SB_QUEUE_UP, { notWhile: 'collapse' }),
      collapse: _.extend(SB_QUEUE_UP, { notWhile: 'expand' })
    });
  };
  TreeTableModel.prototype = Object.create(TableModel.prototype);
  TreeTableModel.prototype.constructor = TreeTableModel;

  /**
   * Create a new `TreeTableModel` instance.
   *
   * @param {Object} params
   * @param {module:nmodule/webEditors/rc/wb/tree/TreeNode} params.node the root
   * node backing this model
   * @returns {Promise.<module:nmodule/webEditors/rc/wb/table/tree/TreeTableModel>}
   * promise to be resolved with the new `TreeTableModel` instance, containing
   * one row per child node of the root node passed in
   */
  TreeTableModel.make = function (params) {
    return Promise.try(function () {
      return new TreeTableModel(params);
    })
      .then((model) => {
        return model.$expandForNode(model.$node, 0).then(() => model);
      });
  };

  /**
   * Create new `Row`s for the given `TreeNode`'s kids and insert them at the specified
   * index.
   * @private
   * @param {module:nmodule/webEditors/rc/wb/tree/TreeNode} parentNode
   * @param {number} index
   * @returns {Promise.<Array.<module:nmodule/webEditors/rc/wb/table/tree/TreeNodeRow>>}
   * promise to be resolved with an array of the inserted rows
   */
  TreeTableModel.prototype.$expandForNode = function (parentNode, index) {
    return parentNode.getKids()
      .then((kids) => {
        const kidRows = kids.map((kid) => this.makeRow(kid));
        return this.insertRows(kidRows, index).then(() => kidRows);
      });
  };

  /**
   * When inserting rows, store away their depth value for future calculations.
   * @private
   * @override
   * @inheritDoc
   */
  TreeTableModel.prototype.$validateRowsToInsert = function (rows) {
    rows = TableModel.prototype.$validateRowsToInsert.apply(this, arguments);
    rows.forEach((row) => row.data(DEPTH_KEY, this.$calculateDepth(row.getTreeNode())));
    return rows;
  };

  /**
   * @private
   * @param {module:nmodule/webEditors/rc/wb/tree/TreeNode} node
   * @returns {number} this node's depth in the TreeTableModel. A depth of 0 indicates a "top-level"
   * node - either it has no parent, or it's a direct child of the root node passed to the
   * constructor.
   */
  TreeTableModel.prototype.$calculateDepth = function (node) {
    const parent = node.getParent();
    if (parent === this.$node || !parent) {
      return 0;
    } else {
      return this.$calculateDepth(parent) + 1;
    }
  };

  /**
   * Get the root node backing this `TreeTableModel`.
   * @deprecated as of Niagara 4.14. TreeTableModel has always supported adding freestanding nodes that
   * do not all belong to the same root node.
   * @returns {module:nmodule/webEditors/rc/wb/tree/TreeNode}
   */
  TreeTableModel.prototype.getRootNode = function () { return this.$node; };

  /**
   * Create a new `Row` instance with a `TreeNode` as the subject.
   * @param {module:nmodule/webEditors/rc/wb/tree/TreeNode} subject
   * @returns {module:nmodule/webEditors/rc/wb/table/tree/TreeNodeRow}
   */
  TreeTableModel.prototype.makeRow = function (subject) {
    return new TreeNodeRow(subject);
  };

  /**
   * Return true if the `Row`'s `TreeNode` might have child nodes.
   * @param {module:nmodule/webEditors/rc/wb/table/tree/TreeNodeRow} row
   * @returns {boolean}
   */
  TreeTableModel.prototype.isExpandable = function (row) {
    return row.getTreeNode().mayHaveKids();
  };

  /**
   * Return true if the given `Row` is marked as expanded.
   * @param {module:nmodule/webEditors/rc/wb/table/model/Row} row
   * @returns {boolean}
   */
  TreeTableModel.prototype.isExpanded = function (row) {
    return !!row.data(EXPANDED_KEY);
  };

  /**
   * Get the depth of this `Row`'s `TreeNode` from the `TreeTableModel`'s root
   * node. A direct child of the root will have a depth of 0, etc.
   * @param {module:nmodule/webEditors/rc/wb/table/model/Row} row
   * @returns {number|null} the depth of the `Row`, or `null` if the depth could
   * not be determined (e.g. the `Row` is not actually contained in this model).
   */
  TreeTableModel.prototype.getDepth = function (row) {
    const depth = row.data(DEPTH_KEY);
    return typeof depth === 'number' ? depth : null;
  };

  /**
   * Get child nodes of the given `Row`'s `TreeNode` and insert new `Row`s for
   * each one.
   * @param {module:nmodule/webEditors/rc/wb/table/tree/TreeNodeRow} row
   * @returns {Promise.<Array.<module:nmodule/webEditors/rc/wb/table/tree/TreeNodeRow>>}
   * promise to be resolved with an array of the inserted rows
   */
  TreeTableModel.prototype.expand = function (row) {
    const i = this.getRowIndex(row);

    if (i < 0) {
      return Promise.reject(new Error(UNKNOWN_ROW_MSG));
    }

    if (row.data(EXPANDED_KEY)) { return Promise.resolve(); }

    row.data(EXPANDED_KEY, true);

    return this.$expandForNode(row.getTreeNode(), i + 1);
  };

  /**
   * Get child nodes of the given `Row`'s `TreeNode` and remove their
   * corresponding `Row`s.
   * @param {module:nmodule/webEditors/rc/wb/table/tree/TreeNodeRow} row
   * @returns {Promise.<Array.<module:nmodule/webEditors/rc/wb/table/model/Row>>}
   * promise to be resolved with an array of the rows that were removed
   */
  TreeTableModel.prototype.collapse = function (row) {
    if (this.getRowIndex(row) < 0) {
      return Promise.reject(new Error(UNKNOWN_ROW_MSG));
    }

    row.data(EXPANDED_KEY, false);

    const node = row.getTreeNode();
    const descendantRows = _.filter(this.getRows(), function (row) {
      return isDescendantOf(node, row.getTreeNode());
    });

    return this.removeRows(descendantRows).then(_.constant(descendantRows));
  };

  /**
   * Collapse the row if it is expanded, and vice versa.
   * @param {module:nmodule/webEditors/rc/wb/table/model/Row} row
   * @returns {Promise}
   */
  TreeTableModel.prototype.toggle = function (row) {
    return this[this.isExpanded(row) ? 'collapse' : 'expand'](row);
  };

  return TreeTableModel;
});