TacoTable.js

import React from 'react';
import shallowCompare from 'react-addons-shallow-compare';
import classNames from 'classnames';
import TacoTableHeader from './TacoTableHeader';
import TacoTableRow from './TacoTableRow';
import SortDirection from './SortDirection';
import { sortData, getColumnById, validateColumns } from './Utils';


const propTypes = {
  columns: React.PropTypes.array.isRequired,
  columnGroups: React.PropTypes.array,
  columnHighlighting: React.PropTypes.bool,
  className: React.PropTypes.string,
  data: React.PropTypes.array,
  fullWidth: React.PropTypes.bool,
  initialSortColumnId: React.PropTypes.string,
  initialSortDirection: React.PropTypes.bool,
  plugins: React.PropTypes.array,
  rowClassName: React.PropTypes.func,
  rowHighlighting: React.PropTypes.bool,
  sortable: React.PropTypes.bool,
  striped: React.PropTypes.bool,
  HeaderComponent: React.PropTypes.func,
  RowComponent: React.PropTypes.func,
};

const defaultProps = {
  columnHighlighting: false,
  initialSortDirection: SortDirection.Ascending,
  striped: false,
  sortable: true,
  fullWidth: true,
  rowHighlighting: true,
  HeaderComponent: TacoTableHeader,
  RowComponent: TacoTableRow,
};

/**
 * React component for rendering a table, uses `<table className="taco-table">`
 *
 * Note that `Renderable` means anything React can render (e.g., String, Number,
 * React.Component, etc.).
 *
 * ### Column Definition
 *
 * Columns are defined by objects with the following format:
 *
 * | Name | Type     | Description   |
 * | :----| :------  | :------------ |
 * | `id` | String | The id of the column. Typically corresponds to a key in the rowData object. |
 * | `[className]` | String | The class name to be applied to both `<td>` and `<th>` |
 * | `[firstSortDirection]` | Boolean | The direction which this column gets sorted by on first click |
 * | `[header]` | Renderable | What is rendered in the column header. If not provided, uses the columnId. |
 * | `[renderer]` | Function | `function (cellData, column, rowData, rowNumber, tableData, columns)`<br>The function that renders the value in the table. Can return anything React can render. |
 * | `[rendererOptions]` | Object | Object of options that can be read by the renderer |
 * | `[simpleRenderer]` | Function | `function (cellData, column, rowData, rowNumber, tableData, columns)`<br>The function that render the cell's value in a simpler format. Must return a String or Number. |
 * | `[sortType]` | String | The `DataType` of the column to be used strictly for sorting, if not provided, uses `type` - number, string, etc |
 * | `[sortValue]` | Function | `function (cellData, rowData)`<br>Function to use when sorting instead of `value`. |
 * | `[summarize]` | Function | `function (column, tableData, columns)`<br>Produces an object representing a summary of the column (e.g., min and max) to be used in the |
 * | `[tdClassName]` | Function or String | `function (cellData, columnSummary, column, rowData, highlightedColumn, highlightedRow, rowNumber, tableData, columns)`<br>A function that returns a class name based on the cell data and column summary or other information. If a string is provided, it is used directly as the class name. |
 * | `[tdStyle]` | Function or Object | `function (cellData, columnSummary, column, rowData, highlightedColumn, highlightedRow, rowNumber, tableData, columns)`<br>A function that returns the style to be applied to the cell. If an object is provided, it is used directly as the style attribute. |
 * | `[thClassName]` | String | The class name to be applied to `<th>` only |
 * | `[type]` | String | The `DataType` of the column - number, string, etc |
 * | `[value]` | Function or String | `function (rowData, rowNumber, tableData, columns)`<br>Function to produce cellData's value. If a String, reads that as a key into the rowData object. If not provided, columnId is used as a key into the rowData object. |
 * | `[width]` | Number or String | The value to set for the style `width` property on the column. |
 *
 *
 * ### Column Groups
 *
 * Column groups are defined by objects with the following format:
 *
 * | Name | Type     | Description   |
 * | :----| :------  | :------------ |
 * | `[className]` | String | The className to apply to cells and headers in this group |
 * | `columns` | String[] | The column IDs to render |
 * | `[header]` | Renderable | What shows up in the table header if provided |
 *
 *
 * ### Plugins
 *
 * Plugins are defined by objects with the following format:
 *
 * | Name | Type     | Description   |
 * | :----| :------  | :------------ |
 * | `[columnTest]` | Function | A function that takes a column and returns true or false if it the plugin should be run on this column. Default is true for everything. |
 * | `id` | String | The ID of the plugin |
 * | `[summarize]` | Function | A column summarizer function |
 * | `[tdStyle]` | Function or Object | The TD style function |
 * | `[tdClassName]` | Function or String | The TD class name function |
 *
 *
 * @prop {Object[]} columns   The column definitions
 * @prop {Object[]} columnGroups   How to group columns - an array of
 *   `{ header:String, columns:[colId1, colId2, ...], className:String}`
 * @prop {Boolean} columnHighlighting=false   Whether or not to turn on mouse listeners
 *    for column highlighting
 * @prop {String} className   The class names to apply to the table
 * @prop {Object[]} data   The data to be rendered as rows
 * @prop {String} initialSortColumnId   Column ID of the data to sort by initially
 * @prop {Boolean} initialSortDirection=true(Ascending)   Direction by which to sort initially
 * @prop {Object[]} plugins   Collection of plugins to run to compute cell style,
 *    cell class name, column summaries
 * @prop {Boolean} sortable=true   Whether the table can be sorted or not
 * @prop {Boolean} striped=false   Whether the table is striped
 * @prop {Boolean} fullWidth=true   Whether the table takes up full width or not
 * @prop {Function} rowClassName   Function that maps (rowData, rowNumber) to a class name
 * @prop {Boolean} rowHighlighting=true   Whether or not to turn on mouse
 *    listeners for row highlighting
 * @prop {Function} HeaderComponent=TacoTableHeader   allow configuration of which
 *     component to use for headers
 * @prop {Function} RowComponent=TacoTableRow   allow configuration of which
 *     component to use for rows
 * @extends React.Component
 */
