From af7830d94a3c26f38b8dcdcc255478d54595282b Mon Sep 17 00:00:00 2001 From: Timur Valiev Date: Tue, 23 Jul 2019 07:57:00 -0700 Subject: [PATCH] immutable data structures in tables 1/n: init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Migrating tables' row collection to Immutable.js List: 1. Сopy ManagedTable code, no changes 2. Add deps to Immutable.js ----- Current implementation of tables forces to copy arrays on new data arrival which causes O(N^2) complexity -> tables can't handle a lot of new rows in short period of time -> tables freeze and become unresponsive for a few seconds. Immutable data structures will bring us to O(N) complexity Reviewed By: jknoxville Differential Revision: D16416870 fbshipit-source-id: d3e1a9571ea08fa7ccaedc5ad3eca863d22a79a4 --- package.json | 1 + .../table/ManagedTable_immutable.js | 722 ++++++++++++++++++ yarn.lock | 5 + 3 files changed, 728 insertions(+) create mode 100644 src/ui/components/table/ManagedTable_immutable.js diff --git a/package.json b/package.json index f3fa9efac..3841402a7 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "expand-tilde": "^2.0.2", "express": "^4.15.2", "fs-extra": "^8.0.1", + "immutable": "^4.0.0-rc.12", "invariant": "^2.2.2", "line-replace": "^1.0.2", "lodash.debounce": "^4.0.8", diff --git a/src/ui/components/table/ManagedTable_immutable.js b/src/ui/components/table/ManagedTable_immutable.js new file mode 100644 index 000000000..d71232aef --- /dev/null +++ b/src/ui/components/table/ManagedTable_immutable.js @@ -0,0 +1,722 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type { + TableColumnOrder, + TableColumnSizes, + TableColumns, + TableHighlightedRows, + TableRowSortOrder, + TableRows, + TableBodyRow, + TableOnAddFilter, +} from './types.js'; + +import type {MenuTemplate} from '../ContextMenu.js'; + +import React from 'react'; +import styled from '../../styled/index.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 '../../../fb-stubs/createPaste.js'; +import debounceRender from 'react-debounce-render'; +import debounce from 'lodash.debounce'; +import {DEFAULT_ROW_HEIGHT} from './types'; +import textContent from '../../../utils/textContent.js'; + +export type ManagedTableProps = {| + /** + * Column definitions. + */ + columns: TableColumns, + /** + * Row definitions. + */ + rows: TableRows, + /* + * Globally unique key for persisting data between uses of a table such as column sizes. + */ + tableKey?: string, + /** + * Whether the table has a border. + */ + floating?: boolean, + /** + * Whether a row can span over multiple lines. Otherwise lines cannot wrap and + * are truncated. + */ + multiline?: boolean, + /** + * Whether the body is scrollable. When this is set to `true` then the table + * is not scrollable. + */ + autoHeight?: boolean, + /** + * Order of columns. + */ + columnOrder?: TableColumnOrder, + /** + * Initial size of the columns. + */ + columnSizes?: TableColumnSizes, + /** + * Value to filter rows on. Alternative to the `filter` prop. + */ + filterValue?: string, + /** + * Callback to filter rows. + */ + filter?: (row: TableBodyRow) => boolean, + /** + * Callback when the highlighted rows change. + */ + onRowHighlighted?: (keys: TableHighlightedRows) => void, + /** + * Whether rows can be highlighted or not. + */ + highlightableRows?: boolean, + /** + * Whether multiple rows can be highlighted or not. + */ + multiHighlight?: boolean, + /** + * Height of each row. + */ + rowLineHeight?: number, + /** + * This makes it so the scroll position sticks to the bottom of the window. + * Useful for streaming data like requests, logs etc. + */ + stickyBottom?: boolean, + /** + * Used by SearchableTable to add filters for rows. + */ + onAddFilter?: TableOnAddFilter, + /** + * Enable or disable zebra striping. + */ + zebra?: boolean, + /** + * Whether to hide the column names at the top of the table. + */ + hideHeader?: boolean, + /** + * Rows that are highlighted initially. + */ + highlightedRows?: Set, + /** + * Allows to create context menu items for rows. + */ + buildContextMenuItems?: () => MenuTemplate, + initialSortOrder?: ?TableRowSortOrder, + /** + * Callback when sorting changes. + */ + onSort?: (order: TableRowSortOrder) => void, + /** + * Initial sort order of the table. + */ + initialSortOrder?: ?TableRowSortOrder, + /** + * Table scroll horizontally, if needed + */ + horizontallyScrollable?: boolean, +|}; + +type ManagedTableState = {| + highlightedRows: Set, + sortOrder: ?TableRowSortOrder, + columnOrder: TableColumnOrder, + columnSizes: TableColumnSizes, + shouldScrollToBottom: boolean, +|}; + +const Container = styled(FlexColumn)(props => ({ + overflow: props.canOverflow ? 'scroll' : 'visible', + flexGrow: 1, +})); + +const globalTableState: {[string]: TableColumnSizes} = {}; + +class ManagedTable extends React.Component< + ManagedTableProps, + ManagedTableState, +> { + static defaultProps = { + highlightableRows: true, + multiHighlight: false, + autoHeight: false, + }; + + getTableKey = (): string => { + return ( + 'TABLE_COLUMNS_' + + Object.keys(this.props.columns) + .join('_') + .toUpperCase() + ); + }; + + state: ManagedTableState = { + columnOrder: + JSON.parse(window.localStorage.getItem(this.getTableKey()) || 'null') || + this.props.columnOrder || + Object.keys(this.props.columns).map(key => ({key, visible: true})), + columnSizes: + this.props.tableKey && globalTableState[this.props.tableKey] + ? globalTableState[this.props.tableKey] + : this.props.columnSizes || {}, + highlightedRows: this.props.highlightedRows || new Set(), + sortOrder: this.props.initialSortOrder || null, + shouldScrollToBottom: Boolean(this.props.stickyBottom), + }; + + tableRef = React.createRef(); + + scrollRef: { + current: null | HTMLDivElement, + } = React.createRef(); + + dragStartIndex: ?number = null; + + // We want to call scrollToHighlightedRows on componentDidMount. However, at + // this time, tableRef is still null, because AutoSizer needs one render to + // measure the size of the table. This is why we are using this flag to + // trigger actions on the first update instead. + firstUpdate = true; + + componentDidMount() { + document.addEventListener('keydown', this.onKeyDown); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.onKeyDown); + } + + componentWillReceiveProps(nextProps: ManagedTableProps) { + // if columnSizes has changed + if (nextProps.columnSizes !== this.props.columnSizes) { + this.setState({ + columnSizes: { + ...(this.state.columnSizes || {}), + ...nextProps.columnSizes, + }, + }); + } + + if (this.props.highlightedRows !== nextProps.highlightedRows) { + this.setState({highlightedRows: nextProps.highlightedRows}); + } + + // if columnOrder has changed + if (nextProps.columnOrder !== this.props.columnOrder) { + if (this.tableRef && this.tableRef.current) { + this.tableRef.current.resetAfterIndex(0); + } + this.setState({ + columnOrder: nextProps.columnOrder, + }); + } + + if ( + this.props.rows.length > nextProps.rows.length && + this.tableRef && + this.tableRef.current + ) { + // rows were filtered, we need to recalculate heights + this.tableRef.current.resetAfterIndex(0); + } + } + + componentDidUpdate( + prevProps: ManagedTableProps, + prevState: ManagedTableState, + ) { + if ( + this.props.rows.length !== prevProps.rows.length && + this.state.shouldScrollToBottom && + this.state.highlightedRows.size < 2 + ) { + this.scrollToBottom(); + } else if ( + prevState.highlightedRows !== this.state.highlightedRows || + this.firstUpdate + ) { + this.scrollToHighlightedRows(); + } + if ( + this.props.stickyBottom && + !this.state.shouldScrollToBottom && + this.scrollRef && + this.scrollRef.current && + this.scrollRef.current.parentElement && + this.scrollRef.current.parentElement instanceof HTMLElement && + this.scrollRef.current.offsetHeight <= + this.scrollRef.current.parentElement.offsetHeight + ) { + this.setState({shouldScrollToBottom: true}); + } + this.firstUpdate = false; + } + + scrollToHighlightedRows = () => { + const {current} = this.tableRef; + const {highlightedRows} = this.state; + if (current && highlightedRows && highlightedRows.size > 0) { + const highlightedRow = Array.from(highlightedRows)[0]; + const index = this.props.rows.findIndex( + ({key}) => key === highlightedRow, + ); + if (index >= 0) { + current.scrollToItem(index); + } + } + }; + + onCopy = (withHeader: boolean) => { + clipboard.writeText( + [ + ...(withHeader ? [this.getHeaderText()] : []), + this.getSelectedText(), + ].join('\n'), + ); + }; + + onKeyDown = (e: KeyboardEvent) => { + const {highlightedRows} = this.state; + if (highlightedRows.size === 0) { + return; + } + if ( + ((e.metaKey && process.platform === 'darwin') || + (e.ctrlKey && process.platform !== 'darwin')) && + e.keyCode === 67 + ) { + this.onCopy(false); + } else if ( + (e.keyCode === 38 || e.keyCode === 40) && + this.props.highlightableRows + ) { + // 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.onRowHighlighted(highlightedRows, () => { + const {current} = this.tableRef; + if (current) { + current.scrollToItem(newIndex); + } + }); + } + }; + + onRowHighlighted = (highlightedRows: Set, cb?: Function) => { + if (!this.props.highlightableRows) { + return; + } + this.setState({highlightedRows}, cb); + const {onRowHighlighted} = this.props; + if (onRowHighlighted) { + onRowHighlighted(Array.from(highlightedRows)); + } + }; + + onSort = (sortOrder: TableRowSortOrder) => { + this.setState({sortOrder}); + this.props.onSort && this.props.onSort(sortOrder); + }; + + onColumnOrder = (columnOrder: TableColumnOrder) => { + this.setState({columnOrder}); + // persist column order + window.localStorage.setItem( + this.getTableKey(), + JSON.stringify(columnOrder), + ); + }; + + onColumnResize = (id: string, width: number | string) => { + this.setState(({columnSizes}) => ({ + columnSizes: { + ...columnSizes, + [id]: width, + }, + })); + if (!this.props.tableKey) { + return; + } + if (!globalTableState[this.props.tableKey]) { + globalTableState[this.props.tableKey] = {}; + } + globalTableState[this.props.tableKey][id] = width; + }; + + scrollToBottom() { + const {current: tableRef} = this.tableRef; + + if (tableRef && this.props.rows.length > 1) { + tableRef.scrollToItem(this.props.rows.length - 1); + } + } + + onHighlight = ( + e: SyntheticMouseEvent<>, + row: TableBodyRow, + index: number, + ) => { + if (e.shiftKey) { + // prevents text selection + e.preventDefault(); + } + + let {highlightedRows} = this.state; + + const contextClick = + e.button !== 0 || + (process.platform === 'darwin' && e.button === 0 && e.ctrlKey); + + if (contextClick) { + if (!highlightedRows.has(row.key)) { + highlightedRows.clear(); + highlightedRows.add(row.key); + } + return; + } + + this.dragStartIndex = index; + document.addEventListener('mouseup', this.onStopDragSelecting); + + if ( + ((process.platform === 'darwin' && e.metaKey) || + (process.platform !== 'darwin' && e.ctrlKey)) && + this.props.multiHighlight + ) { + highlightedRows.add(row.key); + } else if (e.shiftKey && this.props.multiHighlight) { + // 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.onRowHighlighted(highlightedRows); + }; + + onStopDragSelecting = () => { + this.dragStartIndex = null; + document.removeEventListener('mouseup', this.onStopDragSelecting); + }; + + selectInRange = (fromKey: string, toKey: string): Array => { + 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 && + this.props.multiHighlight && + this.props.highlightableRows && + !e.shiftKey // When shift key is pressed, it's a range select not a drag select + ) { + current.scrollToItem(index + 1); + const startKey = this.props.rows[dragStartIndex].key; + const highlightedRows = new Set(this.selectInRange(startKey, row.key)); + this.onRowHighlighted(highlightedRows); + } + }; + + onCopyCell = (rowId: string, index: number) => { + const cellText = this.getTextContentOfRow(rowId)[index]; + clipboard.writeText(cellText); + }; + + buildContextMenuItems: () => Array = () => { + const {highlightedRows} = this.state; + if (highlightedRows.size === 0) { + return []; + } + + const copyCellSubMenu = + highlightedRows.size === 1 + ? [ + { + label: 'Copy cell', + submenu: this.state.columnOrder + .filter(c => c.visible) + .map(c => c.key) + .map((column, index) => ({ + label: this.props.columns[column].value, + click: () => { + const rowId = this.state.highlightedRows.values().next() + .value; + rowId && this.onCopyCell(rowId, index); + }, + })), + }, + ] + : []; + + return [ + ...copyCellSubMenu, + { + label: + highlightedRows.size > 1 + ? `Copy ${highlightedRows.size} rows` + : 'Copy row', + submenu: [ + {label: 'With columns header', click: () => this.onCopy(true)}, + { + label: 'Without columns header', + click: () => { + this.onCopy(false); + }, + }, + ], + }, + { + label: 'Create Paste', + click: () => + createPaste( + [this.getHeaderText(), this.getSelectedText()].join('\n'), + ), + }, + ]; + }; + + getHeaderText = (): string => { + return this.state.columnOrder + .filter(c => c.visible) + .map(c => c.key) + .map(key => this.props.columns[key].value) + .join('\t'); + }; + + 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 || this.getTextContentOfRow(row.key).join('\t'), + ) + .join('\n'); + }; + + getTextContentOfRow = (key: string): Array => { + const row = this.props.rows.find(row => row.key === key); + if (!row) { + return []; + } + return this.state.columnOrder + .filter(({visible}) => visible) + .map(({key}) => textContent(row.columns[key].value)); + }; + + onScroll = debounce( + ({ + scrollDirection, + scrollOffset, + }: { + scrollDirection: 'forward' | 'backward', + scrollOffset: number, + scrollUpdateWasRequested: boolean, + }) => { + const {current} = this.scrollRef; + const parent = current ? current.parentElement : null; + if ( + this.props.stickyBottom && + current && + parent instanceof HTMLElement && + scrollDirection === 'forward' && + !this.state.shouldScrollToBottom && + current.offsetHeight - parent.offsetHeight === scrollOffset + ) { + this.setState({shouldScrollToBottom: true}); + } else if ( + this.props.stickyBottom && + scrollDirection === 'backward' && + this.state.shouldScrollToBottom + ) { + this.setState({shouldScrollToBottom: false}); + } + }, + 100, + ); + + getRow = ({index, style}) => { + const { + onAddFilter, + multiline, + zebra, + rows, + horizontallyScrollable, + } = this.props; + const {columnOrder, columnSizes, highlightedRows} = this.state; + const columnKeys = columnOrder + .map(k => (k.visible ? k.key : null)) + .filter(Boolean); + + return ( + 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} + horizontallyScrollable={horizontallyScrollable} + /> + ); + }; + + render() { + const { + columns, + rows, + rowLineHeight, + hideHeader, + horizontallyScrollable, + } = this.props; + const {columnOrder, columnSizes} = this.state; + + let computedWidth = 0; + if (horizontallyScrollable) { + for (let index = 0; index < columnOrder.length; index++) { + const col = columnOrder[index]; + + if (!col.visible) { + continue; + } + + const width = columnSizes[col.key]; + if (isNaN(width)) { + // non-numeric columns with, can't caluclate + computedWidth = 0; + break; + } else { + computedWidth += parseInt(width, 10); + } + } + } + + return ( + + {hideHeader !== true && ( + + )} + + {this.props.autoHeight ? ( + this.props.rows.map((_, index) => this.getRow({index, style: {}})) + ) : ( + + {({width, height}) => ( + + + (rows[index] && rows[index].height) || + rowLineHeight || + DEFAULT_ROW_HEIGHT + } + ref={this.tableRef} + width={Math.max(width, computedWidth)} + estimatedItemSize={rowLineHeight || DEFAULT_ROW_HEIGHT} + overscanCount={5} + innerRef={this.scrollRef} + onScroll={this.onScroll} + height={height}> + {this.getRow} + + + )} + + )} + + + ); + } +} + +export default debounceRender(ManagedTable, 150, {maxWait: 250}); diff --git a/yarn.lock b/yarn.lock index d30edd989..7edd342d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4003,6 +4003,11 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== +immutable@^4.0.0-rc.12: + version "4.0.0-rc.12" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0-rc.12.tgz#ca59a7e4c19ae8d9bf74a97bdf0f6e2f2a5d0217" + integrity sha512-0M2XxkZLx/mi3t8NVwIm1g8nHoEmM9p9UBl/G9k4+hm0kBgOVdMV/B3CY5dQ8qG8qc80NN4gDV4HQv6FTJ5q7A== + import-fresh@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.0.0.tgz#a3d897f420cab0e671236897f75bc14b4885c390"