Skip to content

Instantly share code, notes, and snippets.

@parkerault
Last active April 16, 2018 16:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save parkerault/660a7751125fc89c1f2e8a3c6434c5f5 to your computer and use it in GitHub Desktop.
Save parkerault/660a7751125fc89c1f2e8a3c6434c5f5 to your computer and use it in GitHub Desktop.

Component

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import JSONPointer from 'json-ptr';
import moment from 'moment';

import SVG from '../SVG';
import {
  deepCopyJSON,
  extendClassNames,
  isNumber,
  isString,
  isUndefined,
} from '../../utils';

import './DataTable.css';

export default function DataTable(props) {
  const sortIcon = 'icon-ui-sort';
  return (
    <table className={`scmDataTable-container ${props.tableClass}`}>
      <thead>
        <tr>
        {props.headers.map((header, i) => {
          if(props.disableSort && props.disableSort[header]) {
            return <th key={i}>{header}</th>
          } else {
            return <th className="scmDataTable-header" key={i}
              onClick={() => props.sort(header)}>{header}
              <SVG path={sortIcon} className="cardIcon scmDataTable-header-svg"/>
            </th>
          }
        })}
        </tr>
      </thead>
      {props.tableData(props)}
    </table>
  )
}

// Alias React.Children.toArray for convenience; we don't want to use array
// methods on props.children since it could be a single object or undefined.
const getChildren = React.Children.toArray;

// Custom error messages for propTypes.children
const noColumnsMessage = [
  "DataTable must have one or more <Column> elements. Please refer to",
  "the documentation:",
  "https://gitlab.com/EL-SCM/SCM-FE/blob/dev/src/components/reusable/README.md"
].join(" ");
const sortDefaultMessage = [
  "Only one <Column> element may have a `sortDefault` attribute. Please refer",
  "to the documentation:",
  "https://gitlab.com/EL-SCM/SCM-FE/blob/dev/src/components/reusable/README.md"
].join(" ");
const noTemplateMessage = [
  "DataTable must have exactly one <TableRowTemplate> element. Please refer",
  "to the documentation:",
  "https://gitlab.com/EL-SCM/SCM-FE/blob/dev/src/components/reusable/README.md"
].join(" ");
const noTableRowMessage = [
  "<TableRowTemplate> must have exactly one <TableRow> child element. Please",
  "refer to the documentation:",
  "https://gitlab.com/EL-SCM/SCM-FE/blob/dev/src/components/reusable/README.md"
].join(" ");

export class DataTableV2 extends Component {

  static propTypes = {
    tableData: PropTypes.array.isRequired,
    children: function(props, propName, componentName) {
      const children = getChildren(props[propName]);
      const columns = children.filter(child => child.type===Column);
      if (!columns.length)
        return new Error(noColumnsMessage);
      const columnSortDefaults = columns.filter(column => column.props.sortDefault);
      if (columnSortDefaults.length>1)
        return new Error(sortDefaultMessage);
      const rowTemplate = children.find(child => child.type===TableRowTemplate);
      if (!rowTemplate)
        return new Error(noTemplateMessage);
    }
  }

  // Sort direction constants are (+/-)1 so a single ascending compare function
  // can be used; the result of the compare operation is multiplied by the
  // constant, and inverted if the active sort direction is SORT_DSC.
  static SORT_ASC = 1
  static SORT_DSC = -1
  static SORT_NONE = 0

