wb/table/model/TableModel.js

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

/**
 * @module nmodule/webEditors/rc/wb/table/model/TableModel
 */
define([ 'Promise',
        'underscore',
        'nmodule/js/rc/switchboard/switchboard',
        'nmodule/js/rc/tinyevents/tinyevents',
        'nmodule/webEditors/rc/mixin/DataMixin',
        'nmodule/webEditors/rc/wb/table/model/Column',
        'nmodule/webEditors/rc/wb/table/model/Row' ], function (
         Promise,
         _,
         switchboard,
         tinyevents,
         DataMixin,
         Column,
         Row) {

  'use strict';

////////////////////////////////////////////////////////////////
// Support functions
////////////////////////////////////////////////////////////////

  /** @param {String} err */
  function reject(err) { return Promise.reject(new Error(err)); }

  /**
   * @inner
   * @param {Array} arr array we want to remove from
   * @param {Array} toRemove array of things to remove
   * @returns {Promise} promise to be resolved if remove can be performed
   */
  function validateArrayToRemove(arr, toRemove) {
    for (var i = 0; i < toRemove.length; i++) {
      if (arr.indexOf(toRemove[i]) < 0) {
        return reject('cannot remove a row or column not already in model');
      }
    }
    return Promise.resolve();
  }

  /**
   * @inner
   * @param {Array} arr array we want to remove from
   * @param {Number} start start removing here (inclusive)
   * @param {Number} end end removing here (exclusive)
   * @returns {Promise} promise to be resolved if remove can be performed
   */
  function validateIndicesToRemove(arr, start, end) {
    if (start < 0 ||
      start >= arr.length ||
      typeof end !== 'number' ||
      end < start ||
      end > arr.length) {

      return reject('invalid range to remove ' + start + ' - ' + end);
    }
    return Promise.resolve();
  }

  /**
   * @inner
   * @param {Array} arr array we want to remove from
   * @param {Array} toRemove array of things to remove
   * @returns {Promise} promise to be resolved with an array if remove
   * is successful, index 0 is the new array after removing, index 1 is the
   * array of objects removed
   */
  function removeArrayMembers(arr, toRemove) {
    return validateArrayToRemove(arr, toRemove)
      .then(function () {
        var removed = [],
            indices = [],
            newArr = arr.filter(function (thing, i) {
              if (toRemove.indexOf(thing) >= 0) {
                removed.push(thing);
                indices.push(i);
                return false;
              }
              return true;
            });

        return [ newArr, removed, indices ];
      });
  }

  /**
   * @inner
   * @param {Array} arr array to remove from
   * @param {Number} start start removing here (inclusive)
   * @param {Number} end end removing here (exclusive)
   * @returns {Promise} promise to be resolved with an array if remove
   * is successful, index 0 is the new array after removing, index 1 is the
   * array of objects removed
   */
  function removeArrayIndices(arr, start, end) {
    return validateIndicesToRemove(arr, start, end)
      .then(function () {
        var removed = arr.splice(start, end - start);
        return [ arr, removed, _.range(start, end) ];
      });
  }

  /**
   * @inner
   * @param {Array} arr array to remove from
   * @param {Array|Number} toRemove array of things to remove; or, start index
   * @param {Number} [end] end index
   * @returns {Promise} promise to be resolved with an array if remove
   * is successful, index 0 is the new array after removing, index 1 is the
   * array of objects removed
   */
  function doArrayRemove(arr, toRemove, end) {
    if (Array.isArray(toRemove)) {
      return removeArrayMembers(arr, toRemove);
    } else if (typeof toRemove === 'number') {
      return removeArrayIndices(arr, toRemove, end);
    } else {
      return reject('could not determine members to remove');
    }
  }

  /**
   * @inner
   * @param {Array} arr array to insert into
   * @param {Array} toInsert array of things to insert
   * @param {Number} [index] index to insert at (will just append to the end if
   * omitted)
   * @returns {Promise} promise to be resolved with the new array if
   * insert is successful
   */
  function doArrayInsert(arr, toInsert, index) {
    var len = arr.length;

    if (typeof index === 'number') {
      if (index < 0 || index > len) {
        return reject('index out of range: ' + index);
      }
    } else {
      index = len;
    }

    Array.prototype.splice.apply(arr, [ index, 0 ].concat(toInsert));
    return Promise.resolve([ arr, index ]);
  }

  /**
   * @inner
   * @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
   * model we are inserting columns into
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} columns
   * @returns {Promise} promise to be resolved if columns can be inserted
   */
  function validateColumnsToInsert(tableModel, columns) {
    if (!Array.isArray(columns)) {
      return reject('array required');
    }

    for (var i = 0; i < columns.length; i++) {
      if (!(columns[i] instanceof Column)) {
        return reject('only Columns can be added');
      }
    }

    return Promise.resolve(columns);
  }

  /**
   * @inner
   * @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
   * model we are inserting rows into
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Row|*>} rows
   * @returns {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>} array
   * of rows
   * @throws {Error} if no rows given
   */
  function validateRowsToInsert(tableModel, rows) {
    if (!Array.isArray(rows)) {
      throw new Error('array of rows/subjects required');
    }

    return _.map(rows, function (row) {
      return row instanceof Row ? row : tableModel.makeRow(row);
    });
  }

////////////////////////////////////////////////////////////////
// TableModel
////////////////////////////////////////////////////////////////

  /**
   * API Status: **Development**
   *
   * Table Model, for use in backing a `Table` widget or similar.
   *
   * @class
   * @alias module:nmodule/webEditors/rc/wb/table/model/TableModel
   * @mixes tinyevents
   * @mixes module:nmodule/webEditors/rc/mixin/DataMixin
   * @param {Object} [params]
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} [params.columns]
   * @param {Array} [params.rows] if given, the values will be converted to
   * the model's initial rows by passing them through `makeRow()`.
   */
  var TableModel = function TableModel(params) {
    params = params || {};

    var that = this,
        columns = (params.columns || []).slice(),
        rows = params.rows || [],
        flagsChangedHandler = function (flags) {
          that.emit('columnsFlagsChanged', [ this ], [ flags ]);
        };

    tinyevents(that);
    DataMixin(that, { sortColumns: {} });

    _.each(columns, function (column) {
      column.on('flagsChanged', flagsChangedHandler);
    });

    that.$flagsChangedHandler = flagsChangedHandler;

    that.$columns = columns;
    that.$rows = validateRowsToInsert(that, rows);
    that.$rowFilter = null;
    that.$filteredRows = null;

    //ensure these async methods don't step on each other if called in quick succession.
    switchboard(that, {
      insertRows: { allow: 'oneAtATime', onRepeat: 'queue', notWhile: "removeRows,clearRows,setRowFilter" },
      insertColumns: { allow: 'oneAtATime', onRepeat: 'queue', notWhile: "removeColumns" },
      removeRows: { allow: 'oneAtATime', onRepeat: 'queue', notWhile: "insertRows,clearRows,setRowFilter" },
      removeColumns: { allow: 'oneAtATime', onRepeat: 'queue', notWhile: "insertColumns" },
      clearRows: { allow: 'oneAtATime', onRepeat: 'queue', notWhile: "insertRows,removeRows,setRowFilter" },
      setRowFilter: { allow: 'oneAtATime', onRepeat: 'queue', notWhile: "insertRows,removeRows,clearRows" }
    });
  };

  /**
   * Add new columns to the model. Will trigger a `columnsAdded` `tinyevent`.
   *
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} toInsert
   * @param {Number} [index] index to insert the columns; will append to the
   * end if omitted
   * @returns {Promise} promise to be resolved if the insert is
   * successful
   */
  TableModel.prototype.insertColumns = function (toInsert, index) {
    var that = this,
        flagsChangedHandler = that.$flagsChangedHandler;

    return validateColumnsToInsert(that, toInsert)
      .then(function (columnsToInsert) {

        _.each(columnsToInsert, function (column) {
          column.on('flagsChanged', flagsChangedHandler);
        });

        return doArrayInsert(that.$columns, columnsToInsert, index)
          .then(([ newColumns, index ]) => {
            that.$columns = newColumns;
            that.emit('columnsAdded', columnsToInsert, index);
            return null; // squelch "promise not returned" warnings
          });
      });
  };

  /**
   * Remove columns from the model. Will trigger a `columnsRemoved` `tinyevent`.
   *
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>|Number} toRemove
   * the columns to remove; or, start index
   * @param {Number} [end] end index
   * @returns {Promise} promise to be resolved if the remove is
   * successful
   */
  TableModel.prototype.removeColumns = function (toRemove, end) {
    var that = this,
        flagsChangedHandler = that.$flagsChangedHandler;

    return doArrayRemove(that.getColumns(), toRemove, end)
      .then(function (arr) {
        var newColumns = arr[0],
            removed = arr[1];

        _.each(removed, function (column) {
          column.removeListener('flagsChanged', flagsChangedHandler);
        });

        that.$columns = newColumns;
        that.emit('columnsRemoved', removed);
        return null; // squelch "promise not returned" warnings
      });
  };

  /**
   * Get the column in this model matching the given name.
   *
   * @param {String} name
   * @returns {module:nmodule/webEditors/rc/wb/table/model/Column} the matching
   * column, or `null` if not found
   */
  TableModel.prototype.getColumn = function (name) {
    if (!name) {
      return null;
    }

    return _.find(this.$columns, function (column) {
      return column.getName() === name;
    }) || null;
  };

  /**
   * Get the current set of columns, optionally filtered by flags.
   *
   * @param {Number} [flags] if given, only return columns that have these
   * flags.
   * @returns {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>}
   */
  TableModel.prototype.getColumns = function (flags) {
    return this.$columns.filter(function (col) {
      return !flags || col.hasFlags(flags);
    });
  };

  /**
   * Get the index of the given column.
   *
   * @param {module:nmodule/webEditors/rc/wb/table/model/Column} column
   * @returns {number} the column's index, or -1 if not found
   */
  TableModel.prototype.getColumnIndex = function (column) {
    return _.indexOf(this.$columns, column);
  };

  /**
   * Return all columns with the `EDITABLE` flag set.
   *
   * @returns {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>}
   */
  TableModel.prototype.getEditableColumns = function () {
    return this.getColumns(Column.flags.EDITABLE);
  };

  /**
   * Ask the column at the given index for the value from the row at the
   * given index.
   *
   * @param {Number} x column index
   * @param {Number} y row index
   * @returns {Promise} promise to be resolved with the value
   */
  TableModel.prototype.getValueAt = function (x, y) {
    var row = this.$getRowsUnsafe()[y],
        column = this.$columns[x];

    return !row ? reject('row not found at ' + y) :
      !column ? reject('column not found at ' + x) :
      Promise.resolve(column.getValueFor(row));
  };

  /**
   * Instantiate a new row for the given subject. `insertRows` will delegate
   * to this if values are passed in rather than `Row` instances. Override
   * as necessary.
   *
   * @param {*} subject
   * @returns {module:nmodule/webEditors/rc/wb/table/model/Row}
   */
  TableModel.prototype.makeRow = function (subject) {
    return new Row(subject);
  };

  /**
   * Add new rows to the model. If non-`Row` instances are given, they will be
   * converted to `Row`s using `makeRow()`.
   *
   * If a row filter has been set to a non null function the index passed to this
   * function will be relative to the resulting filtered array returned from getRows().
   *
   * Will trigger a `rowsAdded` `tinyevent`.
   *
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Row|*>} toInsert
   * @param {Number} [index] index to insert the rows; will append to the
   * end if omitted
   * @returns {Promise} promise to be resolved if the insert is
   * successful
   */
  TableModel.prototype.insertRows = function (toInsert, index) {
    var that = this,
        rowsToInsert;

    try {
      rowsToInsert = validateRowsToInsert(that, toInsert);
    } catch (e) {
      return reject(e);
    }

    // If a filter is in effect for the table model and an index is
    // specified for row insertion, adjust the index for filtered
    // rows to a valid index for the full set of raw unfiltered rows.
    let rowAtIndex = null;
    if (this.$rowFilter !== null && typeof index === 'number' && index >= 0) {
      if (index < this.$filteredRows.length) {
        rowAtIndex = this.$filteredRows[index];
        index = this.$rows.indexOf(rowAtIndex);
      } else {
        index = this.$rows.indexOf(this.$filteredRows.slice(-1)[0]) + 1;
      }
    }

    return doArrayInsert(that.$rows, rowsToInsert, index)
      .then(([ newRows, index ]) => {
        that.$rows = newRows;

        // Before assigning newRows, if a filter is in effect for the table model ...
        //  1) Only rows that pass the filter should be added to the table
        //  2) Set index back to rowAtIndex for current filtered table
        //  3) Set updated filtered array
        if (that.$rowFilter !== null) {
          rowsToInsert = rowsToInsert.filter(that.$rowFilter);
          index = rowAtIndex !== null ? that.$filteredRows.indexOf(rowAtIndex) : that.$filteredRows.length;
          that.$filteredRows = that.$rows.filter(that.$rowFilter);
        }

        that.emit('rowsAdded', rowsToInsert, index);
        return null; // squelch "promise not returned" warnings
      });
  };

  /**
   * Remove rows from the model. Will trigger a `rowsRemoved` `tinyevent`, with
   * parameters:
   *
   * - `rowsRemoved`: the rows that were removed
   * - `indices`: the original indices of the rows that were removed
   *
   * If a row filter has been set to a non null function any indices passed to this
   * function will be relative to the resulting filtered array returned from getRows().
   *
   * Note that `rowsRemoved` and `indices` will always be sorted by their
   * original index in the model's rows, regardless of the order of rows passed
   * to the `removeRows` function.
   *
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>|Number} toRemove
   * the rows to remove; or, start index
   * @param [end] end index
   * @returns {Promise} promise to be resolved if the remove is
   * successful
   */
  TableModel.prototype.removeRows = function (toRemove, end) {
    var that = this;

    // If a row filter is in effect and a range of row indices has been specified,
    // grab the rows to remove out of the filtered row and pass them to doArrayRemove(...).
    if (this.$rowFilter !== null && typeof toRemove === 'number') {
      toRemove = this.getRows().slice(toRemove, end);
    }

    return doArrayRemove(this.$rows.slice(), toRemove, end)
      .then(([ newRows, rowsRemoved, indices ]) => {
        that.$rows = newRows;

        // If filtering is in effect the indices that will be passed to the table in the 'emit'
        // need to be adjusted prior to removing the rows.
        if (that.$rowFilter !== null) {
          indices = adjustIndicesWhenRemovingFilteredRows(that.getRows(), rowsRemoved);
          that.$filteredRows = that.$rows.filter(that.$rowFilter);
        }

        that.emit('rowsRemoved', rowsRemoved, indices);
        return null; // squelch "promise not returned" warnings
      });
  };

  function adjustIndicesWhenRemovingFilteredRows(rowsBeforeRemoval, removedRows) {
      let newIndices = [];

      removedRows.forEach((removedRow) => {
        newIndices.push(rowsBeforeRemoval.indexOf(removedRow));
      });

      return newIndices;
  }

  /**
   * Remove all rows from the model.
   *
   * Will trigger a `rowsRemoved` `tinyevent`, with
   * parameters:
   *
   * - `rowsRemoved`: the rows that were removed
   * - `indices`: the original indices of the rows that were removed
   * @return {Promise}
   */
  TableModel.prototype.clearRows = function () {
    var orgRows = this.getRows(),
        indices = orgRows.map(function (value, index) { return index; });

    this.$rows = [];
    this.$filteredRows = [];

    this.emit('rowsRemoved', orgRows, indices);
    return Promise.resolve();
  };

  /**
   * Get the current set of rows.
   *
   * @returns {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>}
   */
  TableModel.prototype.getRows = function () {
    if (this.$rowFilter === null) {
      return this.$rows.slice();
    } else {
      return this.$filteredRows.slice();
    }
  };

  /**
   * Get the index of the given row.
   *
   * @param {module:nmodule/webEditors/rc/wb/table/model/Row} row
   * @returns {number} the row's index, or -1 if not found
   */
  TableModel.prototype.getRowIndex = function (row) {
    return _.indexOf(this.$rows, row);
  };

  /**
   * Sort the table's rows according to the given sort function. Emits a
   * `rowsReordered` event.
   *
   * Remember that `Array#sort` is synchronous, so if the sort needs to use
   * any data that is asynchronously retrieved, the async work must be performed
   * *before* the sort so that the sort function can work synchronously.
   *
   * @param {Function} sortFunction standard array sort function to receive
   * two `Row` instances
   * @throws {Error} if a non-Function is given
   */
  TableModel.prototype.sort = function (sortFunction) {
    if (typeof sortFunction !== 'function') {
      throw new Error('sort function required');
    }

    this.$rows.sort(sortFunction);
    this.$filteredRows = this.$rowFilter === null ? null : this.$rows.filter(this.$rowFilter);
    this.emit('rowsReordered');
  };


  /**
   * Filter the table's rows according to the given filter function. Setting
   * the `rowFilterFunction` to `null` will remove the current filter and
   * reset the table model to display all rows.
   *
   * Will trigger a `rowsFiltered` `tinyevent`.
   *
   * Remember that `Array#filter` is synchronous, so if the filter needs to use
   * any data that is asynchronously retrieved, the async work must be performed
   * *before* the filter so that the filter function can work synchronously.
   *
   * @param {Function} rowFilterFunction standard array filter function
   * @return {Promise}
   * @throws {Error} if a non-Function is given
   */
  TableModel.prototype.setRowFilter = function (rowFilterFunction) {
    if (rowFilterFunction !== null && typeof rowFilterFunction !== 'function') {
      throw new Error('filter function required');
    }

    this.$rowFilter = rowFilterFunction;
    this.$filteredRows = rowFilterFunction === null ? null : this.$rows.filter(rowFilterFunction);

    this.emit('rowsFiltered');
    return Promise.resolve();
  };

  /**
   * For now, only support one column. But could support sorting by multiple
   * columns someday.
   * @private
   * @returns {string|undefined} the name of the column currently sorted by
   */
  TableModel.prototype.$getSortColumn = function () {
    return Object.keys(this.data('sortColumns'))[0];
  };

  /**
   * @private
   * @param {string} columnName
   * @returns {string|undefined} `asc`, `desc`, or undefined if the column is
   * not currently sorted
   */
  TableModel.prototype.$getSortDirection = function (columnName) {
    return this.data('sortColumns')[columnName];
  };

  /**
   * @private
   * @param {string} columnName
   * @param {string} sortDirection `asc`, `desc`, or undefined if the column
   * should not be sorted
   */
  TableModel.prototype.$setSortDirection = function (columnName, sortDirection) {
    var sortColumns = {};
    sortColumns[columnName] = sortDirection;
    this.data('sortColumns', sortColumns);
  };

  /**
   * @private
   * @returns {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>} direct
   * reference, don't modify
   */
  TableModel.prototype.$getRowsUnsafe = function () {
    if (this.$rowFilter === null) {
      return this.$rows;
    } else {
      return this.$filteredRows;
    }
  };

  /**
   * Sets the sort direction to `asc` or `desc` depending on its current sort
   * direction. If the TableModel is not currently sorted by this column name,
   * it will be afterwards.
   *
   * @private
   * @param {string} columnName
   */
  TableModel.prototype.$toggleSortDirection = function (columnName) {
    var sortDirection = this.$getSortDirection(columnName);
    this.$setSortDirection(columnName, sortDirection === 'asc' ? 'desc' : 'asc');
  };

  return TableModel;
});