class TacoTable extends React.Component {
  /**
   * @param {Object} props React props
   */
  constructor(props) {
    super(props);

    // check for column warnings
    if (process.env.NODE_ENV !== 'production') {
      validateColumns(props.columns);
    }

    // store the data in the state to have a unified interface for sortable and
    // non-sortable tables. Take a slice to ensure we do not modify the original
    this.state = {
      data: props.data.slice(),
      columnSummaries: this.summarizeColumns(props),
    };

    // if sortable, do the initial sort
    if (props.sortable) {
      const sortColumn = getColumnById(props.columns, props.initialSortColumnId);
      const sortColumnId = props.initialSortColumnId;

      if (sortColumn) {
        // get the sort direction by interpreting initialSortDir then firstSortDir then default Asc
        let sortDirection;
        if (props.initialSortDirection == null) {
          if (sortColumn.firstSortDirection == null) {
            sortDirection = SortDirection.Ascending;
          } else {
            sortDirection = sortColumn.firstSortDirection;
          }
        } else {
          sortDirection = props.initialSortDirection;
        }

        Object.assign(this.state, {
          sortColumnId,
          sortDirection,
          data: sortData(this.state.data, props.initialSortColumnId,
            props.initialSortDirection, props.columns),
        });
      }
    }


    // bind handlers
    this.handleHeaderClick = this.handleHeaderClick.bind(this);
    this.handleRowHighlight = this.handleRowHighlight.bind(this);
    this.handleColumnHighlight = this.handleColumnHighlight.bind(this);
    this.sort = this.sort.bind(this);
  }

