moving tables to react-window

Summary:
Tables were using a custom virtualization, which wasn't as performant as other solutions out there. In this diff, the table component is reworked for performance.
- removes `Table` component, because it was never used standalone, `ManagedTable` is what all plugins used
- uses `react-window` for `ManagedTable`
- reworks table highlighting and arrow-navigation to work with the new virtualization
- moves actual filtering out of `ManagedTable` into `Searchable` component for a better separation of concerns.

Reviewed By: jknoxville

Differential Revision: D9447721

fbshipit-source-id: 15eb2eb55eed9f49a0cb1ccfb2d748b3672fa898
This commit is contained in:
Daniel Büchele
2018-08-23 04:43:51 -07:00
committed by Facebook Github Bot
parent 33f34650df
commit 1891e2c869
13 changed files with 611 additions and 1253 deletions

View File

@@ -6,7 +6,6 @@
*/
import type {
TableColumnRawOrder,
TableColumnOrder,
TableColumnSizes,
TableColumns,
@@ -16,8 +15,18 @@ import type {
TableBodyRow,
TableOnAddFilter,
} from './types.js';
import React from 'react';
import styled from '../../styled/index.js';
import Table from './Table.js';
import AutoSizer from 'react-virtualized-auto-sizer';
import {VariableSizeList as List} from 'react-window';
import {clipboard} from 'electron';
import TableHead from './TableHead.js';
import TableRow from './TableRow.js';
import ContextMenu from '../ContextMenu.js';
import FlexColumn from '../FlexColumn.js';
import createPaste from '../../../utils/createPaste.js';
import {DEFAULT_ROW_HEIGHT} from './types';
export type ManagedTableProps = {|
/**
@@ -50,7 +59,7 @@ export type ManagedTableProps = {|
/**
* Order of columns.
*/
columnOrder?: ?TableColumnRawOrder,
columnOrder?: TableColumnOrder,
/**
* Size of the columns.
*/
@@ -99,10 +108,11 @@ export type ManagedTableProps = {|
|};
type ManagedTableState = {|
highlightedRows: TableHighlightedRows,
highlightedRows: Set<string>,
sortOrder: ?TableRowSortOrder,
columnOrder: ?TableColumnRawOrder,
columnSizes: ?TableColumnSizes,
columnOrder: TableColumnOrder,
columnSizes: TableColumnSizes,
shouldScrollToBottom: boolean,
|};
/**
@@ -111,7 +121,7 @@ type ManagedTableState = {|
* If you require lower level access to the state then use [`<Table>`]()
* directly.
*/
export default class ManagedTable extends styled.StylablePureComponent<
export default class ManagedTable extends styled.StylableComponent<
ManagedTableProps,
ManagedTableState,
> {
@@ -124,16 +134,32 @@ export default class ManagedTable extends styled.StylablePureComponent<
);
};
state = {
state: ManagedTableState = {
columnOrder:
JSON.parse(window.localStorage.getItem(this.getTableKey()) || 'null') ||
this.props.columnOrder,
columnSizes: this.props.columnSizes,
highlightedRows: [],
this.props.columnOrder ||
Object.keys(this.props.columns).map(key => ({key, visible: true})),
columnSizes: this.props.columnSizes || {},
highlightedRows: new Set(),
sortOrder: null,
shouldScrollToBottom: Boolean(this.props.stickyBottom),
};
tableRef: ?Table;
tableRef: {
current: null | List,
} = React.createRef();
scrollRef: {
current: null | HTMLDivElement,
} = React.createRef();
dragStartIndex: ?number = null;
componentDidMount() {
document.addEventListener('keydown', this.onKeyDown);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onKeyDown);
}
componentWillReceiveProps(nextProps: ManagedTableProps) {
// if columnSizes has changed
@@ -152,20 +178,64 @@ export default class ManagedTable extends styled.StylablePureComponent<
columnOrder: nextProps.columnOrder,
});
}
if (
nextProps.filter !== this.props.filter &&
this.tableRef &&
this.tableRef.current
) {
// rows were filtered, we need to recalculate heights
this.tableRef.current.resetAfterIndex(0);
}
}
onHighlight = (highlightedRows: TableHighlightedRows) => {
if (this.props.highlightableRows === false) {
componentDidUpdate(prevProps: ManagedTableProps) {
if (
this.props.rows.length !== prevProps.rows.length &&
this.state.shouldScrollToBottom &&
this.state.highlightedRows.size < 2
) {
this.scrollToBottom();
}
}
onCopy = () => {
clipboard.writeText(this.getSelectedText());
};
onKeyDown = (e: KeyboardEvent) => {
const {highlightedRows} = this.state;
if (highlightedRows.size === 0) {
return;
}
if (this.props.multiHighlight !== true) {
highlightedRows = highlightedRows.slice(0, 1);
}
this.setState({highlightedRows});
if (this.props.onRowHighlighted) {
this.props.onRowHighlighted(highlightedRows);
if (
((e.metaKey && process.platform === 'darwin') ||
(e.ctrlKey && process.platform !== 'darwin')) &&
e.keyCode === 67
) {
this.onCopy();
} else if (e.keyCode === 38 || e.keyCode === 40) {
// arrow navigation
const {rows} = this.props;
const {highlightedRows} = this.state;
const lastItemKey = Array.from(this.state.highlightedRows).pop();
const lastItemIndex = this.props.rows.findIndex(
row => row.key === lastItemKey,
);
const newIndex = Math.min(
rows.length - 1,
Math.max(0, e.keyCode === 38 ? lastItemIndex - 1 : lastItemIndex + 1),
);
if (!e.shiftKey) {
highlightedRows.clear();
}
highlightedRows.add(rows[newIndex].key);
this.setState({highlightedRows}, () => {
const {current} = this.tableRef;
if (current) {
current.scrollToItem(newIndex);
}
});
}
};
@@ -174,7 +244,6 @@ export default class ManagedTable extends styled.StylablePureComponent<
};
onColumnOrder = (columnOrder: TableColumnOrder) => {
// $FlowFixMe
this.setState({columnOrder});
// persist column order
window.localStorage.setItem(
@@ -187,45 +256,246 @@ export default class ManagedTable extends styled.StylablePureComponent<
this.setState({columnSizes});
};
setRef = (table: ?Table) => {
this.tableRef = table;
};
scrollToBottom() {
const {tableRef} = this;
if (tableRef) {
tableRef.scrollToBottom();
const {current} = this.tableRef;
if (current && this.props.rows.length > 1) {
current.scrollToItem(this.props.rows.length - 1);
}
}
onHighlight = (
e: SyntheticMouseEvent<>,
row: TableBodyRow,
index: number,
) => {
if (e.button !== 0) {
// Only highlight rows when using primary mouse button,
// otherwise do nothing, to not interfere context menus.
return;
}
if (e.shiftKey) {
// prevents text selection
e.preventDefault();
}
let {highlightedRows} = this.state;
this.dragStartIndex = index;
document.addEventListener('mouseup', this.onStopDragSelecting);
if (
(e.metaKey && process.platform === 'darwin') ||
(e.ctrlKey && process.platform !== 'darwin')
) {
highlightedRows.add(row.key);
} else if (e.shiftKey) {
// range select
const lastItemKey = Array.from(this.state.highlightedRows).pop();
highlightedRows = new Set([
...highlightedRows,
...this.selectInRange(lastItemKey, row.key),
]);
} else {
// single select
this.state.highlightedRows.clear();
this.state.highlightedRows.add(row.key);
}
this.setState({highlightedRows});
};
onStopDragSelecting = () => {
this.dragStartIndex = null;
document.removeEventListener('mouseup', this.onStopDragSelecting);
};
selectInRange = (fromKey: string, toKey: string): Array<string> => {
const selected = [];
let startIndex = -1;
let endIndex = -1;
for (let i = 0; i < this.props.rows.length; i++) {
if (this.props.rows[i].key === fromKey) {
startIndex = i;
}
if (this.props.rows[i].key === toKey) {
endIndex = i;
}
if (endIndex > -1 && startIndex > -1) {
break;
}
}
for (
let i = Math.min(startIndex, endIndex);
i <= Math.max(startIndex, endIndex);
i++
) {
try {
selected.push(this.props.rows[i].key);
} catch (e) {}
}
return selected;
};
onMouseEnterRow = (
e: SyntheticMouseEvent<>,
row: TableBodyRow,
index: number,
) => {
const {dragStartIndex} = this;
const {current} = this.tableRef;
if (dragStartIndex && current) {
current.scrollToItem(index + 1);
const startKey = this.props.rows[dragStartIndex].key;
const highlightedRows = new Set(this.selectInRange(startKey, row.key));
this.setState({
highlightedRows,
});
}
};
buildContextMenuItems = () => {
const {highlightedRows} = this.state;
if (highlightedRows.size === 0) {
return [];
}
return [
{
label:
highlightedRows.size > 1
? `Copy ${highlightedRows.size} rows`
: 'Copy row',
click: this.onCopy,
},
{
label: 'Create Paste',
click: () => createPaste(this.getSelectedText()),
},
];
};
getSelectedText = (): string => {
const {highlightedRows} = this.state;
if (highlightedRows.size === 0) {
return '';
}
return this.props.rows
.filter(row => highlightedRows.has(row.key))
.map(
(row: TableBodyRow) =>
row.copyText ||
Array.from(
document.querySelectorAll(`[data-key='${row.key}'] > *`) || [],
)
.map(node => node.textContent)
.join('\t'),
)
.join('\n');
};
onScroll = ({
scrollDirection,
scrollOffset,
}: {
scrollDirection: 'forward' | 'backward',
scrollOffset: number,
scrollUpdateWasRequested: boolean,
}) => {
const {current} = this.scrollRef;
const parent = current ? current.parentElement : null;
if (
this.props.stickyBottom &&
scrollDirection === 'forward' &&
!this.state.shouldScrollToBottom &&
current &&
parent instanceof HTMLElement &&
current.offsetHeight - (scrollOffset + parent.offsetHeight) <
parent.offsetHeight
) {
this.setState({shouldScrollToBottom: true});
} else if (
this.props.stickyBottom &&
scrollDirection === 'backward' &&
this.state.shouldScrollToBottom
) {
this.setState({shouldScrollToBottom: false});
}
};
render() {
const {props, state} = this;
const {
onAddFilter,
columns,
multiline,
zebra,
rows,
rowLineHeight,
} = this.props;
const {columnOrder, columnSizes, highlightedRows} = this.state;
const columnKeys = columnOrder
.map(k => (k.visible ? k.key : null))
.filter(Boolean);
return (
<Table
ref={this.setRef}
virtual={props.virtual}
floating={props.floating}
multiline={props.multiline}
columns={props.columns}
rows={props.rows}
rowLineHeight={props.rowLineHeight}
autoHeight={props.autoHeight}
filter={props.filter}
filterValue={props.filterValue}
highlightedRows={state.highlightedRows}
onHighlight={this.onHighlight}
sortOrder={state.sortOrder}
onSort={this.onSort}
columnOrder={state.columnOrder}
onColumnOrder={this.onColumnOrder}
columnSizes={state.columnSizes}
onColumnResize={this.onColumnResize}
stickyBottom={props.stickyBottom}
onAddFilter={props.onAddFilter}
zebra={props.zebra}
hideHeader={props.hideHeader}
/>
<FlexColumn style={{flexGrow: 1}}>
<TableHead
columnOrder={columnOrder}
onColumnOrder={this.onColumnOrder}
columns={columns}
onColumnResize={this.onColumnResize}
sortOrder={this.state.sortOrder}
columnSizes={columnSizes}
onSort={this.onSort}
/>
<FlexColumn
style={{
flexGrow: 1,
}}>
<AutoSizer>
{({width, height}) => (
<ContextMenu buildItems={this.buildContextMenuItems}>
<List
itemCount={this.props.rows.length}
itemSize={index =>
(rows[index] && rows[index].height) ||
rowLineHeight ||
DEFAULT_ROW_HEIGHT
}
ref={this.tableRef}
width={width}
estimatedItemSize={rowLineHeight || DEFAULT_ROW_HEIGHT}
overscanCount={5}
innerRef={this.scrollRef}
onScroll={this.onScroll}
height={height}>
{({index, style}) => (
<TableRow
columnSizes={columnSizes}
columnKeys={columnKeys}
onMouseDown={e => this.onHighlight(e, rows[index], index)}
onMouseEnter={e =>
this.onMouseEnterRow(e, rows[index], index)
}
multiline={multiline}
rowLineHeight={24}
highlighted={highlightedRows.has(rows[index].key)}
row={rows[index]}
index={index}
style={style}
onAddFilter={onAddFilter}
zebra={zebra}
/>
)}
</List>
</ContextMenu>
)}
</AutoSizer>
</FlexColumn>
</FlexColumn>
);
}
}