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