  /**
   * On receiving new props, sort the data and recompute column summaries if the data
   * has changed.
   * @param {Object} nextProps The next props
   * @returns {void}
   */
  componentWillReceiveProps(nextProps) {
    const { data } = this.props;

    // check for column warnings
    if (process.env.NODE_ENV !== 'production') {
      validateColumns(nextProps.columns);
    }

    if (data !== nextProps.data) {
      const newState = Object.assign({}, this.state, { data: nextProps.data.slice() });

      // re-sort the data
      Object.assign(newState, this.sort(newState.sortColumnId, nextProps, newState));

      // recompute column summaries
      newState.columnSummaries = this.summarizeColumns(nextProps);

      this.setState(newState);
    }
  }

  /**
   * Uses `shallowCompare`
   * @param {Object} nextProps The next props
   * @param {Object} nextState The next state
   * @return {Boolean} If the component should update
   */
  shouldComponentUpdate(nextProps, nextState) {
    return shallowCompare(this, nextProps, nextState);
  }

  /**
   * Callback when a header is clicked. If a sortable table, sorts the table.
   *
   * @param {String} columnId The ID of the column that was clicked.
   * @returns {void}
   * @private
   */
  handleHeaderClick(columnId) {
    const { sortable } = this.props;

    if (sortable) {
      const sortResults = this.sort(columnId);
      if (sortResults) {
        this.setState(sortResults);
      }
    }
  }

  /**
   * Callback when a row is highlighted
   *
   * @param {Object} rowData The row data for the row that is highlighted
   * @returns {void}
   * @private
   */
  handleRowHighlight(rowData) {
    this.setState({
      highlightedRowData: rowData,
    });
  }

  /**
   * Callback when a column is highlighted
   *
   * @param {String} columnId The ID of the column being highlighted
   * @returns {void}
   * @private
   */
  handleColumnHighlight(columnId) {
    this.setState({
      highlightedColumnId: columnId,
    });
  }

  /**
   * Sort the table based on a column
   *
   * @param {String} columnId the ID of the column to sort by
   * @param {Object} props=this.props
   * @param {Object} state=this.state
   * @return {Object} Object representing sort state
   *    `{ sortDirection, sortColumnId, data }`.
   * @private
   */
  sort(columnId, props = this.props, state = this.state) {
    const { columns } = props;
    const { sortColumnId, data } = state;
    let { sortDirection } = state;
    const column = getColumnById(columns, columnId);

    if (!column) {
      return undefined;
    }

    // if there was no sort direction before or the column ID changed, use the firstSort
    if (sortDirection == null || columnId !== sortColumnId) {
      sortDirection = column.firstSortDirection;

    // if it is the same column, invert direction
    } else if (columnId === sortColumnId) {
      sortDirection = !sortDirection;

    // otherwise just default to ascending
    } else {
      sortDirection = SortDirection.Ascending;
    }

    const newState = {
      sortDirection: sortDirection == null ? SortDirection.Ascending : sortDirection,
      sortColumnId: columnId,
    };

    newState.data = sortData(data, newState.sortColumnId, newState.sortDirection, columns);
    return newState;
  }

  /**
   * Computes a summary for each column that is configured to have one.
   *
   * @param {Object} props React component props
   * @return {Array} array of summaries matching the indices for `columns`,
   *   null for those without a `summarize` property.
   * @private
   */
  summarizeColumns(props = this.props) {
    const { columns, data, plugins } = props;

    const summaries = columns.map(column => {
      let result;

      // run the summarize from each plugin
      if (plugins) {
        plugins.forEach(plugin => {
          // if the plugin has summarize and this column matches the column test (if provided)
          if (plugin.summarize && (!plugin.columnTest || plugin.columnTest(column))) {
            const pluginResult = plugin.summarize(column, data, columns);
            if (pluginResult) {
              if (!result) {
                result = pluginResult;
              } else {
                Object.assign(result, pluginResult);
              }
            }
          }
        });
      }

      // run the column summarize last to potentially override plugins
      if (column.summarize) {
        const columnResult = column.summarize(column, data, columns);
        if (!result) {
          result = columnResult;
        } else {
          Object.assign(result, columnResult);
        }
      }

      return result;
    });

    return summaries;
  }