  constructor(props) {
    super(props);
    // Combine the user-supplied table className with default element
    // classNames for a generous number of selectors for overriding default
    // table element styles.
    const classNames = {
      table: extendClassNames(
        'scmDataTable',
        props.className
      ),
      thead: extendClassNames(
        'scmDataTable-head',
        `${props.className}-head`
      ),
      theadRow: extendClassNames(
        'scmDataTable-headRow',
        `${props.className}-headRow`
      ),
      tbody: extendClassNames(
        'scmDataTable-body',
        `${props.className}-body`
      ),
    }
    // Convert props.children to a proper Array so we don't blow up the
    // constructor if (for some reason) a single child or null is ever passed
    // to the table component.
    const children = getChildren(props.children);
    // Get all Column elements
    const columns = children.filter(child => child.type===Column);
    // (At most one) Column element with a sortDefault attribute will be
    // used for the default sort order
    const defaultColumn = columns.find(column => column.props.sortDefault);
    // Keep a reference to the initial sort pointer if defaultColumn exists.
    const sortPointer = defaultColumn && defaultColumn.props.sortPointer;
    // Keep a reference to the initial sort direction, or SORT_NONE if none of
    // the Column elements have a sortDefault attribute.
    const sortDir = defaultColumn && defaultColumn.props.sortDefault
      ? defaultColumn.props.sortDefault
      : DataTableV2.SORT_NONE;
    // Keep a reference to the initial sort compare function, or use
    // defaultCompare if none of the Column elements have a sortDefault
    // attribute.
    const sortCompare = defaultColumn && defaultColumn.props.sortCompare
      ? defaultColumn.props.sortCompare
      : this.defaultCompare;
    // Keep a reference to the row template. This element is never actually
    // rendered, but its render attribute is called with each item in
    // tableData. It could just as easily have been a child function, but I
    // think the element-with-render-function pattern makes more readable JSX.
    const rowTemplate = children.find(child => child.type===TableRowTemplate);
    // It's unlikely the tableData attribute will be populated on the first
    // render, but just in case:
    const tableData = sortPointer && sortDir!==DataTableV2.SORT_NONE
      ? this.sortTableData(props.tableData, sortPointer, sortCompare, sortDir)
      : props.tableData;
    // Optional placeholder for tables with no data.
    let emptyPlaceholder = children.find(child => child.type===TableEmpty);
    emptyPlaceholder = emptyPlaceholder
      ? React.cloneElement(emptyPlaceholder, {
        colSpan: columns.length,
        children: emptyPlaceholder.props.children,
      })
      : (<TableEmpty colSpan={columns.length}>No results.</TableEmpty>);
    // Assign tableData to the default state, but keep all other values
    // outside of the state since none of them should actually trigger
    // a re-render when changed.
    Object.assign(this, {
      classNames,
      emptyPlaceholder,
      columns,
      currentSort: {
        compare: sortCompare,
        direction: sortDir,
        pointer: sortPointer,
      },
      rowTemplate,
      state: { tableData }
    });
  }

  defaultCompare(valueA, valueB) {
    if (isString(valueA) && isString(valueB)) {
      return valueA.localeCompare(valueB);
    } else if (isNumber(valueA) && isNumber(valueB)) {
      return valueA - valueB;
    } else {
      throw new Error([
        "Expected sort values to be Number or String:\n",
        "value a: ", JSON.stringify(valueA), "\n",
        "value b: ", JSON.stringify(valueB)
      ].join());
    }
  }

  pluckSortValues(pointer, rows) {
    const [valueA, valueB] = rows.map(row => JSONPointer.get(row, pointer));
    if (isUndefined(valueA) || isUndefined(valueB)) {
      throw new Error([
        "Property ", pointer, " not found on items:\n",
        JSON.stringify(valueA), "\n",
        JSON.stringify(valueB)
      ].join());
    }
    return [valueA, valueB];
  }

  // TODO: Compare function for Locations. (group by state?)
  // TODO: Compare function for Claim status. (custom status order?)

  sortTableData(tableData, pointer, compare, direction) {
    // We need to copy on every sort, since the source will always be either
    // props or state, neither of which can be sorted in place. `deepCopyJSON`
    // is safe here because our table data should always be JSON parsed from
    // an API response. If non-serializable properties get added to the data
    // set, this will throw an error! -Parker
    tableData = deepCopyJSON(tableData);
    return tableData.sort((...rows) => {
      const values = this.pluckSortValues(pointer, rows);
      return compare(...values) * direction;
    });
  }

  reverseTableData() {
    return deepCopyJSON(this.state.tableData).reverse();
  }

  createSortClickHandler({pointer, compare}) {
    return (event) => {
      let sorted, direction;
      if (this.currentSort.pointer === pointer) {
        direction = this.currentSort.direction * -1;
        sorted = this.reverseTableData();
      } else {
        direction = DataTableV2.SORT_ASC;
        sorted = this.sortTableData(this.state.tableData, pointer, compare, direction);
      }
      this.currentSort = { compare, direction, pointer };
      this.setState({ tableData: sorted });
    }
  }

  cloneColumn = (column, i) => {
    // Clone Column elements with bound click handlers if they have a
    // `sortPointer` attribute. `this.createSortHandler` creates a closure with
    // the column's pointer string and compare function.
    const { sortPointer, sortCompare } = column.props;
    const _sortClickHandler = sortPointer && this.createSortClickHandler({
      pointer: sortPointer,
      compare: sortCompare || this.defaultCompare,
    });
    return React.cloneElement(column, {
      key: i,
      currentSort: this.currentSort,
      tableClassName: this.props.className,
      _sortClickHandler,
    });
  }

