wb/table/Table.js

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