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';

  const { range } = _;

////////////////////////////////////////////////////////////////
// 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 (let 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 () {
        const removed = [];
        const indices = [];
        const newArr = arr.filter((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 () {
        const 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) {
    const 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 (let i = 0; i < columns.length; i++) {
      if (!(columns[i] instanceof Column)) {
        return reject('only Columns can be added');
      }
    }

    return Promise.resolve(columns);
  }

////////////////////////////////////////////////////////////////
// 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()`.
   */
  const TableModel = function TableModel(params) {
    params = params || {};

    // keep that = this and flagsChangedHandler as non-arrow function,
    // because handler needs reference to emitting column
    const that = this;
    const columns = (params.columns || []).slice();
    const rows = params.rows || [];
    const flagsChangedHandler = function (flags) {
      // this === the column instance so cannot change to arrow function
      return that.emit('columnsFlagsChanged', [ this ], [ flags ]);
    };

    /*
     * set TableModel.$resolveHandlers to true to make sure that operations such as insertRows do not resolve until
     * all asynchronous event handlers (such as the Table repainting itself) have resolved. this way
     * you can do .insertRows(newRows).then(() => expect(table.find('tr').length).toBe(newRowsTotal))
     * instead of using waitForTrue(() => table.find('tr').length === newRowsTotal).
     */
    // noinspection JSUnresolvedVariable
    tinyevents(that, { resolveHandlers: TableModel.$resolveHandlers });
    DataMixin(that, { sortColumns: {} });

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

    that.$flagsChangedHandler = flagsChangedHandler;

    that.$i = Symbol('rowIndex');
    that.$columns = columns;
    that.$rows = updateIndices(that, that.$validateRowsToInsert(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) {
    const flagsChangedHandler = this.$flagsChangedHandler;

    return validateColumnsToInsert(this, toInsert)
      .then((columnsToInsert) => {

        columnsToInsert.forEach((column) => {
          column.on('flagsChanged', flagsChangedHandler);
        });

        return doArrayInsert(this.$columns, columnsToInsert, index)
          .then(([ newColumns, index ]) => {
            this.$columns = newColumns;
            return this.emit('columnsAdded', columnsToInsert, index);
          });
      });
  };

  /**
   * 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) {
    const flagsChangedHandler = this.$flagsChangedHandler;

    return doArrayRemove(this.getColumns(), toRemove, end)
      .then(([ newColumns, removed ]) => {
        removed.forEach((column) => {
          column.removeListener('flagsChanged', flagsChangedHandler);
        });

        this.$columns = newColumns;
        return this.emit('columnsRemoved', removed);
      });
  };

  /**
   * 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 this.$columns.find((column) => 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((col) => !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 this.$columns.indexOf(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) {
    const row = this.$getRowsUnsafe()[y];
    const 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 subject instanceof Row ? subject : 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) {
    let rowsToInsert;

    try {
      rowsToInsert = this.$validateRowsToInsert(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(this.$rows, rowsToInsert, index)
      .then(([ newRows, index ]) => {
        this.$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 (this.$rowFilter !== null) {
          rowsToInsert = rowsToInsert.filter(this.$rowFilter);
          index = rowAtIndex !== null ? this.$filteredRows.indexOf(rowAtIndex) : this.$filteredRows.length;
          this.$filteredRows = this.$rows.filter(this.$rowFilter);
        }

        this.$updateIndices();
        return this.emit('rowsAdded', rowsToInsert, index);
      });
  };

  /**
   * @private
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Row|*>} rows the rows or values to
   * insert
   * @returns {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>} array
   * of rows
   * @throws {Error} if no rows given
   */
  TableModel.prototype.$validateRowsToInsert = function (rows) {
    if (!Array.isArray(rows)) {
      throw new Error('array of rows/subjects required');
    }

    return rows.map((row) => row instanceof Row ? row : this.makeRow(row));
  };

  /**
   * 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) {
    const symbol = this.$i;

    // 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.$getRowsUnsafe().slice(toRemove, end);
    }

    return doArrayRemove(this.$rows.slice(), toRemove, end)
      .then(([ newRows, rowsRemoved, indices ]) => {
        this.$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 (this.$rowFilter !== null) {
          indices = adjustIndicesWhenRemovingFilteredRows(this.$getRowsUnsafe(), rowsRemoved);
          this.$filteredRows = this.$rows.filter(this.$rowFilter);
        }
        rowsRemoved.forEach((row) => delete row[symbol]);
        this.$updateIndices();
        return this.emit('rowsRemoved', rowsRemoved, indices);
      });
  };

  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 () {
    const orgRows = this.$getRowsUnsafe();
    const indices = orgRows.map((value, index) => index);

    clearIndices(this, this.$rows);
    clearIndices(this, this.$filteredRows);

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

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

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

  /**
   * Get the number of rows in the TableModel.
   *
   * @since Niagara 4.12
   * @returns {Number}
   */
  TableModel.prototype.getRowCount = function () {
    return this.$getRowsUnsafe().length;
  };

  /**
   * Get the row at the given index.
   *
   * @since Niagara 4.12
   * @param {number} i
   * @returns {module:nmodule/webEditors/rc/wb/table/model/Row|undefined} the row at this index, or
   * undefined if not present
   */
  TableModel.prototype.getRow = function (i) {
    return this.$getRowsUnsafe()[i];
  };

  /**
   * Get the index of the given row. If a filter is applied, returns the index of the row among the
   * currently filtered rows.
   *
   * @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) {
    const i = row[this.$i];
    return i === 0 ? 0 : i || -1;
  };

  /**
   * Update the stored index on each row so that getRowIndex() becomes O(1) instead of O(n).
   * @private
   */
  TableModel.prototype.$updateIndices = function () {
    if (this.$rowFilter === null) {
      clearIndices(this, this.$filteredRows);
      updateIndices(this, this.$rows);
    } else {
      clearIndices(this, this.$rows);
      updateIndices(this, this.$filteredRows);
    }
  };

  /**
   * 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
   * @returns {Promise} to be resolved after any necessary post-sorting work (this does *not* make
   * the sorting itself asynchronous).
   * @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.$updateIndices();
    return 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} to be resolved after any necessary post-filtering work (this does *not* make
   * the filtering itself asynchronous).
   * @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.$updateIndices();
    return this.emit('rowsFiltered');
  };

  /**
   * 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
   * @returns {Array.<*>|Thenable}
   */
  TableModel.prototype.$setSortDirection = function (columnName, sortDirection) {
    return this.data('sortColumns', { [columnName]: sortDirection });
  };

  /**
   * @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
   * @returns {Array.<*>|Thenable}
   */
  TableModel.prototype.$toggleSortDirection = function (columnName) {
    const sortDirection = this.$getSortDirection(columnName);
    return this.$setSortDirection(columnName, sortDirection === 'asc' ? 'desc' : 'asc');
  };

  /**
   * This field describes the behavior of TableModel with respect to insertion, editing, and removal
   * of rows.
   *
   * If true, methods related to these (such as `insertRows()`) will ensure that all async event
   * handlers are resolved before resolving the promise. This means that any changes to the DOM
   * (e.g. the Table that owns this TableModel inserting new `<tr>` elements) are complete. You will
   * often want this to be true in a test context, so you can `insertRows()` and then move on to
   * interacting with the DOM.
   *
   * If false, the returned promise will not wait - the DOM updates may be complete on resolution,
   * or they may not.
   *
   * @private
   * @type {boolean}
   * @since Niagara 4.13
   */
  TableModel.$resolveHandlers = false;

  function updateIndices(tableModel, rows) {
    if (rows) {
      const symbol = tableModel.$i;
      for (let i = 0, len = rows.length; i < len; ++i) {
        rows[i][symbol] = i;
      }
    }
    return rows;
  }

  function clearIndices(tableModel, rows) {
    if (rows) {
      const symbol = tableModel.$i;
      for (let i = 0, len = rows.length; i < len; ++i) {
        delete rows[i][symbol];
      }
    }
    return rows;
  }

  return TableModel;
});