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
}