  componentWillReceiveProps(nextProps) {
    // TODO: depending on use case, we may need to compare nextProps.children
    // and see if any of the Column or RowTemplate elements have changed.
    const { compare, direction, pointer } = this.currentSort;
    let tableData;
    if (this.currentSort.direction === DataTableV2.SORT_NONE) {
      // table is unsorted and props are being updated
      tableData = nextProps.tableData;
    } else {
      // table is sorted and props are being updated
      tableData = this.sortTableData(nextProps.tableData, pointer, compare, direction);
    }
    this.setState({ tableData });
  }

  render() {
    const {
      classNames,
      cloneColumn,
      columns,
      rowTemplate,
    } = this;
    const { tableData } = this.state;
    // Ensure the TableRowTemplate render function conforms to the spec
    if (process.env.NODE_ENV!=='production') {
      const testRow = tableData.length && rowTemplate.props.render(tableData[0]);
      if (testRow && testRow.type!==TableRow)
        console.error("Warning: Failed prop type: " + noTableRowMessage);
    }
    return (
      <table className={classNames.table}>
        <thead className={classNames.thead}>
          <tr className={classNames.theadRow}>
            {columns.map(cloneColumn)}
          </tr>
        </thead>
        <tbody className={classNames.tbody}>
            {tableData.length
              ? tableData.map((item, i)=> rowTemplate.props.render(item, i))
              : this.emptyPlaceholder
            }
        </tbody>
      </table>
    )
  }

}

export function Column({
  children,
  className,
  currentSort,
  sortCompare,
  sortDefault,
  sortPointer,
  tableClassName,
  _sortClickHandler,
  ...passthrough,
}) {
  // NOTE: the `_sortClickHandler` and `_getSortState` properties are used
  // internally by the DataTable component and will be overridden if they
  // are set as an attribute by the parent component.
  let classNames = extendClassNames(
    'scmDataTable-column',
    sortPointer && 'scmDataTable-column_sortable',
    `${tableClassName}-column`,
    className
  );
  let sortIcon = 'icon-ui-sort';
  if (currentSort && currentSort.pointer===sortPointer) {
    // NOTE: in this condition `direction` will always be either SORT_ASC or
    // SORT_DSC.
    classNames += " scmDataTable-column_active";
    sortIcon = currentSort.direction===DataTableV2.SORT_ASC
      ? 'icon-ui-sort-asc'
      : 'icon-ui-sort-dsc';
  }
  return (
    <th {...passthrough} className={classNames} onClick={_sortClickHandler}>
      {children}
      {sortPointer &&
        <SVG className="scmDataTable-sortIcon" path={sortIcon} />
      }
    </th>
  )
}

Column.propTypes = {
  sortCompare: PropTypes.func,
  sortDefault: PropTypes.oneOf([
    DataTableV2.SORT_ASC,
    DataTableV2.SORT_DSC,
    DataTableV2.SORT_NONE,
  ]),
  sortPointer: PropTypes.string
}

export function TableRowTemplate(props) {
  // This component is never actually rendered in the document; it has a single
  // `render` attribute (a function) that the DataTable calls for each item in
  // the `tableData` collection. The only reason it is a component and not an
  // attribute of DataTable itself is that it typically returns a large block
  // of JSX and I think putting it into a dummy component is much more readable.
  return null;
}

TableRowTemplate.propTypes = {
  render: PropTypes.func.isRequired
}

export function TableRow({className, children, ...passthrough}) {
  // We need to use a component for rows to assign the default className.
  const classNames = extendClassNames(
    "scmDataTable-row",
    className
  );
  return <tr {...passthrough} className={classNames}>{children}</tr>
}

export function TableEmpty({className, children, colSpan, ...passthrough}) {
  const rowClassNames = extendClassNames(
    "scmDataTable-row",
    className
  );
  const cellClassNames = extendClassNames(
    "scmDataTable-data",
    "scmDataTable-empty",
    className
  );
  return (
    <tr {...passthrough} className={rowClassNames}>
      <td className={cellClassNames} colSpan={colSpan}>
        <div className="scmDataTable-cell">
          {children}
        </div>
      </td>
    </tr>
  )
}

export function TableText({className, children, ...passthrough}) {
  // We need to use a component for td to assign the default className.
  const classNames = extendClassNames(
    "scmDataTable-data",
    "scmDataTable-text",
    className
  );
  return (
    <td {...passthrough} className={classNames}>
      <div className="scmDataTable-cell">
        {children}
      </div>
    </td>
  )
}

