offirgolan/ember-light-table

View on GitHub
addon/components/lt-body.js

Summary

Maintainability
B
5 hrs
Test Coverage
import Component from '@ember/component';
import { action, computed } from '@ember/object';
import { readOnly } from '@ember/object/computed';
import { cancel, debounce, once, schedule, scheduleOnce } from '@ember/runloop';
import Row from 'ember-light-table/classes/Row';

/**
 * @module Light Table
 */

/**
 * ```hbs
 * <LightTable @table={{this.table}} as |t| >
 *   <t.body @multiSelect={{true}} @onRowClick={{this.rowClicked}} as |body| >
 *     <body.ExpandedRow as |row| >
 *       Hello <b>{{row.firstName}}</b>
 *     </body.ExpandedRow>
 *
 *     {{#if this.isLoading}}
 *       {{#body.loader}}
 *         Loading...
 *       {{/body.loader}}
 *     {{/if}}
 *
 *     {{#if this.table.isEmpty}}
 *       {{#body.no-data}}
 *         No users found.
 *       {{/body.no-data}}
 *     {{/if}}
 *   </t.body>
 * </LightTable>
 * ```
 *
 * @class t.body
 */

export default Component.extend({
  tagName: '',

  /**
   * @property table
   * @type {Table}
   * @private
   */
  table: null,

  /**
   * @property sharedOptions
   * @type {Object}
   * @private
   */
  sharedOptions: null,

  /**
   * @property tableActions
   * @type {Object}
   */
  tableActions: null,

  /**
   * @property extra
   * @type {Object}
   */
  extra: null,

  /**
   * @property isInViewport
   * @default false
   * @type {Boolean}
   */
  isInViewport: false,

  /**
   * Allows a user to select a row on click. All this will do is apply the necessary
   * CSS classes and add the row to `table.selectedRows`. If `multiSelect` is disabled
   * only one row will be selected at a time.
   *
   * @property canSelect
   * @type {Boolean}
   * @default true
   */
  canSelect: true,

  /**
   * Select a row on click. If this is set to `false` and multiSelect is
   * enabled, using click + `shift`, `cmd`, or `ctrl` will still work as
   * intended, while clicking on the row will not set the row as selected.
   *
   * @property selectOnClick
   * @type {Boolean}
   * @default true
   */
  selectOnClick: true,

  /**
   * Allows for expanding row. This will create a new row under the row that was
   * clicked with the template provided by `body.expanded-row`.
   *
   * ```hbs
   * <Body.expandedRow as |row| >
   *  This is the content of the expanded row for {{row.firstName}}
   * </Body.expandedRow>
   * ```
   *
   * @property canExpand
   * @type {Boolean}
   * @default false
   */
  canExpand: false,

  /**
   * Allows a user to select multiple rows with the `ctrl`, `cmd`, and `shift` keys.
   * These rows can be easily accessed via `table.get('selectedRows')`
   *
   * @property multiSelect
   * @type {Boolean}
   * @default false
   */
  multiSelect: false,

  /**
   * When multiSelect is true, this property determines whether or not `ctrl`
   * (or `cmd`) is required to select additional rows, one by one. When false,
   * simply clicking on subsequent rows will select or deselect them.
   *
   * `shift` to select many consecutive rows is unaffected by this property.
   *
   * @property multiSelectRequiresKeyboard
   * @type {Boolean}
   * @default true
   */
  multiSelectRequiresKeyboard: true,

  /**
   * Hide scrollbar when not scrolling
   *
   * @property autoHideScrollbar
   * @type {Boolean}
   * @default true
   */
  autoHideScrollbar: true,

  /**
   * Allows multiple rows to be expanded at once
   *
   * @property multiRowExpansion
   * @type {Boolean}
   * @default true
   */
  multiRowExpansion: true,

  /**
   * Expand a row on click
   *
   * @property expandOnClick
   * @type {Boolean}
   * @default true
   */
  expandOnClick: true,

  /**
   * If true, the body block will yield columns and rows, allowing you
   * to define your own table body
   *
   * @property overwrite
   * @type {Boolean}
   * @default false
   */
  overwrite: false,

  /**
   * If true, the body will prepend an invisible `<tr>` that scaffolds the
   * widths of the table cells.
   *
   * ember-light-table uses [`table-layout: fixed`](https://developer.mozilla.org/en-US/docs/Web/CSS/table-layout).
   * This means, that the widths of the columns are defined by the first row
   * only. By prepending this scaffolding row, widths of columns only need to
   * be specified once.
   *
   * @property enableScaffolding
   * @type {Boolean}
   * @default false
   */
  enableScaffolding: false,

  /**
   * ID of main table component. Used to generate divs for ember-wormhole and set scope for scroll observers
   *
   * @property tableId
   * @type {String}
   * @private
   */
  tableId: null,

  /**
   * @property scrollBuffer
   * @type {Number}
   * @default 500
   */
  scrollBuffer: 500,

  /**
   * @property scrollBufferRows
   * @type {Number}
   * @default 500 / estimatedRowHeight
   */
  scrollBufferRows: computed(
    'scrollBuffer',
    'sharedOptions.estimatedRowHeight',
    function () {
      return Math.ceil(
        this.scrollBuffer / (this.sharedOptions.estimatedRowHeight || 1)
      );
    }
  ),

  /**
   * @property useVirtualScrollbar
   * @type {Boolean}
   * @default false
   * @private
   */
  useVirtualScrollbar: false,

  /**
   * Set this property to scroll to a specific px offset.
   *
   * This only works when `useVirtualScrollbar` is `true`, i.e. when you are
   * using fixed headers / footers.
   *
   * @property scrollTo
   * @type {Number}
   * @default null
   */
  scrollTo: null,
  _scrollTo: null,

  /**
   * Set this property to a `Row` to scroll that `Row` into view.
   *
   * This only works when `useVirtualScrollbar` is `true`, i.e. when you are
   * using fixed headers / footers.
   *
   * @property scrollToRow
   * @type {Row}
   * @default null
   */
  scrollToRow: null,
  _scrollToRow: null,

  /**
   * @property targetScrollOffset
   * @type {Number}
   * @default 0
   * @private
   */
  targetScrollOffset: 0,

  /**
   * @property currentScrollOffset
   * @type {Number}
   * @default 0
   * @private
   */
  currentScrollOffset: 0,

  /**
   * @property hasReachedTargetScrollOffset
   * @type {Boolean}
   * @default true
   * @private
   */
  hasReachedTargetScrollOffset: true,

  /**
   * Allows to customize the component used to render rows
   *
   * ```hbs
   * <LightTable @table={{this.table}} as |t|}}
   *    <t.body @rowComponent={{component 'my-row'}} />
   * </LightTable>
   * ```
   * @property rowComponent
   * @type {Ember.Component}
   * @default null
   */
  rowComponent: null,

  /**
   * Allows to customize the component used to render spanned rows
   *
   * ```hbs
   * <LightTable @table={{this.table}} as |t| >
   *    <t.body @spannedRowComponent={{component 'my-spanned-row'}} />
   * </LightTable>
   * ```
   * @property spannedRowComponent
   * @type {Ember.Component}
   * @default null
   */
  spannedRowComponent: null,

  /**
   * Allows to customize the component used to render infinite loader
   *
   * ```hbs
   * <LightTable @table={{this.table}} as |t|>
   *    <t.body @infinityComponent={{component 'my-infinity'}} />
   * </LightTable>
   * ```
   * @property infinityComponent
   * @type {Ember.Component}
   * @default null
   */
  infinityComponent: null,

  rows: readOnly('table.visibleRows'),
  columns: readOnly('table.visibleColumns'),
  colspan: readOnly('columns.length'),

  /**
   * fills the screen with row items until lt-infinity component has exited the viewport
   * @property scheduleScrolledToBottom
   */
  scheduleScrolledToBottom: action(function () {
    if (this.isInViewport && this.onScrolledToBottom) {
      /*
       Continue scheduling onScrolledToBottom until no longer in viewport
       */
      this._schedulerTimer = scheduleOnce(
        'afterRender',
        this,
        this._debounceScrolledToBottom
      );
    }
  }),

  _prevSelectedIndex: -1,

  init() {
    this._super(...arguments);

    /*
      We can only set `useVirtualScrollbar` once all contextual components have
      been initialized since fixedHeader and fixedFooter are set on t.head and t.foot
      initialization.
     */
    once(this, this._setupVirtualScrollbar);
  },

  destroy() {
    this._super(...arguments);
    this._cancelTimers();
  },

  _setupVirtualScrollbar() {
    let { fixedHeader, fixedFooter } = this.sharedOptions;
    this.set('useVirtualScrollbar', fixedHeader || fixedFooter);
  },

  onRowsChange: action(function () {
    this._checkTargetOffsetTimer = scheduleOnce(
      'afterRender',
      this,
      this.checkTargetScrollOffset
    );
  }),

  setupScrollOffset: action(function (element) {
    let { scrollTo, _scrollTo, scrollToRow, _scrollToRow } = this;
    let targetScrollOffset = null;

    this.setProperties({ _scrollTo: scrollTo, _scrollToRow: scrollToRow });

    if (scrollTo !== _scrollTo) {
      targetScrollOffset = Number.parseInt(scrollTo, 10);

      if (Number.isNaN(targetScrollOffset)) {
        targetScrollOffset = null;
      }

      this.setProperties({
        targetScrollOffset,
        hasReachedTargetScrollOffset: targetScrollOffset <= 0,
      });
    } else if (scrollToRow !== _scrollToRow) {
      if (scrollToRow instanceof Row) {
        let rowElement = element.querySelector(
          `[data-row-id=${scrollToRow.get('rowId')}]`
        );

        if (rowElement instanceof Element) {
          targetScrollOffset = rowElement.offsetTop;
        }
      }

      this.setProperties({
        targetScrollOffset,
        hasReachedTargetScrollOffset: true,
      });
    }
  }),

  checkTargetScrollOffset() {
    if (!this.hasReachedTargetScrollOffset) {
      let targetScrollOffset = this.targetScrollOffset;
      let currentScrollOffset = this.currentScrollOffset;

      if (targetScrollOffset > currentScrollOffset) {
        this.set('targetScrollOffset', null);
        this._setTargetOffsetTimer = schedule('render', null, () => {
          this.set('targetScrollOffset', targetScrollOffset);
        });
      } else {
        this.set('hasReachedTargetScrollOffset', true);
      }
    }
  },

  toggleExpandedRow(row) {
    let multiRowExpansion = this.multiRowExpansion;
    let shouldExpand = !row.expanded;

    if (multiRowExpansion) {
      row.toggleProperty('expanded');
    } else {
      this.table.expandedRows.setEach('expanded', false);
      row.set('expanded', shouldExpand);
    }
  },

  /**
   * @method _debounceScrolledToBottom
   */
  _debounceScrolledToBottom(delay = 100) {
    /*
     This debounce is needed when there is not enough delay between onScrolledToBottom calls.
     Without this debounce, all rows will be rendered causing immense performance problems
     */
    this._debounceTimer = debounce(this, this.onScrolledToBottom, delay);
  },

  /**
   * @method _cancelTimers
   */
  _cancelTimers() {
    cancel(this._checkTargetOffsetTimer);
    cancel(this._setTargetOffsetTimer);
    cancel(this._schedulerTimer);
    cancel(this._debounceTimer);
  },

  // Noop for closure actions
  onRowClick() {},
  onRowDoubleClick() {},
  onScroll() {},
  firstVisibleChanged() {},
  lastVisibleChanged() {},
  firstReached() {},
  lastReached() {},

  /**
   * lt-infinity action to determine if component is still in viewport
   * @event enterViewport
   */
  enterViewport: action(function () {
    this.set('isInViewport', true);
  }),

  /**
   * lt-infinity action to determine if component has exited the viewport
   * @event exitViewport
   */
  exitViewport: action(function () {
    this.set('isInViewport', false);
  }),

  actions: {
    /**
     * onRowClick action. Handles selection, and row expansion.
     * @event onRowClick
     * @param  {Row}   row The row that was clicked
     * @param  {Event}   event   The click event
     */
    onRowClick(row, e) {
      let rows = this.table.rows;
      let multiSelect = this.multiSelect;
      let multiSelectRequiresKeyboard = this.multiSelectRequiresKeyboard;
      let canSelect = this.canSelect;
      let selectOnClick = this.selectOnClick;
      let canExpand = this.canExpand;
      let expandOnClick = this.expandOnClick;
      let isSelected = row.get('selected');
      let currIndex = rows.indexOf(row);
      let prevIndex =
        this._prevSelectedIndex === -1 ? currIndex : this._prevSelectedIndex;

      this._prevSelectedIndex = currIndex;

      let toggleExpandedRow = () => {
        if (canExpand && expandOnClick) {
          this.toggleExpandedRow(row);
        }
      };

      if (canSelect) {
        if (e.shiftKey && multiSelect) {
          rows
            .slice(
              Math.min(currIndex, prevIndex),
              Math.max(currIndex, prevIndex) + 1
            )
            .forEach((r) => r.set('selected', !isSelected));
        } else if (
          (!multiSelectRequiresKeyboard || e.ctrlKey || e.metaKey) &&
          multiSelect
        ) {
          row.toggleProperty('selected');
        } else {
          if (selectOnClick) {
            this.table.selectedRows.setEach('selected', false);
            row.set('selected', !isSelected);
          }

          toggleExpandedRow();
        }
      } else {
        toggleExpandedRow();
      }

      this.onRowClick(...arguments);
    },

    /**
     * onRowDoubleClick action.
     * @event onRowDoubleClick
     * @param  {Row}   row The row that was clicked
     * @param  {Event}   event   The click event
     */
    onRowDoubleClick(/* row */) {
      this.onRowDoubleClick(...arguments);
    },

    /**
     * onScroll action - sent when user scrolls in the Y direction
     *
     * This only works when `useVirtualScrollbar` is `true`, i.e. when you are
     * using fixed headers / footers.
     *
     * @event onScroll
     * @param {Number} scrollOffset The scroll offset in px
     * @param {Event} event The scroll event
     */
    onScroll(scrollOffset /* , event */) {
      this.set('currentScrollOffset', scrollOffset);
      this.onScroll(...arguments);
    },

    firstVisibleChanged(item, index /* , key */) {
      this.firstVisibleChanged(...arguments);
      const estimateScrollOffset =
        index * this.sharedOptions.estimatedRowHeight;
      this.onScroll(estimateScrollOffset, null);
    },

    lastVisibleChanged(/* item, index, key */) {
      this.lastVisibleChanged(...arguments);
    },

    firstReached(/* item, index, key */) {
      this.firstReached(...arguments);
    },

    lastReached(/* item, index, key */) {
      this.lastReached(...arguments);
      this.onScrolledToBottom?.();
    },
  },
});