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/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,
        BaseWidget,
        htmlUtils,
        ListSelection,
        TableModel,
        PaginationModel) {

  'use strict';

  var preventSelectOnShiftClick = htmlUtils.preventSelectOnShiftClick;

  var logError = tableLog.severe.bind(tableLog);

  var TABLE_HTML = '<thead class="ux-table-head"></thead>' +
      '<tbody></tbody>' +
      '<tfoot class="ux-table-foot"></tfoot>',
    QUEUE_UP = { allow: 'oneAtATime', onRepeat: 'queue' };

  var DENSITY_LOW = "low",
    DENSITY_MEDIUM = "medium",
    DENSITY_HIGH = "high",
    VALID_DENSITIES = [ DENSITY_LOW, DENSITY_MEDIUM, DENSITY_HIGH ];

  function widgetDefaults() {
    return {
      properties: {
        density: { value: DENSITY_MEDIUM, typeSpec: 'webEditors:ContentDensity' }
      }
    };
  }

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

  function toCssClass(column) {
    return 'js-col-' + column.getName().replace(' ', '_');
  }

  function toCell(table, column, row) {
    const td = document.createElement('td');
    td.className = toCssClass(column);

    if (table.$isHideUnseen() && column.isUnseen()) {
      td.style.display = 'none';
    }

    return Promise.resolve(table.buildCell(column, row, $(td)))
      .then(function () {
        return td;
      });
  }

  function toTableRow(table, row, columns, selected) {
    return Promise.all(columns.map((column) => toCell(table, column, row)))
      .then((tds) => {
        const tr = document.createElement('tr');
        tr.className = 'ux-table-row';
        if (selected) { tr.classList.add('selected'); }
        tr.append(...tds);
        return $(tr)
          .data('columns', columns.slice())
          .data('row', row);
      })
      .then(function (tr) {
        return table.finishBuildingRow(row, tr);
      });
  }

  /**
   * @param {module:nmodule/webEditors/rc/wb/table/Table} table
   * @param {JQuery} tbody
   * @returns {Promise}
   */
  function destroyCells(table, tbody) {
    var columns = tbody.data('columns');
    tbody.removeData('columns');

    function mapDom(elem, fun) {
      return elem.map(fun).get();
    }

    return Promise.all(mapDom(tbody.find("tr"), function () {
      var $tr = $(this),
          row = $tr.data('row');

      $tr.removeData('row');
      return mapDom($tr.find("td"), function () {
        var $td = $(this),
            col = columns[this.cellIndex];

        return Promise.resolve(table.destroyCell(col, row, $td));
      });
    }))
    .then(function () {
      tbody.html('');
    });
  }

  /**
   * @param {module:nmodule/webEditors/rc/wb/table/Table} table
   * @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
   * @returns {Promise}
   */
  function updateTbody(table, tableModel) {
    //TODO: be smart about inserts/removes

    var tbody = table.$getTbody(tableModel),
      selection = table.$getSelection(),
      rows = tableModel.getRows(),
      columns = tableModel.getColumns();

    return destroyCells(table, tbody)
      .then(function () {
        return Promise.all(rows.map(function (row, i) {
          return toTableRow(table, row, columns, selection.isSelected(i));
        }));
      })
      .then(function (rows) {
        tbody.data('columns', columns.slice()).html(rows);
        table.emit('tbodyUpdated', tbody);
      });
  }

  /**
   * @param {module:nmodule/webEditors/rc/wb/table/Table} table
   * @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}
   */
  function addRows(table, tableModel, addedRows, index) {
    var tbody = table.$getTbody(tableModel),
      columns = tableModel.getColumns(),
      selection = table.$getSelection();

    return Promise.all(addedRows.map(function (row) {
      return toTableRow(table, row, columns, false);
    }))
      .then(function (trs) {
        if (index === 0) {
          tbody.prepend(trs);
        } else {
          tbody.children(':eq(' + (index - 1) + ')').after(trs);
        }
        selection.insert(index, addedRows.length);
      });
  }

  function removeIt(elem) {
    elem.remove();
  }

  /**
   * @param {module:nmodule/webEditors/rc/wb/table/Table} table
   * @param {module:nmodule/webEditors/rc/wb/table/model/TableModel} tableModel
   * @param {Array.<module:nmodule/webEditors/rc/wb/table/model/Row>} removedRows
   * @param {Array.<number>} indices
   * @returns {Promise}
   */
  function removeRows(table, tableModel, removedRows, indices) {
    var tbody = table.$getTbody(tableModel),
      selection = table.$getSelection(),
      kids = tbody.children(),
      trs = $(indices.map(function (i) {
        return $(kids[i]);
      }));

    return table.getChildWidgets(trs).destroyAll()
      .then(function () {
        _.each(trs, removeIt);
        selection.remove(indices);
      });
  }

  function updateThead(table, tableModel) {
    var thead = table.$getThead(),
      isFixedHeaders = table.$isFixedHeaders(),
      isHideUnseen = table.$isHideUnseen(),
      columns = tableModel.getColumns(),
      sortColumn = tableModel.$getSortColumn();

    return Promise.all(columns.map(function (column) {
      return Promise.resolve(column.toDisplayName())
        .then(function (displayName) {
          var th = $('<th/>')
            .text(displayName)
            .data('column', column)
            .toggle(!isHideUnseen || !column.isUnseen())
            .toggleClass('sortable', column.isSortable())
            .addClass(toCssClass(column));

          if (column.getName() === sortColumn) {
            var 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(function (ths) {
        thead.html(ths);
        table.emit('theadUpdated', thead);
      });
  }

  //TODO: MgrColumn#toSortKey equivalent
  function sortByDataKey(model, dataKey, desc) {
    var lessThan = desc ? 1 : -1,
      greaterThan = desc ? -1 : 1;

    model.sort(function (row1, row2) {
      var display1 = row1.data(dataKey),
        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. But
   * in Firefox it somehow doubles up on the margin so the headers go sailing
   * off the left side of the table. But you can't just halve the margin - it's
   * correctly positioned without the margin. But you ALSO can't just set the
   * margin to 0 because then it doesn't scroll! So the solution that Firefox
   * likes is apply the margin, force a reflow, then remove the margin, and
   * Firefox then snaps the header back to its correct position of its own
   * accord.
   *
   * TODO: reproduction steps and Firefox bug.
   *
   * @param {module:nmodule/webEditors/rc/wb/table/Table} table
   * @param {JQuery} dom
   */
  function applyFixedHeaderScrolling(table, dom) {
    // i tried to do this without browser sniffing by detecting when the headers
    // got out of whack. swear i did.
    var isFirefox = navigator.userAgent.toLowerCase().match('firefox');
    var onScroll = function onScroll() {
      var headerDups = table.$getThead().find('.headerDuplicate'),
        scrollLeft = this.scrollLeft;
      headerDups.css('marginLeft', -scrollLeft);

      if (isFirefox) {
        headerDups.offset(); //force reflow
        headerDups.css('marginLeft', '0.1px');
      }
    };

    dom.children('.tableContainer').on('scroll', onScroll);
  }

  /**
   * Applies density property to the table
   *
   * @param {module:nmodule/webEditors/rc/wb/table/Table} table
   */
  function applyDensity(table) {
    var density = getValidDensity(table.$getDensity());

    // Remove classes
    table.$clearDensity();

    switch (density.toLowerCase()) {
      case DENSITY_LOW:
        table.$getTableJq().addClass('ux-table-density-low');
        break;
      case DENSITY_MEDIUM:
        table.$getTableJq().addClass('ux-table-density-medium');
        break;
      case DENSITY_HIGH:
        table.$getTableJq().addClass('ux-table-density-high');
        break;
      default:
        table.$getTableJq().addClass('ux-table-density-medium');
    }
  }

  function getValidDensity(density) {
    if (typeof density !== 'string') {
      return DENSITY_LOW;
    }
    var validDensity = _.find(VALID_DENSITIES, function (d) {
      return 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.
   * - `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
   *
   * @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.
   */
  var Table = function Table(params) {
    var that = this;

    BaseWidget.call(that, {
      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' }
    });
  };
  Table.prototype = Object.create(BaseWidget.prototype);
  Table.prototype.constructor = Table;

  Table.prototype.$isFixedHeaders = function () {
    var tagName = this.jq().prop('tagName').toLowerCase();
    var isFixedHeaders = this.properties().getValue('fixedHeaders');
    if (isFixedHeaders && (tagName === 'table' || tagName === 'tbody')) {
      throw new Error("Can only initialize inside a div if fixed headers is true.");
    }
    return tagName !== 'table' && tagName !== 'tbody' &&
      isFixedHeaders !== false;
  };

  /**
   * 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) {
    var 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 () {
    var dom = this.jq();
    switch (dom.prop('tagName').toLowerCase()) {
      case 'table':
        return dom.children('thead');
      case 'tbody':
        return $();
      default:
        return dom.children('.tableContainer').children('table').children('thead');
    }
  };

  /**
   * Return the content selector to apply density
   *
   * @private
   * @returns {JQuery}
   */
  Table.prototype.$getTableJq = function () {
    return 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.$rebuildTrs = function (tableModel, rows) {
    const selection = this.$getSelection();

    const trs = this.$getTbody(tableModel).children('tr');
    const indices = [];

    return Promise.all(rows.map((row, i) => {
      const index = indices[i] = tableModel.getRowIndex(row);
      const selected = selection.isSelected(index);

      return toTableRow(this, row, tableModel.getColumns(), selected);
    }))
      .then((newTrs) => {
        for (let i = 0, len = indices.length; i < len; ++i) {
          const oldTr = trs[indices[i]];
          $(oldTr).replaceWith(newTrs[i]);
        }
      });
  };

  /**
   * 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) {
    var that = this,
      dataKey = 'displayString.' + column.getName(),
      model = that.getModel(),
      rows = model.getRows();

    return Promise.all(rows.map(function (row) {
      var dom = $('<div/>');
      return Promise.resolve(that.buildCell(column, row, dom))
        .then(function () {
          row.data(dataKey, dom.text());
        });
    }))
      .then(function () {
        sortByDataKey(model, dataKey, desc);
        return null; //squelch "promise not returned" warnings from row event handlers
      });
  };

  /**
   * @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.$handleRowEvent = function (tableModel, rows, eventName, args) {
    switch (eventName) {
      case 'rowsAdded':
        return addRows(this, tableModel, rows, /* index */ args[0]);
      case 'rowsRemoved':
        return removeRows(this, tableModel, rows, /* indices */ args[0]);
      case 'rowsReordered':
        return updateTbody(this, tableModel);
      case 'rowsChanged':
        return this.$rebuildTrs(tableModel, rows);
      case 'rowsFiltered':
        return updateTbody(this, 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 Promise.all([
          updateThead(this, tableModel),
          updateTbody(this, tableModel)
        ]);
      case 'columnsFlagsChanged':
        if (this.$isHideUnseen()) {
          var toUpdate = this.$getTbody(tableModel).add(this.$getThead());
          columns.forEach(function (c) {
            toUpdate.find('.' + $.escapeSelector(toCssClass(c))).toggle(!c.isUnseen());
          });
        }
    }
    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(); }

    var that = this,
      columnName = tableModel.$getSortColumn(),
      sortDirection = tableModel.$getSortDirection(columnName),
      column = tableModel.getColumn(columnName);

    updateThead(that, tableModel);

    return Promise.resolve(that.sort(column, sortDirection === 'desc'))
      .then(function () {
        return Promise.all(that.emit('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) {
    var that = this;

    params = params || {};

    dom.addClass('TableWidget')
      .toggleClass('fixedHeaders', that.$isFixedHeaders());

    preventSelectOnShiftClick(dom);

    var selection = that.$getSelection();

    // Apply density (only if the property is set)
    applyDensity(that);

    // 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) {
        $(this).toggleClass('selected', selection.isSelected(i));
      });
    });

    function armHeaderHandlers() {
      dom.on('click', 'thead > th', function () {
        var th = $(this).closest('th'),
            column = th.data('column');

        if (column.isSortable()) {
          return that.getModel().$toggleSortDirection(column.getName());
        }
      });
    }

    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) {
        var i = $(e.currentTarget).index();
        if (!selection.isSelected(i)) {
          selection.select(i);
        }
      });
    }

    switch (dom.prop('tagName').toLowerCase()) {
      case 'table':
        dom.html(TABLE_HTML);
        armHeaderHandlers();
        armRowHandlers('tbody > tr');
        break;
      case 'tbody':
        armRowHandlers('tr');
        break;
      default:
        dom.html('<div class="tableContainer">' +
          '<table class="ux-table">' +
            TABLE_HTML +
          '</table>' +
          '</div>');

        applyFixedHeaderScrolling(that, dom);

        dom.on('mousedown', '.tableContainer', function (e) {
          var table = dom.children('.tableContainer').children('table')[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) {
    var that = this;

    if (model instanceof PaginationModel) {
      return that.$initializePagination(model);
    } else if (model instanceof TableModel) {
      that.$initializeModel(model);
      return Promise.all([
        updateThead(that, model),
        updateTbody(that, model)
      ]);
    } else {
      throw new Error('TableModel or PaginationModel required');
    }

  };

  /**
   * Remove `TableWidget` class and event handlers from the loaded table model.
   */
  Table.prototype.doDestroy = function () {
    var that = this,
        jq = that.jq();

    jq.children('.tableContainer').off('scroll');

    that.$disarmModel(this.value());

    this.$getSelection().removeAllListeners();

    this.$clearDensity();

    return destroyCells(that, that.$getTbody())
      .then(function () {
        jq.removeClass('TableWidget fixedHeaders');
        return that.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) {
    var that = this;
    that.$disarmModel();
    that.$tableModel = model;

    function handleRowsEvent(rows, eventName, args) {
      that.$handleRowEvent(model, rows, eventName, args).catch(logError);
    }

    function handleColumnsEvent(columns, eventName) {
      that.$handleColumnEvent(model, columns, eventName).catch(logError);
    }

    function handleDataChangedEvent(key, event, value) {
      that.$handleDataChangedEvent(model, key, value[0]).catch(logError);
    }

    //TODO: make these instance methods and protect w/ switchboard
    var handlers = that.$handlers = {};
    _.each({
      rowsAdded: handleRowsEvent,
      rowsChanged: handleRowsEvent,
      rowsRemoved: handleRowsEvent,
      rowsReordered: handleRowsEvent,
      rowsFiltered: handleRowsEvent,
      columnsAdded: handleColumnsEvent,
      columnsRemoved: handleColumnsEvent,
      columnsFlagsChanged: handleColumnsEvent,
      dataChanged: handleDataChangedEvent
    }, function (handler, eventName) {
      var f = function (rowsOrColumns) {
        var args = _.toArray(arguments).slice(1);
        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 () {
    var handlers = this.$handlers,
      model = this.$tableModel;

    //Remove all the model listeners
    if (handlers && model) {
      _.each(handlers, function (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) {
    var that = this;
    that.$disarmPagination();
    that.$paginationModel = model;

    var handler = that.$paginationChanged = function (key) {
      if (key === 'currentPage' || key === 'config') {
        return resolveCurrentPage().catch(logError);
      }
    };

    /** @returns {Promise} */
    function resolveCurrentPage() { return that.$resolveCurrentPage(); }

    model.on('changed', handler);

    return resolveCurrentPage();
  };

  /**
   * @private
   * @returns {Promise}
   */
  Table.prototype.$resolveCurrentPage = function () {
    var that = this,
      model = that.$paginationModel;
    return model.resolvePage(model.getCurrentPage())
      .then(function (tableModel) {
        return that.doLoad(tableModel);
      });
  };

  /**
   * Remove all listeners from previous TableModel before arming the new one.
   * @private
   */

  Table.prototype.$disarmPagination = function () {
    var 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) {
    var model = this.getModel();

    if (!model) {
      return [];
    }

    var index = elem.closest('tr').index(),
      selection = this.$getSelection(),
      rows = model.getRows();

    if (selection.isSelected(index)) {
      return _.invoke(selection.getSelectedElements(rows), 'getSubject');
    }

    var 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().getRows());
  };

  /**
   * 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 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}
   */
  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 () {
    var that = this;
    return asyncUtils.doRequire('nmodule/webEditors/rc/transform/TableTransformOperationProvider')
      .then(function (TableTransformOperationProvider) {
        return new TableTransformOperationProvider().getTransformOperations(that);
      });
  };

  /**
   * 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') {
      return;
    }
    applyDensity(this);
  };

  /**
   * Clear density related classes from the table
   *
   * @private
   */
  Table.prototype.$clearDensity = function () {
    this.$getTableJq().removeClass('ux-table-density-low');
    this.$getTableJq().removeClass('ux-table-density-medium');
    this.$getTableJq().removeClass('ux-table-density-high');
  };

  return Table;
});