export function TableRadioButton({
  className,
  checked=false,
  disabled=false,
  name,
  onChange=() => {},
  title,
  value,
  ...passthrough
}) {
  const classNames = extendClassNames(
    "scmDataTable-data",
    "scmDataTable-radio",
    className
  );
  return (
    <td {...passthrough} className={classNames}>
      <label className="scmDataTable-radioLabel" htmlFor={name}>
        <input
          className="scmDataTable-radioInput"
          checked={checked}
          disabled={disabled}
          id={name}
          name={name}
          onChange={onChange}
          title={title}
          type="radio"
          value={value}
        />
      </label>
    </td>
  )
}

TableRadioButton.propTypes = {
  checked: PropTypes.bool,
  disabled: PropTypes.bool,
  name: PropTypes.string,
  onChange: PropTypes.func,
  title: PropTypes.string,
  value: PropTypes.string,
}

export function TableDate({
  className,
  date,
  format="MM/DD/YY",
  ...passthrough
}) {
  // Format string reference: http://momentjs.com/docs/#/displaying/format/
  const classNames = extendClassNames(
    "scmDataTable-data",
    "scmDataTable-date",
    className
  );
  const dateString = isNumber(date) && moment(date).format(format);
  if (isUndefined(dateString) && process.env.NODE_ENV!=='production')
    console.error(
      `TableDate expects epoch milliseconds, got: %o`, dateString
    );
  return (
    <td {...passthrough} className={classNames}>{dateString}</td>
  )
}

TableDate.propTypes = {
  date: PropTypes.number.isRequired,
  format: PropTypes.string.isRequired,
}

export function TableAutoComplete({className, value, ...passthrough}) {
  // TODO: This needs to be a text input with either a pre-computed list of
  // values or an onChanged event handler.
  const classNames = extendClassNames(
    "scmDataTable-data",
    "scmDataTable-autoComplete",
    className
  );
  return (
    <td {...passthrough} className={classNames}>{value}</td>
  )
}

export function TableIcon({className, iconPath, ...passthrough}) {
  const classNames = extendClassNames(
    "scmDataTable-data",
    "scmDataTable-icon",
    className
  );
  return (
    <td {...passthrough} className={classNames}>
      <SVG className="scmDataTable-iconSVG" path={iconPath} />
    </td>
  )
}

TableIcon.propTypes = {
  iconPath: PropTypes.string.isRequired
}

Usage

<DataTableV2 className="scmActionCenterTable" tableData={actionsList}>

  <Column className="scmActionCenterTable-title" sortPointer="/title">Title</Column>
  <Column className="scmActionCenterTable-description" sortPointer="/description">Description</Column>
  <Column className="scmActionCenterTable-dueDate" sortPointer="/targetDate" sortDefault={DataTableV2.SORT_ASC}>Due Date</Column>
  <Column className="scmActionCenterTable-reassign" sortPointer="/assignedToUser/lastName">Reassign</Column>
  <Column className="scmActionCenterTable-act">Act</Column>
  { snoozeAction &&
    <Column className="scmActionCenterTable-snooze">Snooze</Column>
  }
  { completeAction &&
    <Column className="scmActionCenterTable-complete">Complete</Column>
  }

  <TableEmpty className="scmActionCenterTable-empty">
    { isPending && "No pending actions." }
    { isSnoozed && "No snoozed actions." }
    { isCompleted && "No completed actions." }
  </TableEmpty>

  <TableRowTemplate render={action => {
    let actIcon = 'icon-ui-na';
    if (action.requiredForms.length || action.optionalForms.length) actIcon = 'icon-ui-download';
    if (action.link.length) actIcon = 'icon-ui-attachments';
    console.log(action.snoozable)
    return (
      <TableRow key={action.id} className="scmActionCenterTable-row">
        <TableText className="scmActionCenterTable-title">
          {action.title}
        </TableText>
        <TableText className="scmActionCenterTable-description">
          {action.description}
        </TableText>
        <TableDate className="scmActionCenterTable-dueDate" format="MM/DD/YY" date={action.targetDate} />
        <TableAutoComplete
          className="scmActionCenterTable-reassign"
          value={`${action.assignedToUser.firstName} ${action.assignedToUser.lastName}`}
        />
        <TableIcon className="scmActionCenterTable-act" iconPath={actIcon} />
        { snoozeAction &&
          <TableRadioButton
            checked={action.isSnoozed}
            disabled={!action.snoozable || requestPending}
            className="scmActionCenterTable-snooze"
            name={`snooze_${action.id}`}
            onChange={() => snoozeAction({ actionId: action.id })}
            value="snooze"
          />
        }
        { completeAction &&
          <TableRadioButton
            className="scmActionCenterTable-complete"
            disabled={requestPending}
            name={`complete_${action.id}`}
            onChange={() => completeAction({ actionId: action.id })}
            value="complete"
          />
        }
      </TableRow>
    )
  }}/>
</DataTableV2>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment