huge-table
Version:
Table component to handle huge sets of data, based on Facebook's FixedDataTable
578 lines (519 loc) • 21.2 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import { Table, Column, Cell } from 'fixed-data-table-2';
import classNames from 'classnames';
import * as Constants from './constants';
import * as CellUtils from './CellUtils';
import { HeaderCell } from './HeaderCell';
import { TextCell } from './TextCell';
import TouchWrapper from './TouchWrapper';
import { RETURNED_DATA_TYPES } from './constants';
import _ from 'lodash';
import { StyleSheet, css } from 'aphrodite';
export class HugeTable extends React.Component {
static propTypes = {
data: PropTypes.arrayOf(PropTypes.object),
options: PropTypes.shape({
height: PropTypes.number,
width: PropTypes.number,
mixedContentImage: PropTypes.func,
tableScrolled: PropTypes.func,
id: PropTypes.string,
maxTitleWidth: PropTypes.number,
maxContentWidth: PropTypes.number,
minColumnWidth: PropTypes.number,
rowNumberColumnWidth: PropTypes.number,
fontDetails: PropTypes.string,
}),
schema: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
type: PropTypes.string,
})),
renderers: PropTypes.shape(RETURNED_DATA_TYPES.reduce((initial, next) => {
return {
...initial,
[next]: PropTypes.func,
};
}, {HEADER: PropTypes.func})),
onSchemaChange: PropTypes.func,
resizeByContent: PropTypes.bool,
hideRowNumbers: PropTypes.bool,
showScrollingArrows: PropTypes.bool,
scrollToNewColumn: PropTypes.bool,
onScrollToNewColumn: PropTypes.func,
rowHeight: PropTypes.number,
headerHeight: PropTypes.number,
cellPadding: PropTypes.shape ({
top: PropTypes.number,
bottom: PropTypes.number,
left: PropTypes.number,
right: PropTypes.number,
}),
lineHeight: PropTypes.number,
buttonColumnWidth: PropTypes.number,
activeColumnIndex: PropTypes.number,
onActiveColumnChange: PropTypes.func,
scrollToColumn: PropTypes.number,
}
constructor(props) {
super(props);
const
cellPadding = props.cellPadding || Constants.CELL_PADDING,
lineHeight = props.lineHeight || Constants.LINE_HEIGHT,
headerHeight = props.headerHeight || Constants.HEADER_HEIGHT,
rowHeight = props.rowHeight || Constants.ROW_HEIGHT;
this.state = {
columnWidths: {},
isColumnResizing: undefined,
columnNameToDataTypeMap: {},
columnOrder: [],
currentSchema: [],
shouldActivateLeftScroll: false,
shouldActivateRightScroll: false,
scrollLeft: 0,
cellPadding,
lineHeight,
headerHeight,
rowHeight,
contentHeight: 500,
contentWidth: 500,
};
this.uniqueId = props.options.id || null;
if (this.uniqueId){
this.savedColumnsWidth = JSON.parse(localStorage.getItem('huge-table-column-widths')) || {};
this.savedColumnsWidth[this.uniqueId] = this.savedColumnsWidth[this.uniqueId] || {};
}
this.maxTitleWidth = props.options.maxTitleWidth || Constants.MAX_TITLE_WIDTH;
this.maxContentWidth = props.options.maxContentWidth || Constants.MAX_CONTENT_WIDTH;
this.fontDetails = props.options.fontDetails || Constants.FONT_DETAILS;
this.minColumnWidth = props.options.minColumnWidth || Constants.MIN_COLUMN_WIDTH;
}
componentDidMount() {
this.generateColumnToDataTypeMap(this.props.schema);
this.generateColumnWidths(this.props.schema, this.props.options.width);
this.generateInitialColumnOrder(this.props.schema);
this.checkForScrollArrows(this.state.scrollLeft);
}
componentWillReceiveProps(nextProps) {
if(this.props.schema !== nextProps.schema) {
this.generateColumnToDataTypeMap(nextProps.schema);
this.generateInitialColumnOrder(nextProps.schema);
}
if(this.props.schema !== nextProps.schema || this.props.options.width !== nextProps.options.width) {
this.generateColumnWidths(nextProps.schema, nextProps.options.width);
this.checkForScrollArrows(this.state.scrollLeft);
}
if(this.props.data.length !== nextProps.data.length) {
this.setContentHeight(nextProps.data);
}
}
componentDidUpdate(prevProps, prevState) {
if (prevState.columnOrder !== this.state.columnOrder && !_.isEqual(prevState.columnOrder, this.state.columnOrder)) {
this.reorderSchema(this.props.schema, this.state.columnOrder);
}
if (prevState.currentSchema !== this.state.currentSchema && !_.isEqual(prevState.currentSchema, this.state.currentSchema)) {
this.onSchemaChange(this.state.currentSchema, prevState.currentSchema);
this.checkForScrollArrows(this.state.scrollLeft);
}
if (prevState.currentSchema < this.state.currentSchema && this.state.shouldShowScrolls) {
this.scrollNewColumnIntoView();
this.checkForScrollArrows(this.state.scrollLeft);
}
if (prevProps.activeColumnIndex !== this.props.activeColumnIndex && this.props.onActiveColumnChange) {
this.props.onActiveColumnChange();
}
}
onScrollEnd = (scrollLeft) => {
this.setState({ scrollLeft });
}
onSchemaChange = (schema, prevSchema) => {
if (this.props.onSchemaChange) {
this.props.onSchemaChange(schema, prevSchema);
}
}
scrollNewColumnIntoView = () => {
if (this.refs.table && this.props.scrollToNewColumn) {
this.scrollAndCheckForArrows(this.refs.table.state.maxScrollX);
if (this.props.onScrollToNewColumn) {
this.props.onScrollToNewColumn();
}
}
}
reorderSchema = (schema, columnOrder) => {
const newSchema = [];
columnOrder.forEach(col => {
const newSchemaItem = schema.find(s => (s.id || s.name) === col);
if (newSchemaItem) {
newSchema.push(newSchemaItem);
}
});
this.setState({ currentSchema: newSchema });
}
setContentHeight = (data) => {
this.setState({
contentHeight: this.state.rowHeight * data.length + this.state.headerHeight,
});
}
generateInitialColumnOrder = (schema) => {
const columnOrder = schema.map(schemaItem => schemaItem.id || schemaItem.name);
this.setState({
columnOrder,
});
this.reorderSchema(schema, columnOrder);
}
generateColumnToDataTypeMap = (schema) => {
const columnNameToDataTypeMap = {};
schema.forEach((schemaItem) => {
columnNameToDataTypeMap[schemaItem.name] = schemaItem.type;
});
this.setState({columnNameToDataTypeMap});
}
generateColumnWidths = (schema, width, columnKey, newColumnWidth) => {
const columnWidths = {};
let isColumnResizing;
let contentWidth;
// Table width - rowNumberColumn (width + border) - columns border / columnsCount
const calculatedWidth = (width - Constants.ROW_NUMBER_COLUMN_WIDTH - 5 - (schema.length * 2)) / Math.max(schema.length, 1);
const defaultColumnWidth = Math.max(calculatedWidth, this.minColumnWidth);
schema.forEach((schemaItem) => {
const maxColumnWidth = this.props.resizeByContent ? this.getMaxColumnWidth(schemaItem, this.minColumnWidth) : defaultColumnWidth;
if (this.uniqueId){
//Reference the content width over the width set in state if we have data and we are passed the resizeByContent prop
if (this.props.data.length > 0 && this.props.resizeByContent) {
this.state.columnWidths[schemaItem.id || schemaItem.name] = this.savedColumnsWidth[this.uniqueId][schemaItem.id || schemaItem.name] || maxColumnWidth || this.state.columnWidths[schemaItem.id || schemaItem.name] || defaultColumnWidth;
} else {
this.state.columnWidths[schemaItem.id || schemaItem.name] = this.savedColumnsWidth[this.uniqueId][schemaItem.id || schemaItem.name] || this.state.columnWidths[schemaItem.id || schemaItem.name] || maxColumnWidth || defaultColumnWidth;
}
} else {
this.state.columnWidths[schemaItem.id || schemaItem.name] = this.state.columnWidths[schemaItem.id || schemaItem.name] || maxColumnWidth || defaultColumnWidth;
}
columnWidths[schemaItem.id || schemaItem.name] = this.state.columnWidths[schemaItem.id || schemaItem.name];
});
if (columnKey) {
columnWidths[columnKey] = newColumnWidth;
if (this.uniqueId){
this.savedColumnsWidth[this.uniqueId][columnKey] = newColumnWidth;
localStorage.setItem('huge-table-column-widths', JSON.stringify(this.savedColumnsWidth));
}
isColumnResizing = false;
}
contentWidth = schema.reduce((sum, item) => sum + columnWidths[item.id || item.name], 0) + Constants.ROW_NUMBER_COLUMN_WIDTH;
this.setState({
columnWidths,
isColumnResizing,
contentWidth,
});
}
getMaxColumnWidth = (schemaItem, defaultColumnWidth) => {
let maxColumnWidth = 0;
//Calculate the column width unless the content is an image
if (schemaItem.type !== Constants.ColumnTypes.IMAGE) {
this.props.data.forEach(row => {
const cellContent = this.getCellContent(row, schemaItem);
const cellText = this.getCellText(cellContent);
const cellColumnWidth = this.getContentSize(cellText, this.fontDetails);
maxColumnWidth = maxColumnWidth > cellColumnWidth ? maxColumnWidth : cellColumnWidth;
});
//If the content width is less than the max title width
//Set the column width based off of max title width
//Else set column width based off of content width
if (maxColumnWidth < this.maxTitleWidth) {
const titleWidth = this.getContentSize(schemaItem.name, this.fontDetails);
maxColumnWidth = Math.max(titleWidth, maxColumnWidth);
maxColumnWidth = Math.min(maxColumnWidth, this.maxTitleWidth);
} else {
maxColumnWidth = Math.min(this.maxContentWidth, maxColumnWidth);
}
}
return maxColumnWidth > defaultColumnWidth ? maxColumnWidth : defaultColumnWidth;
}
getContentSize = (txt, font) => {
this.element = document.createElement('canvas');
this.context = this.element.getContext('2d');
this.context.font = font;
const tsize = {'width':this.context.measureText(txt).width};
return tsize.width;
}
getCellContent = (row, schemaItem) => {
let content;
if (schemaItem.type === Constants.ColumnTypes.TEXT) {
const cellData = Array.isArray(row[schemaItem.name]) ? row[schemaItem.name][0] : row[schemaItem.name];
if (cellData !== undefined) {
content = cellData.text !== undefined ? cellData.text : '';
} else {
content = '';
}
} else if (schemaItem.type === Constants.ColumnTypes.IMAGE) {
content = '';
} else {
content = row[schemaItem.name + '/_text'] !== undefined ? row[schemaItem.name + '/_text'] : row[schemaItem.name];
}
return content;
}
getCellText = cellContent => {
if (Array.isArray(cellContent)) {
return this.getCellText(cellContent[0]);
} else if (typeof cellContent === 'object') {
return JSON.stringify(cellContent);
} else {
return cellContent;
}
}
createColumn = (schemaItem, idx) => {
let width = this.state.columnWidths[schemaItem.id || schemaItem.name];
const lastColumn = idx === (this.state.currentSchema.length - 1) && this.state.currentSchema.length > 1;
if (this.state.shouldShowScrolls && lastColumn) {
// this adds some extra room to accomodate the scrolling arrows
width = width + 120;
}
let cellClass = '', headerClass = '';
if (this.props.showScrollingArrows && this.state.shouldShowScrolls) {
if (this.props.hideRowNumbers && idx === 0) {
cellClass = 'hugetable-index-column nudge';
} else if (lastColumn) {
cellClass = 'last-column';
}
}
if (idx === this.props.activeColumnIndex) {
cellClass = `${cellClass} active-column`;
headerClass = 'active-column-header';
}
// if we are hiding the row numbers but showing scrolling arrows, need to nudge this column with padding
return (
<Column
cellClassName={cellClass}
headerClassName={headerClass}
header={props => this.renderHeader({...props, cellData: {main: schemaItem.name}})}
columnKey={schemaItem.id || schemaItem.name}
minWidth={this.minColumnWidth}
width={width}
cell={(props) => this.cellRenderer({...props, schemaItem })}
key={schemaItem.name}
isResizable
isReorderable
/>
);
}
renderHeader = (props) => {
if(this.props.renderers && this.props.renderers.HEADER && typeof this.props.renderers.HEADER === 'function') {
return (
<Cell {...props}>
{this.props.renderers.HEADER(props)}
</Cell>
);
} else {
return <HeaderCell {...props} />;
}
}
getCellStyles = (columnDataType) => {
let cellStyles = {};
if (columnDataType == Constants.ColumnTypes.IMAGE) {
cellStyles = StyleSheet.create({
cellStyle: {
paddingTop: Constants.IMAGE_CELL_PADDING.cellPaddingTop,
paddingBottom: Constants.IMAGE_CELL_PADDING.cellPaddingBottom,
paddingLeft: Constants.IMAGE_CELL_PADDING.cellPaddingLeft,
paddingRight: Constants.IMAGE_CELL_PADDING.cellPaddingRight,
},
});
} else {
cellStyles = StyleSheet.create({
cellStyle: {
paddingTop: Constants.CELL_PADDING.cellPaddingTop,
paddingBottom: Constants.CELL_PADDING.cellPaddingBottom,
paddingLeft: Constants.CELL_PADDING.cellPaddingLeft,
paddingRight: Constants.CELL_PADDING.cellPaddingRight,
},
});
}
return (cellStyles);
};
cellRenderer = ({rowIndex, width, height, schemaItem}) => {
const rowObject = this.props.data[rowIndex];
const cellData = {};
cellData.main = rowObject[schemaItem.name];
Constants.RETURNED_DATA_TYPES.forEach(dataType => {
cellData[dataType] = rowObject[schemaItem.name + '/_' + dataType] || null;
});
const columnDataType = this.state.columnNameToDataTypeMap[schemaItem.name];
const cellCustomRenderer = this.props.renderers && this.props.renderers[columnDataType];
cellData.type = columnDataType;
return (
<Cell className={css(this.getCellStyles(columnDataType).cellStyle)}>
{CellUtils.getComponentDataType({
columnDataType,
cellData,
width,
height,
columnKey: schemaItem.id || schemaItem.name,
mixedContentImage: this.props.options.mixedContentImage,
cellCustomRenderer,
})}
</Cell>
);
}
onColumnResizeEndCallback = (newColumnWidth, columnKey) => {
this.generateColumnWidths(this.props.schema, this.props.options.width, columnKey, newColumnWidth);
}
onScroll = (scrollLeft, scrollTop) => {
this.setState({
scrollLeft: scrollLeft ? scrollLeft : this.state.scrollLeft,
scrollTop,
});
if(this.props.options.tableScrolled) {
this.props.options.tableScrolled(scrollLeft, scrollTop);
}
}
onContentDimensionsChange = (contentHeight, contentWidth) => {
this.setState({
contentWidth,
contentHeight,
});
}
onColumnReorderEndCallback = (event) => {
const columnOrder = this.state.columnOrder.filter((columnKey) => {
return columnKey !== event.reorderColumn;
});
if (event.columnAfter) {
const index = columnOrder.indexOf(event.columnAfter);
columnOrder.splice(index, 0, event.reorderColumn);
} else {
columnOrder.push(event.reorderColumn);
}
this.setState({
columnOrder,
});
}
handleMouseEnter = (scrollVal) => {
this.intervalId = setInterval(() => this.moveScrollPos(scrollVal), 10);
}
stopScrollInterval = () => {
clearInterval(this.intervalId);
this.intervalId = undefined;
}
moveScrollPos = (val) => {
if (this.state.scrollLeft === 0 && val >= 0 || this.state.scrollLeft > 0) {
const scrollLeft = this.state.scrollLeft + val >= 0 ? this.state.scrollLeft + val : 0;
this.scrollAndCheckForArrows(scrollLeft);
}
if (this.state.scrollLeft >= this.refs.table.state.maxScrollX) {
this.stopScrollInterval();
}
}
calcElementsWidth = (elementsArr) => {
return elementsArr.map(e => e.getBoundingClientRect().width).reduce((i, n) => i+n, 0);
}
getChildElements = () => {
const headerContainer = this.getHeaderContainer();
const childElements = headerContainer ? Array.from(this.getHeaderContainer().children) : [];
return childElements;
}
handleScroll = (scrollLeft) => {
this.setState({
scrollLeft,
});
return true;
}
checkForScrollArrows = (scrollLeft, allElementsWidth) => {
const ALL_ELEMENTS_WIDTH = allElementsWidth ? allElementsWidth : this.calcElementsWidth(this.getChildElements());
const shouldShowScrolls = ALL_ELEMENTS_WIDTH > this.props.options.width && this.props.showScrollingArrows;
this.setState({
shouldShowScrolls,
shouldActivateLeftScroll: scrollLeft > 0,
shouldActivateRightScroll: ALL_ELEMENTS_WIDTH-1 > (this.props.options.width + scrollLeft),
});
return true;
}
scrollAndCheckForArrows(scrollLeft) {
this.checkForScrollArrows(scrollLeft);
this.handleScroll(scrollLeft);
}
getListContainerWidth = () => {
return this.getHeaderContainer().getBoundingClientRect().width;
}
getHeaderContainer = () => {
const headerCell = document.querySelector('.hugetable-index-column');
return headerCell ? headerCell.parentElement : null;
}
render() {
const tableWidth = this.props.options.width;
const tableHeight = this.props.options.height - this.state.headerHeight;
const rowNumberColumnWidth = this.props.options.rowNumberColumnWidth ? this.props.options.rowNumberColumnWidth : Constants.ROW_NUMBER_COLUMN_WIDTH;
let leftScroll, rightScroll;
if(this.state.shouldShowScrolls) {
// increase the size of the row number column so there is no overlap
leftScroll = (
<section style={{ height: this.state.headerHeight }} className={classNames('scroll-toggle', 'left', {'active': this.state.shouldActivateLeftScroll})} onMouseEnter={() => this.handleMouseEnter(-10)} onMouseLeave={() => this.stopScrollInterval()}>
<i className="fa fa-chevron-left fa-lg"></i>
</section>
);
rightScroll = (
<section style={{ height: this.state.headerHeight }} className={classNames('scroll-toggle', 'right', {'active': this.state.shouldActivateRightScroll})} onMouseEnter={() => this.handleMouseEnter(10)} onMouseLeave={() => this.stopScrollInterval()}>
<i className="fa fa-chevron-right fa-lg"></i>
</section>
);
}
return (
<TouchWrapper
onScroll={this.onScroll}
tableWidth={tableWidth}
tableHeight={tableHeight}
contentWidth={this.state.contentWidth}
contentHeight={this.state.contentHeight}
>
<div style={{ position: 'relative', height: tableHeight, width: tableWidth }}>
{leftScroll}
{rightScroll}
<Table
onHorizontalScroll={this.checkForScrollArrows}
onScrollEnd={this.onScrollEnd}
ref="table"
rowHeight={this.state.rowHeight}
rowsCount={this.props.data.length}
width={tableWidth}
scrollLeft={this.state.scrollLeft}
scrollTop={this.state.scrollTop}
height={tableHeight}
headerHeight={this.state.headerHeight}
isColumnResizing={this.state.isColumnResizing}
onColumnResizeEndCallback={this.onColumnResizeEndCallback}
onContentDimensionsChange={this.onContentDimensionsChange}
onColumnReorderEndCallback={this.onColumnReorderEndCallback}
scrollToColumn={this.props.scrollToColumn}
isColumnReordering={false}
>
{(() => {
if (!this.props.hideRowNumbers) {
return (
<Column
cellClassName="hugetable-index-column"
key="hugetable-index-column"
columnKey="hugetable-index-column"
width={rowNumberColumnWidth}
header={props => this.renderHeader({...props, cellData: {main: '#'}})}
cell={(props) => <Cell><TextCell {...props} cellData={{main: props.rowIndex+1}}/></Cell>}
/>
);
}
})()}
{this.state.currentSchema.map(this.createColumn)}
{(() => {
if (this.state.shouldShowScrolls) {
return (
<Column
cellClassName="huge-table-right-scroll-column"
key="huge-table-right-scroll-column"
columnKey="huge-table-right-scroll-column"
width={this.props.buttonColumnWidth ? 40 + this.props.buttonColumnWidth : 40}
header={props => this.renderHeader({...props, cellData: {main: ''}})}
cell={(props) => <Cell><TextCell {...props} cellData={{main: ''}}/></Cell>}
/>
);
}
})()}
</Table>
</div>
</TouchWrapper>
);
}
}