  /**
   * Renders the group headers above column headers
   *
   * @return {React.Component} `<tr>`
   * @private
   */
  renderGroupHeaders() {
    const { columns, columnGroups } = this.props;

    // only render if we have labels
    if (!columnGroups || !columnGroups.some(columnGroup => columnGroup.header)) {
      return null;
    }

    // note we iterate over columns instead of columnGroups since not all columns
    // may be in a defined group
    return (
      <tr className="group-headers">
        {columns.map((column, i) => {
          const columnGroup = columnGroups.find(group =>
            group.columns.includes(column.id));

          // if not in a group, render an empty th
          if (!columnGroup) {
            return <th key={i} className="group-header-no-group" />;
          }

          // if first item in the group, render a multiple column spanning header
          if (columnGroup.columns.indexOf(column.id) === 0) {
            return (
              <th
                key={i}
                colSpan={columnGroup.columns.length}
                className={classNames('group-header', `group-header-${i}`, columnGroup.className)}
              >
                {columnGroup.header}
              </th>
            );
          }

          // if not the first item in the group, do not render it since colSpan handles it
          return null;
        })}
      </tr>
    );
  }

  /**
   * Renders the headers of the table in a thead
   *
   * @return {React.Component} `<thead>`
   * @private
   */
  renderHeaders() {
    const { columns, columnGroups, HeaderComponent, sortable } = this.props;
    const { highlightedColumnId, sortColumnId, sortDirection } = this.state;

    return (
      <thead>
        {this.renderGroupHeaders()}
        <tr>
          {columns.map((column, i) => {
            // find the associated column group
            let columnGroup;
            if (columnGroups) {
              columnGroup = columnGroups.find(group =>
                group.columns.includes(column.id));
            }

            return (
              <HeaderComponent
                key={i}
                column={column}
                columnGroup={columnGroup}
                highlightedColumn={column.id === highlightedColumnId}
                sortableTable={sortable}
                onClick={this.handleHeaderClick}
                sortDirection={sortColumnId === column.id ? sortDirection : undefined}
              />
            );
          })}
        </tr>
      </thead>
    );
  }

  /**
   * Renders the rows of the table in a tbody
   *
   * @return {React.Component} `<tbody>`
   * @private
   */
  renderRows() {
    const { columns, RowComponent, rowClassName, rowHighlighting,
      columnHighlighting, plugins, columnGroups } = this.props;
    const { data, highlightedRowData, highlightedColumnId, columnSummaries } = this.state;

    return (
      <tbody>
        {data.map((rowData, i) => {
          // compute the class name if a row class name function is provided
          let className;
          if (rowClassName) {
            className = rowClassName(rowData, i);
          }

          return (
            <RowComponent
              key={i}
              rowNumber={i}
              rowData={rowData}
              columns={columns}
              columnGroups={columnGroups}
              columnSummaries={columnSummaries}
              tableData={data}
              plugins={plugins}
              className={className}
              highlighted={highlightedRowData === rowData}
              onHighlight={rowHighlighting ? this.handleRowHighlight : undefined}
              highlightedColumnId={highlightedColumnId}
              onColumnHighlight={columnHighlighting ? this.handleColumnHighlight : undefined}
            />
          );
        })}
      </tbody>
    );
  }

  /**
   * Main render method
   * @return {React.Component} The table component
   */
  render() {
    const { className, fullWidth, striped, sortable } = this.props;

    return (
      <table
        className={classNames('taco-table', className, {
          'table-full-width': fullWidth,
          'table-not-full-width': !fullWidth,
          'table-striped': striped,
          'table-sortable': sortable,
        })}
      >
        {this.renderHeaders()}
        {this.renderRows()}
      </table>
    );
  }
}

TacoTable.propTypes = propTypes;
TacoTable.defaultProps = defaultProps;

export default TacoTable;