/**
* @copyright 2015 Tridium, Inc. All Rights Reserved.
* @author Logan Byam
*/
/*eslint-env browser *//*jshint browser: true */
/**
* @module nmodule/webEditors/rc/wb/table/Table
*/
define([ 'log!nmodule.webEditors.rc.wb.table',
'jquery',
'Promise',
'underscore',
'nmodule/js/rc/asyncUtils/asyncUtils',
'nmodule/js/rc/log/Log',
'nmodule/js/rc/switchboard/switchboard',
'nmodule/js/rc/tinyevents/tinyevents',
'nmodule/webEditors/rc/fe/BaseWidget',
'nmodule/webEditors/rc/util/htmlUtils',
'nmodule/webEditors/rc/util/ListSelection',
'nmodule/webEditors/rc/wb/table/model/TableModel',
'nmodule/webEditors/rc/wb/table/pagination/PaginationModel' ], function (
tableLog,
$,
Promise,
_,
asyncUtils,
Log,
switchboard,
tinyevents,
BaseWidget,
htmlUtils,
ListSelection,
TableModel,
PaginationModel) {
'use strict';
const CELL_ACTIVATED_EVENT = 'table:cellActivated';
const ROW_SELECTION_CHANGED_EVENT = 'table:rowSelectionChanged';
const { each, extend, find, invoke, toArray } = _;
const { preventSelectOnShiftClick } = htmlUtils;
const logError = tableLog.severe.bind(tableLog);
const TABLE_CONTENTS_HTML = '<thead class="ux-table-head"></thead>' +
'<tbody></tbody>' +
'<tfoot class="ux-table-foot"></tfoot>';
const QUEUE_UP = { allow: 'oneAtATime', onRepeat: 'queue' };
const DENSITY_LOW = "low";
const DENSITY_MEDIUM = "medium";
const DENSITY_HIGH = "high";
const VALID_DENSITIES = [ DENSITY_LOW, DENSITY_MEDIUM, DENSITY_HIGH ];
function widgetDefaults() {
return {
properties: {
density: { value: DENSITY_MEDIUM, typeSpec: 'webEditors:ContentDensity' },
leftJustify: true,
enableStriping: true
}
};
}
////////////////////////////////////////////////////////////////
// Support functions
////////////////////////////////////////////////////////////////
function toCssClass(column) {
return 'js-col-' + column.getName().replace(' ', '_');
}
function mapDom(elem, fun) {
return elem.map((i, el) => fun(el)).get();
}
function removeIt(elem) {
elem.remove();
}
function getTableContainer(dom) {
return dom.children('.tableContainer');
}
function getTable(dom) {
return getTableContainer(dom).children('table');
}
//TODO: MgrColumn#toSortKey equivalent
/**
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} model
* @param {string} dataKey
* @param {boolean} desc
* @returns {Thenable|Array}
*/
function sortByDataKey(model, dataKey, desc) {
const lessThan = desc ? 1 : -1;
const greaterThan = desc ? -1 : 1;
return model.sort((row1, row2) => {
const display1 = row1.data(dataKey);
const display2 = row2.data(dataKey);
return display1 === display2 ? 0 :
display1 < display2 ? lessThan : greaterThan;
});
}
/**
* This is ugly, but I am convinced it is impossible to do with pure CSS.
*
* When scrolling with a fixed header, in most browsers a margin-left on the
* header dups to counteract the container's scrollLeft does the trick.
*
* @param {module:nmodule/webEditors/rc/wb/table/Table} table
* @param {JQuery} dom
*/
function applyFixedHeaderScrolling(table, dom) {
const onScroll = function onScroll() {
const headerDups = table.$getThead().find('.headerDuplicate');
const scrollLeft = this.scrollLeft;
headerDups.css('marginLeft', -scrollLeft);
};
getTableContainer(dom).on('scroll', onScroll);
}
function getDensityClass(density) {
switch (density.toLowerCase()) {
case DENSITY_LOW:
return 'ux-table-density-low';
case DENSITY_HIGH:
return 'ux-table-density-high';
default:
return 'ux-table-density-medium';
}
}
function getValidDensity(density) {
if (typeof density !== 'string') {
return DENSITY_LOW;
}
const validDensity = find(VALID_DENSITIES, (d) => d === density.toLowerCase());
return validDensity || DENSITY_LOW;
}
////////////////////////////////////////////////////////////////
// Table
////////////////////////////////////////////////////////////////
/**
* API Status: **Development**
*
* Table widget.
*
* It supports the following `bajaux` `Properties`:
*
* - `fixedHeaders`: (boolean) set to true to allow scrolling the table body
* up and down while the headers remain fixed. This will only make sense
* when the table widget is instantiated in a block-level element, like a
* `div`, whose dimensions are constrained.
* - `leftJustify`: (boolean) set to true to left-justify the table and span
* the last visible column of the table to 100% width.This makes the table
* itself 100% width now, so the table will no longer work inline. Defaults
* to true.
* - `hideUnseenColumns`: (boolean) set to `false` to cause columns with the
* `UNSEEN` flag to always be shown. Defaults to `true` (unseen columns are
* hidden by default).
* - `density`: (string) supports "small", "medium" and "large" font-sizes to
* specify the density of the table
* - `enableStriping`: (boolean) defaults to true and shows stripes on table widget,
* and is ignored if on a <tbody>
*
*
*
*
* @class
* @alias module:nmodule/webEditors/rc/wb/table/Table
* @extends module:nmodule/webEditors/rc/fe/BaseWidget
* @implements module:nmodule/export/rc/TransformOperationProvider
* @param {Object} params
* @param {module:nmodule/webEditors/rc/util/ListSelection} [params.selection] the `ListSelection`
* to manage which rows are currently selected - if not given, a new one will be constructed.
*/
const Table = function Table(params) {
BaseWidget.call(this, {
params: extend({ moduleName: 'webEditors', keyName: 'Table' }, params),
defaults: widgetDefaults()
});
this.$selection = (params && params.selection) || new ListSelection();
// if multiple rowsChanged events come in for the same row rapid-fire, ensure
// that they queue correctly and execute in sequence.
switchboard(this, {
'$handleRowEvent': extend({}, QUEUE_UP, { notWhile: '$handleColumnEvent' }),
'$handleColumnEvent': extend({}, QUEUE_UP, { notWhile: '$handleRowEvent' }),
'$resolveCurrentPage': { allow: 'oneAtATime', onRepeat: 'preempt' }
});
tinyevents(this, { resolveHandlers: TableModel.$resolveHandlers });
};
Table.prototype = Object.create(BaseWidget.prototype);
Table.prototype.constructor = Table;
/**
* Will be triggered when a row is "activated," or selected by the user, such as by
* double-clicking on it. The handler will receive the Table that triggered the
* event, and the Row and Column that were activated.
*
* @type {string}
* @since Niagara 4.12
* @example
* dom.on(Table.CELL_ACTIVATED_EVENT, (event, table, activatedRow, activatedColumn) => {
* const activatedValue = activatedColumn.getValueFor(activatedRow); // value for the cell
* const activatedSubject = activatedRow.getSubject(); // value for the row
* });
*/
Table.CELL_ACTIVATED_EVENT = CELL_ACTIVATED_EVENT;
/**
* Will be triggered when rows are selected or deselected. The handler will receive the Table
* that triggered the event. Calculating which rows are selected is cheap but not free, so to
* protect performance in the case that the actual selected rows are not used, they will _not_
* be passed to the handler. To act on the newly selected rows, call `table.getSelectedRows()`.
*
* @type {string}
* @since Niagara 4.12
* @example
* dom.on(Table.ROW_SELECTION_CHANGED_EVENT, (event, table) => {
* const newSelectedRows = table.getSelectedRows();
* });
*/
Table.ROW_SELECTION_CHANGED_EVENT = ROW_SELECTION_CHANGED_EVENT;
/**
* @private
* @returns {boolean}
*/
Table.prototype.$isFixedHeaders = function () {
const tagName = this.jq().prop('tagName').toLowerCase();
const isFixedHeaders = this.properties().getValue('fixedHeaders');
const isInTable = tagName === 'table' || tagName === 'tbody';
if (isFixedHeaders && isInTable) {
throw new Error('When fixedHeaders is true, must not initialize in "table" or "tbody" tag.');
}
return !isInTable && isFixedHeaders !== false;
};
/**
* Get if the striping is enabled or disabled
*
* @private
* @returns {boolean}
*/
Table.prototype.$isEnableStriping = function () {
return this.properties().getValue('enableStriping', false);
};
/**
* @private
* @returns {boolean}
*/
Table.prototype.$isTableLeftJustified = function () {
return this.properties().getValue('leftJustify');
};
/**
* Get the `ListSelection` representing the currently selected table rows.
*
* @private
* @returns {module:nmodule/webEditors/rc/util/ListSelection}
*/
Table.prototype.$getSelection = function () {
return this.$selection;
};
/**
* Get the content density of the table
*
* @private
* @returns {string} density
*/
Table.prototype.$getDensity = function () {
return this.properties().getValue('density');
};
/**
* Return true if columns with the `UNSEEN` flag should be hidden.
*
* @private
* @returns {boolean}
*/
Table.prototype.$isHideUnseen = function () {
return this.properties().getValue('hideUnseenColumns') !== false;
};
//noinspection JSUnusedLocalSymbols
/**
* Get the table body element(s).
*
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} [tableModel]
* a table could contain multiple `tbody` tags, one per `TableModel`. If
* given, this should return a jQuery object of length 1 corresponding only
* to that `TableModel`. Otherwise, this could return multiple `tbody`
* elements. By default, there will only be one.
* @returns {JQuery}
*/
Table.prototype.$getTbody = function (tableModel) {
const dom = this.jq();
switch (dom.prop('tagName').toLowerCase()) {
case 'table':
return dom.children('tbody');
case 'tbody':
return dom;
default:
return dom.find('tbody').eq(0);
}
};
/**
* Get the table head element.
*
* @private
* @returns {JQuery}
*/
Table.prototype.$getThead = function () {
const dom = this.jq();
switch (dom.prop('tagName').toLowerCase()) {
case 'table':
return dom.children('thead');
case 'tbody':
return $();
default:
return getTable(dom).children('thead');
}
};
/**
* @private
* @returns {JQuery} when this Table is initialized in a block level element, returns the child
* element that contains the actual <table> element
*/
Table.prototype.$getTableContainer = function () {
return getTableContainer(this.jq());
};
/**
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
* @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>} rows
* @returns {Promise}
*/
Table.prototype.$rebuildRowContents = function (tableModel, rows) {
const selection = this.$getSelection();
const columns = tableModel.getColumns();
const tbody = this.$getTbody(tableModel);
const kids = tbody.children();
const rowsToReplace = [];
const buildNewRows = [];
rows.forEach((row) => {
const rowIndex = tableModel.getRowIndex(row);
const selected = selection.isSelected(rowIndex);
const tableRow = kids[this.$rowIndexToTrIndex(tableModel.getRowIndex(row))];
if (tableRow) {
rowsToReplace.push(tableRow);
buildNewRows.push(this.$toTableRow(tableModel, columns, row, selected));
}
});
return Promise.all(buildNewRows)
.then((newTrs) => {
for (let i = 0, len = newTrs.length; i < len; ++i) {
const oldTr = rowsToReplace[i];
$(oldTr).replaceWith(newTrs[i]);
}
});
};
/**
* @private
* @param {number} rowIndex the index of the row in the TableModel
* @returns {number} the index in the `tbody` of the `tr` corresponding to the given row
*/
Table.prototype.$rowIndexToTrIndex = function (rowIndex) {
return rowIndex;
};
/**
* @private
* @param {number} trIndex the index of the `tr` in the `tbody`
* @returns {number} the index of the row in the TableModel corresponding to the given `tr`
*/
Table.prototype.$trIndexToRowIndex = function (trIndex) {
return trIndex;
};
/**
* Sort table rows given a column and asc/desc flag.
*
* Override this if you want to override the sort ordering.
*
* @param {module:nmodule/webEditors/rc/wb/table/model/Column} column
* @param {boolean} desc
* @returns {Promise|*}
* @since Niagara 4.8
*/
Table.prototype.sort = function (column, desc) {
return this.$sortByColumnDisplay(column, desc);
};
/**
* Sort table rows based on the display string value provided by the given
* column.
*
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/Column} column
* @param {boolean} desc
* @returns {Promise}
*/
Table.prototype.$sortByColumnDisplay = function (column, desc) {
const dataKey = 'displayString.' + column.getName();
const model = this.getModel();
const rows = model.getRows();
return Promise.all(rows.map((row) => {
const dom = $('<div/>');
return Promise.resolve(this.buildCell(column, row, dom))
.then(() => row.data(dataKey, dom.text()));
}))
.then(() => {
return sortByDataKey(model, dataKey, desc);
});
};
/**
* Only exists for switchboard purposes - actual logic is in $doHandleRowEvent.
* @private
* @returns {Promise}
*/
Table.prototype.$handleRowEvent = function () {
return this.$doHandleRowEvent.apply(this, arguments);
};
/**
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
* @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>} rows
* @param {string} eventName
* @param {Array.<*>} args
* @returns {Promise}
*/
Table.prototype.$doHandleRowEvent = function (tableModel, rows, eventName, args) {
switch (eventName) {
case 'rowsAdded':
const [ index ] = args;
return this.$insertRows(tableModel, rows, index);
case 'rowsRemoved':
const [ rowIndices ] = args;
const trIndices = rowIndices.map((i) => this.$rowIndexToTrIndex(i));
return this.$removeRows(tableModel, trIndices)
.then(() => this.$getSelection().remove(rowIndices));
case 'rowsReordered':
return this.$rebuildTbody(tableModel);
case 'rowsChanged':
return this.$rebuildRowContents(tableModel, rows);
case 'rowsFiltered':
return this.$rebuildTbody(tableModel);
}
};
/**
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
* @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} columns
* @param {string} eventName
* @returns {Promise}
*/
Table.prototype.$handleColumnEvent = function (tableModel, columns, eventName) {
switch (eventName) {
case 'columnsAdded':
case 'columnsRemoved':
return this.$rebuild(tableModel);
case 'columnsFlagsChanged':
if (this.$isHideUnseen()) {
const toUpdate = this.$getTbody(tableModel).add(this.$getThead());
columns.forEach(function (c) {
toUpdate.find('.' + $.escapeSelector(toCssClass(c))).toggle(!c.isUnseen());
});
this.$updateLastVisibleColumn(tableModel);
}
}
return Promise.resolve();
};
/**
* When the TableModel's `sortColumns` data changes, rebuild the table header
* to reflect the sort directions, and sort the data.
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
* @param {string} key
* @returns {Promise}
*/
Table.prototype.$handleDataChangedEvent = function (tableModel, key) {
if (key !== 'sortColumns') { return Promise.resolve(); }
const columnName = tableModel.$getSortColumn();
const sortDirection = tableModel.$getSortDirection(columnName);
const column = tableModel.getColumn(columnName);
this.$rebuildThead(tableModel);
return Promise.resolve(this.sort(column, sortDirection === 'desc'))
.then(() => this.emitAndWait('sorted', columnName, sortDirection));
};
/**
* Initialize the HTML table, creating `thead`, `tbody`, and `tfoot` elements.
*
* @param {JQuery} dom
* @param {Object} params optional initialization parameters
*/
Table.prototype.doInitialize = function (dom, params) {
const that = this;
params = params || {};
dom.addClass('TableWidget')
.toggleClass('fixedHeaders', that.$isFixedHeaders());
preventSelectOnShiftClick(dom);
const selection = that.$getSelection();
// Apply density (only if the property is set)
that.$applyDensity();
// Allow users of the table to hook into the default cell building/destruction
// process for a table, via function parameters that can be specified in fe.buildFor().
// This is intended to allow clients such as the Manager view to have a bit more
// control over how the dom content is generated.
if (params.buildCell) { this.$buildCell = params.buildCell; }
if (params.destroyCell) { this.$destroyCell = params.destroyCell; }
if (params.finishBuildingRow) { this.$finishBuildingRow = params.finishBuildingRow; }
selection.on('changed', function () {
that.$getTbody().children('tr').each(function (i) {
const rowIndex = that.$trIndexToRowIndex(i);
$(this).toggleClass('selected', selection.isSelected(rowIndex));
});
that.trigger(ROW_SELECTION_CHANGED_EVENT);
});
function armHeaderHandlers() {
dom.on('click', 'thead > th', function () {
const th = $(this).closest('th');
const column = th.data('column');
if (column.isSortable()) {
Promise.resolve(that.getModel().$toggleSortDirection(column.getName()))
.catch(logError);
}
});
}
function armRowHandlers(selector) {
//this must be click and not mousedown, because if it were mousedown then
//starting a drag would wreck your current selection.
dom.on('click', selector, function (e) {
if ($(e.target).is('button')) {
return false;
}
selection.defaultHandler.apply(this, arguments);
});
dom.on('contextmenu', selector, function (e) {
if ($(e.target).is('button') || e.which !== 3) {
return;
}
selection.defaultHandler.apply(this, arguments);
});
dom.on('dragstart', selector, function (e) {
const i = $(e.currentTarget).index();
if (!selection.isSelected(i)) {
selection.select(i);
}
});
dom.on('dblclick', selector, function (e) {
const rowIndex = $(e.currentTarget).index();
const columnIndex = $(e.target).closest('td').index();
const row = that.getModel().getRow(rowIndex);
if (row) {
let columns = that.getModel().getColumns();
if (that.$isHideUnseen()) {
columns = columns.filter((c) => !c.isUnseen());
}
that.trigger(Table.CELL_ACTIVATED_EVENT, row, columns[columnIndex]);
}
});
}
switch (dom.prop('tagName').toLowerCase()) {
case 'table':
dom.html(TABLE_CONTENTS_HTML);
armHeaderHandlers();
armRowHandlers('tbody > tr');
dom.toggleClass('no-stripe', !that.$isEnableStriping());
break;
case 'tbody':
armRowHandlers('tr');
break;
default:
dom.html('<div class="tableContainer">' +
'<table class="ux-table">' +
TABLE_CONTENTS_HTML +
'</table>' +
'</div>');
getTable(dom).toggleClass('no-stripe', !that.$isEnableStriping());
applyFixedHeaderScrolling(that, dom);
// click, not mousedown, because mousedown triggers when scrolling tableContainer via the
// scroll bar.
dom.on('click', '.tableContainer', function (e) {
const table = getTable(dom)[0];
if (!$.contains(table, e.target)) {
selection.clear();
}
});
armHeaderHandlers();
armRowHandlers('tbody > tr');
}
};
/**
* Get the currently loaded `TableModel`.
* @returns {module:nmodule/webEditors/rc/wb/table/model/TableModel}
* @since Niagara 4.6
*/
Table.prototype.getModel = function () {
return this.$tableModel;
};
/**
* Load in a `TableModel`, immediately rendering all columns and rows. Event
* handlers will be registered to listen for updates to the table model.
*
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} model
* @returns {Promise}
* @throws {Error} if no TableModel provided
*/
Table.prototype.doLoad = function (model) {
if (model instanceof PaginationModel) {
return this.$initializePagination(model);
} else if (model instanceof TableModel) {
this.$initializeModel(model);
return this.$rebuild(model);
} else {
throw new Error('TableModel or PaginationModel required');
}
};
/**
* Remove `TableWidget` class and event handlers from the loaded table model.
*/
Table.prototype.doDestroy = function () {
const jq = this.jq();
const model = this.getModel();
getTableContainer(jq).off('scroll');
this.$disarmModel(this.value());
this.$getSelection().removeAllListeners();
this.$clearDensity();
const tbody = this.$getTbody();
return Promise.resolve(model && this.$destroyRows(model.getColumns(), tbody.children('tr')))
.then(() => {
tbody.empty();
jq.removeClass('TableWidget fixedHeaders');
return this.getChildWidgets().destroyAll();
});
};
/**
* Arm event handlers on the loaded TableModel.
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} model
*/
Table.prototype.$initializeModel = function (model) {
this.$disarmModel();
this.$tableModel = model;
const handleRowsEvent = (rows, eventName, args) => {
return this.$handleRowEvent(model, rows, eventName, args).catch(logError);
};
const handleColumnsEvent = (columns, eventName) => {
return this.$handleColumnEvent(model, columns, eventName).catch(logError);
};
const handleDataChangedEvent = (key, event, value) => {
return this.$handleDataChangedEvent(model, key, value[0]).catch(logError);
};
//TODO: make these instance methods and protect w/ switchboard
const handlers = this.$handlers = {};
each({
rowsAdded: handleRowsEvent,
rowsChanged: handleRowsEvent,
rowsRemoved: handleRowsEvent,
rowsReordered: handleRowsEvent,
rowsFiltered: handleRowsEvent,
columnsAdded: handleColumnsEvent,
columnsRemoved: handleColumnsEvent,
columnsFlagsChanged: handleColumnsEvent,
dataChanged: handleDataChangedEvent
}, function (handler, eventName) {
const f = function (rowsOrColumns) {
const args = toArray(arguments).slice(1);
return handler(rowsOrColumns, eventName, args);
};
model.on(eventName, f);
handlers[eventName] = f;
});
};
/**
* Remove all listeners from previous TableModel before arming the new one.
* @private
*/
Table.prototype.$disarmModel = function () {
const handlers = this.$handlers;
const model = this.$tableModel;
//Remove all the model listeners
if (handlers && model) {
each(handlers, (handler, event) => model.removeListener(event, handler));
}
};
/**
* Arm event handlers on the loaded PaginationModel.
* @param {module:nmodule/webEditors/rc/wb/table/pagination/PaginationModel} model
* @returns {Promise}
*/
Table.prototype.$initializePagination = function (model) {
this.$disarmPagination();
this.$paginationModel = model;
const handler = this.$paginationChanged = (key) => {
if (key === 'currentPage' || key === 'config') {
return resolveCurrentPage().catch(logError);
}
};
/** @returns {Promise} */
const resolveCurrentPage = () => this.$resolveCurrentPage();
model.on('changed', handler);
return resolveCurrentPage();
};
/**
* @private
* @returns {Promise}
*/
Table.prototype.$resolveCurrentPage = function () {
const model = this.$paginationModel;
return model.resolvePage(model.getCurrentPage())
.then((tableModel) => this.doLoad(tableModel));
};
/**
* Remove all listeners from previous TableModel before arming the new one.
* @private
*/
Table.prototype.$disarmPagination = function () {
const model = this.$paginationModel;
if (model) {
model.removeListener('changed', this.$paginationChanged);
}
};
/**
* When showing a context menu, will decide which values in the TableModel are
* the targets of the right-click operation.
*
* If the row being right-clicked is not already selected, then the subject of
* the corresponding `Row` will be used to show the context menu.
*
* If the row being right-clicked is already selected, then the subjects of
* *all* selected `Row`s will be used.
*
* @param {JQuery} elem
* @returns {Array.<*>} array containing the subjects of the rows being
* right-clicked. Can return an empty array if no rows are present.
*/
Table.prototype.getSubject = function (elem) {
const model = this.getModel();
if (!model) {
return [];
}
const index = elem.closest('tr').index();
const selection = this.$getSelection();
const rows = model.getRows();
if (selection.isSelected(index)) {
return invoke(selection.getSelectedElements(rows), 'getSubject');
}
const row = rows[index];
return row ? [ row.getSubject() ] : [];
};
/**
* Get all rows which are currently selected by the user.
* @returns {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>}
* @since Niagara 4.6
*/
Table.prototype.getSelectedRows = function () {
return this.$getSelection().getSelectedElements(this.getModel().$getRowsUnsafe());
};
/**
* Resolve display HTML for the given column and row. By default,
* will simply proxy through to `Column#buildCell`.
*
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/Column} column
* @param {module:nmodule/webEditors/rc/wb/table/model/Row} row
* @param {JQuery} dom the td element for the cell
* @returns {Promise}
*/
Table.prototype.buildCell = function (column, row, dom) {
return Promise.resolve(this.$buildCell ? this.$buildCell(column, row, dom)
: column.buildCell(row, dom));
};
/**
* Complementary function to `#buildCell`. This will give the model a chance
* to clean up any resources allocated when creating the cell's HTML, such as
* unhooking event handlers. As with cell construction, this will default to calling through
* to `Column#destroyCell`.
*
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/Column} column
* @param {module:nmodule/webEditors/rc/wb/table/model/Row} row
* @param {JQuery} dom the td element for the cell
* @returns {Promise}
*/
Table.prototype.destroyCell = function (column, row, dom) {
return this.$destroyCell ? this.$destroyCell(column, row, dom)
: column.destroyCell(row, dom);
};
/**
* Called when a row and its cells have been constructed. This will
* allow any final dom customizations on the row with all its cells
* constructed, before it is inserted into the table. By default, this
* will not modify the dom.
*
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/Column} row
* @param {JQuery} dom the tr element constructed for the given `Row`.
* @returns {Promise.<JQuery>}
*/
Table.prototype.finishBuildingRow = function (row, dom) {
return this.$finishBuildingRow ? this.$finishBuildingRow(row, dom)
: Promise.resolve(dom);
};
/**
* @returns {Promise.<Array.<module:nmodule/export/rc/TransformOperation>>}
*/
Table.prototype.getTransformOperations = function () {
return asyncUtils.doRequire('nmodule/webEditors/rc/transform/TableTransformOperationProvider')
.then((TableTransformOperationProvider) => {
return new TableTransformOperationProvider().getTransformOperations(this);
});
};
/**
* Detect density property change and apply it to the table
* @see {module:nmodule/webEditors/rc/wb/table/Table} for valid densities
*/
Table.prototype.doChanged = function (name, value) {
if (name === 'density') {
this.$applyDensity();
}
};
/**
* Applies density property to the table.
*/
Table.prototype.$applyDensity = function () {
const density = getValidDensity(this.$getDensity());
// Remove classes
this.$clearDensity();
this.jq().addClass(getDensityClass(density));
};
/**
* Clear density related classes from the table
*
* @private
*/
Table.prototype.$clearDensity = function () {
this.jq().removeClass('ux-table-density-low ux-table-density-medium ux-table-density-high');
};
/**
* Create the `td` element for the intersection of the given column and row.
*
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/Column} column
* @returns {HTMLTableCellElement}
*/
Table.prototype.$makeCellElement = function (column) {
const td = document.createElement('td');
td.className = toCssClass(column);
if (this.$isHideUnseen() && column.isUnseen()) {
td.style.display = 'none';
}
return td;
};
/**
* Create the `tr` element to hold the cells in the given row.
*
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel the table model
* containing the Row we're making a `tr` for
* @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} columns the columns in the
* table model (exactly the same as tableModel.getColumns(), but passed as a separate parameter
* to avoid re-filtering/re-slice()ing once for every individual row)
* @param {module:nmodule/webEditors/rc/wb/table/model/Row} row the row we're building a tr for
* @param {boolean} selected
* @returns {Promise.<JQuery>}
*/
Table.prototype.$toTableRow = function (tableModel, columns, row, selected) {
return Promise.all(columns.map((column) => {
const td = this.$makeCellElement(column);
return Promise.resolve(this.buildCell(column, row, $(td)))
.then(() => td);
}))
.then((tds) => {
const tr = document.createElement('tr');
tr.className = 'ux-table-row';
tr.append(...tds);
if (selected) { tr.classList.add('selected'); }
const $tr = $(tr).data('row', row);
return this.finishBuildingRow(row, $tr);
});
};
/**
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
* @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>} addedRows
* @param {number} index
* @returns {Promise}
*/
Table.prototype.$insertRows = function (tableModel, addedRows, index) {
const tbody = this.$getTbody(tableModel);
const columns = tableModel.getColumns();
const selection = this.$getSelection();
return Promise.all(addedRows.map((row) => this.$toTableRow(tableModel, columns, row, false)))
.then((trs) => {
if (index === 0) {
tbody.prepend(trs);
} else {
const prevIndex = this.$rowIndexToTrIndex(index - 1);
const prevRow = tbody.children()[prevIndex];
if (prevRow) {
$(prevRow).after(trs);
}
}
selection.insert(index, addedRows.length);
});
};
/**
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
* @param {Array.<number>} trIndices
* @returns {Promise}
*/
Table.prototype.$removeRows = function (tableModel, trIndices) {
const tbody = this.$getTbody(tableModel);
const kids = tbody.children();
const trs = $(trIndices.map((i) => kids[i]).filter((tr) => tr));
return this.$destroyRows(tableModel.getColumns(), trs)
.then(() => each(trs, removeIt));
};
/**
* Rebuild the whole table header and body to reflect the new TableModel.
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
* @returns {Promise}
*/
Table.prototype.$rebuild = function (tableModel) {
return Promise.all([ this.$rebuildThead(tableModel), this.$rebuildTbody(tableModel) ]);
};
/**
* Modify the last visible column css to span through the screen.
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
*/
Table.prototype.$updateLastVisibleColumn = function (tableModel) {
const columns = tableModel.getColumns();
let lastVisibleIndex = 0;
columns.forEach((c, index) => {
if (!c.isUnseen()) {
lastVisibleIndex = index;
}
});
const ths = this.$getThead();
const isTableLeftJustified = this.$isTableLeftJustified();
ths.find('th').each((i, el) => $(el).toggleClass('-t-Table-last-visible-column', ($(el).index() === lastVisibleIndex && isTableLeftJustified)));
};
/**
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
* @returns {Promise}
*/
Table.prototype.$rebuildThead = function (tableModel) {
const thead = this.$getThead();
const isFixedHeaders = this.$isFixedHeaders();
const isHideUnseen = this.$isHideUnseen();
const columns = tableModel.getColumns();
const sortColumn = tableModel.$getSortColumn();
let lastVisibleColumnIndex = 0;
return Promise.all(columns.map((column, index) => {
return Promise.resolve(column.toDisplayName())
.then((displayName) => {
const th = $('<th/>')
.text(displayName)
.data('column', column)
.toggle(!isHideUnseen || !column.isUnseen())
.toggleClass('sortable', column.isSortable())
.addClass(toCssClass(column));
if (!column.isUnseen()) {
lastVisibleColumnIndex = index;
}
if (column.getName() === sortColumn) {
const sortDirection = tableModel.$getSortDirection(column.getName());
if (sortDirection) { th.addClass(sortDirection); }
}
if (isFixedHeaders) {
$('<div class="headerDuplicate ux-table-head-cell" aria-hidden="true"></div>')
.text(displayName)
.appendTo(th);
}
return th;
});
}))
.then((ths) => {
thead.html(ths);
ths[lastVisibleColumnIndex] && ths[lastVisibleColumnIndex].toggleClass('-t-Table-last-visible-column', this.$isTableLeftJustified());
return this.emit('theadUpdated', thead);
});
};
/**
* @private
* @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
* @returns {Promise}
*/
Table.prototype.$rebuildTbody = function (tableModel) {
//TODO: be smart about inserts/removes
const tbody = this.$getTbody(tableModel);
const selection = this.$getSelection();
const columns = tableModel.getColumns();
return Promise.all([
Promise.all(tableModel.getRows().map((row, i) => {
return this.$toTableRow(tableModel, columns, row, selection.isSelected(i));
})),
this.$destroyRows(columns, tbody.children('tr'))
])
.then(([ rows ]) => {
tbody.html(rows);
return this.emit('tbodyUpdated', tbody);
});
};
/**
* Clear the contents of the cells in the specified rows. The cell elements themselves will remain.
*
* @private
* @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} columns
* @param {JQuery} rowsToDestroy
* @returns {Promise}
*/
Table.prototype.$destroyRows = function (columns, rowsToDestroy) {
return Promise.all(mapDom(rowsToDestroy, (tr) => {
const $tr = $(tr);
const row = $tr.data('row');
$tr.removeData('row');
return this.$destroyRowCells(columns, row, $tr);
}));
};
/**
* Clear the contents of the cells in a single specified row. The cell elements themselves will
* remain.
*
* @private
* @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Column>} columns
* @param {module:nmodule/webEditors/rc/wb/table/model/Row} row
* @param {JQuery} tr
* @returns {Promise}
*/
Table.prototype.$destroyRowCells = function (columns, row, tr) {
return Promise.all(mapDom(tr.children('td'), (td) => {
const $td = $(td);
const col = columns[td.cellIndex];
return Promise.resolve(col && this.destroyCell(col, row, $td))
.then(() => $td.empty());
}));
};
return Table;
});