TacoTable.js

import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import TacoTableHeader from './TacoTableHeader';
import TacoTableRow from './TacoTableRow';
import SortDirection from './SortDirection';
import { sortData, getColumnById, validateColumns, renderCell } from './Utils';
import curry from 'lodash.curry';

const propTypes = {
  bottomData: PropTypes.oneOfType([PropTypes.array, PropTypes.object, PropTypes.bool]),
  columns: PropTypes.array.isRequired,
  columnGroups: PropTypes.array,
  columnHighlighting: PropTypes.bool,
  className: PropTypes.string,
  data: PropTypes.array,
  fullWidth: PropTypes.bool,
  initialSortColumnId: PropTypes.string,
  initialSortDirection: PropTypes.bool,
  onRowClick: PropTypes.func,
  onRowDoubleClick: PropTypes.func,
  onSort: PropTypes.func,
  plugins: PropTypes.array,
  rowClassName: PropTypes.func,
  rowHighlighting: PropTypes.bool,
  sortable: PropTypes.bool,
  striped: PropTypes.bool,
  HeaderComponent: PropTypes.func,
  RowComponent: 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. |
 * | `[bottomDataRender]` | Function or Function[] or String or String[] | `function({ columnSummary, column, rowData, tableData, columns, bottomData })`<br>A function that specifies how to render the bottom data (if enabled on the table). Use an array for multiple rows. The `rowData` is only populated if `bottomData` of the TacoTable is an array. If a string is provided, it is rendered directly. |
 * | `[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, { columnSummary, 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 |
 * | `[renderOnNull]` | Boolean | Whether the cell should render if the cellData is null (default: false) |
 * | `[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[]|Boolean} bottomData Special rows to place at the bottom of the table,
 *    unaffected by sorting. If true, populates values based on the `bottomData` property of
 *    the column definition or the column summarizer. If an array, that data is used to render
 *    the row.
 * @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 {Boolean} fullWidth=true   Whether the table takes up full width or not
 * @prop {String} initialSortColumnId   Column ID of the data to sort by initially
 * @prop {Boolean} initialSortDirection=true(Ascending)   Direction by which to sort initially
 * @prop {Function} onRowClick `function (rowData)`<br>Callback for when a row is clicked.
 * @prop {Function} onRowDoubleClick `function (rowData)`<br>Callback for when a row is double clicked.
 * @prop {Function} onSort `function (columnId, sortDirection, sortedData)`<br>Callback for after the data is sorted when a user clicks a header
 * @prop {Object[]} plugins   Collection of plugins to run to compute cell style,
 *    cell class name, column summaries
 * @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 {Boolean} sortable=true   Whether the table can be sorted or not
 * @prop {Boolean} striped=false   Whether the table is striped
 * @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 && 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 && nextProps.data.slice() });

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

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

      this.setState(newState);
    }
  }

  /**
   * Callback when a header is clicked. If a sortable table, sorts the table.
   * If the onSort callback is provided, it is fired with the columnId,
   * sort direction, and new sorted data as arguments.
   *
   * @param {String} columnId The ID of the column that was clicked.
   * @returns {void}
   * @private
   */
  handleHeaderClick(columnId) {
    const { sortable, onSort } = this.props;

    if (sortable) {
      const sortResults = this.sort(columnId);
      if (sortResults) {
        this.setState(sortResults);
        if (onSort) {
          onSort(columnId, sortResults.sortDirection, sortResults.data);
        }
      }
    }
  }

  /**
   * 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
   * @param {Boolean} keepSortDirection=false Whether to keep the same sort direction if sorting on
   *   the same column as what the data is already sorted on or not. Used primarily when receiving
   *   new data that should maintain its current sort.
   * @return {Object} Object representing sort state
   *    `{ sortDirection, sortColumnId, data }`.
   * @private
   */
  sort(columnId, props = this.props, state = this.state, keepSortDirection) {
    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) {
      if (!keepSortDirection) { // unless we say to keep it
        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, onRowClick, onRowDoubleClick } = 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}
              onClick={onRowClick}
              onDoubleClick={onRowDoubleClick}
              onHighlight={rowHighlighting ? this.handleRowHighlight : undefined}
              highlightedColumnId={highlightedColumnId}
              onColumnHighlight={columnHighlighting ? this.handleColumnHighlight : undefined}
            />
          );
        })}
      </tbody>
    );
  }

  /**
   * Renders the bottom data of the table in a separate tbody.
   * This data is configured by the `bottomData` table prop and the
   * `bottomData` field in column definitions. It is not affected by
   * sorting.
   *
   * @return {React.Component} `<tbody>`
   * @private
   */
  renderBottomData() {
    let { bottomData } = this.props;
    const { columns, RowComponent, rowClassName, rowHighlighting, columnHighlighting, plugins,
      columnGroups, onRowClick, onRowDoubleClick } = this.props;
    const { data, highlightedRowData, highlightedColumnId, columnSummaries } = this.state;

    // only render if we have it explicitly configured
    if (!bottomData) {
      return null;
    }

    let bottomDataRowComponents;
    let bottomRowData = [];

    // helper function to compute row data based on input data and the
    // column.bottomDataRender configuration
    const computeRowData = curry((bottomRowIndex, computedRowData, column, columnIndex) => {
      let { bottomDataRender } = column;

      // if it is an array, access it at the right index.
      if (Array.isArray(bottomDataRender)) {
        bottomDataRender = bottomDataRender[bottomRowIndex];
      }

      // if we have a value for this column already and no explicit bottom data render function
      // then we should use the column renderer on it
      if (computedRowData[column.id] != null && bottomDataRender == null) {
        computedRowData[column.id] = renderCell(computedRowData[column.id], column, computedRowData,
          `bottom-${bottomRowIndex}`, data, columns, false, columnSummaries[columnIndex]);

      // run if function, otherwise render directly
      } else if (typeof bottomDataRender === 'function') {
        const columnSummary = columnSummaries[columnIndex];
        computedRowData[column.id] = bottomDataRender({ columnSummary, column,
          rowData: computedRowData, data, columns, bottomData });

      // not a function, render whatever value is provided
      } else if (bottomDataRender != null) {
        computedRowData[column.id] = bottomDataRender;
      }
      // otherwise keep whatever computed value was there to begin with or nothing.
      return computedRowData;
    });

    if (typeof bottomData === 'object' && !Array.isArray(bottomData)) {
      bottomData = [bottomData];
    }

    if (Array.isArray(bottomData)) {
      // for each row
      bottomRowData = bottomData.map((rowData, bottomRowIndex) => {
        // compute the row data based on the functions in the column data, including
        // the data that was passed in as an argument to the function
        const computedRowData = columns.reduce(computeRowData(bottomRowIndex),
          Object.assign({}, rowData));

        return computedRowData;
      });

    // passed in a truthy value, render based on column definition only.
    } else {
      // figure out the number of rows to render by counting the length of bottomData
      // in the column definitions
      const numBottomRows = columns.reduce((numBottomRows, column) => {
        if (column.bottomDataRender) {
          let numRowsForColumn = 0;
          // if it isn't an array, it counts as one row, otherwise one for each entry
          if (!Array.isArray(column.bottomDataRender)) {
            numRowsForColumn = 1;
          } else {
            numRowsForColumn = column.bottomDataRender.length;
          }

          if (numRowsForColumn > numBottomRows) {
            return numRowsForColumn;
          }
        }
        return numBottomRows;
      }, 0);

      // render each row
      for (let bottomRowIndex = 0; bottomRowIndex < numBottomRows; bottomRowIndex++) {
        // compute the row data based on the functions in the column data
        const rowData = columns.reduce(computeRowData(bottomRowIndex), {});

        bottomRowData.push(rowData);
      }
    }

    if (bottomRowData.length) {
      bottomDataRowComponents = bottomRowData.map((rowData, bottomRowIndex) => {
        // compute the class name if a row class name function is provided
        let className;
        const rowNumber = `bottom-${bottomRowIndex}`;
        if (rowClassName) {
          className = rowClassName(rowData, rowNumber);
        }

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


    return (
      <tbody className="bottom-data">
        {bottomDataRowComponents}
      </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()}
        {this.renderBottomData()}
      </table>
    );
  }
}

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

export default TacoTable;