/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import { TableColumnOrder, TableColumnSizes, TableColumns, TableHighlightedRows, TableRowSortOrder, TableRows, TableBodyRow, TableOnAddFilter, } from './types'; import {MenuTemplate} from '../ContextMenu'; import React from 'react'; import styled from '@emotion/styled'; import AutoSizer from 'react-virtualized-auto-sizer'; import {VariableSizeList as List} from 'react-window'; import {clipboard, MenuItemConstructorOptions} from 'electron'; import TableHead from './TableHead'; import TableRow from './TableRow'; import ContextMenu from '../ContextMenu'; import FlexColumn from '../FlexColumn'; import createPaste from '../../../fb-stubs/createPaste'; import debounceRender from 'react-debounce-render'; import {debounce} from 'lodash'; import {DEFAULT_ROW_HEIGHT} from './types'; import textContent from '../../../utils/textContent'; import {notNull} from '../../../utils/typeUtils'; const EMPTY_OBJECT = {}; Object.freeze(EMPTY_OBJECT); 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; /** * Callback when sorting changes. */ onSort?: (order: TableRowSortOrder) => void; /** * Initial sort order of the table. */ initialSortOrder?: TableRowSortOrder; /** * Table scroll horizontally, if needed */ horizontallyScrollable?: boolean; /** * Whether to allow navigation via arrow keys. Default: true */ enableKeyboardNavigation?: boolean; /** * Reference to the managed table. */ innerRef?: | React.MutableRefObject | ((ref: ManagedTable | undefined) => void); }; type ManagedTableState = { highlightedRows: Set; sortOrder?: TableRowSortOrder; columnOrder: TableColumnOrder; columnKeys: string[]; columnSizes: TableColumnSizes; shouldScrollToBottom: boolean; }; const Container = styled(FlexColumn)<{canOverflow?: boolean}>((props) => ({ overflow: props.canOverflow ? 'scroll' : 'visible', flexGrow: 1, height: '100%', })); Container.displayName = 'ManagedTable:Container'; const globalTableState: {[key: string]: TableColumnSizes} = {}; export class ManagedTable extends React.Component< ManagedTableProps, ManagedTableState > { static defaultProps = { highlightableRows: true, multiHighlight: false, autoHeight: false, enableKeyboardNavigation: true, }; getTableKey = (): string => { return ( 'TABLE_COLUMNS_' + Object.keys(this.props.columns).join('_').toUpperCase() ); }; tableRef = React.createRef(); scrollRef: { current: null | HTMLDivElement; } = React.createRef(); dragStartIndex: number | null = 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; constructor(props: ManagedTableProps) { super(props); const columnOrder = JSON.parse(window.localStorage.getItem(this.getTableKey()) || 'null') || this.props.columnOrder || Object.keys(this.props.columns).map((key) => ({key, visible: true})); this.state = { columnOrder, columnKeys: this.computeColumnKeys(columnOrder), 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 || undefined, shouldScrollToBottom: Boolean(this.props.stickyBottom), }; } componentDidMount() { document.addEventListener('keydown', this.onKeyDown); if (typeof this.props.innerRef === 'function') { this.props.innerRef(this); } else if (this.props.innerRef) { this.props.innerRef.current = this; } } componentWillUnmount() { document.removeEventListener('keydown', this.onKeyDown); if (typeof this.props.innerRef === 'function') { this.props.innerRef(undefined); } else if (this.props.innerRef) { this.props.innerRef.current = undefined; } } UNSAFE_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 || new Set()}); } // if columnOrder has changed if ( nextProps.columnOrder !== this.props.columnOrder && nextProps.columnOrder ) { if (this.tableRef && this.tableRef.current) { this.tableRef.current.resetAfterIndex(0, true); } this.setState({ columnOrder: nextProps.columnOrder, columnKeys: this.computeColumnKeys(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, true); } } 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; } computeColumnKeys(columnOrder: TableColumnOrder) { return columnOrder.map((k) => (k.visible ? k.key : null)).filter(notNull); } 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 && this.props.enableKeyboardNavigation ) { // 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?: () => void) => { 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: React.MouseEvent, row: TableBodyRow, index: number) => { if (!this.props.highlightableRows) { return; } 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(highlightedRows).pop()!; highlightedRows = new Set([ ...highlightedRows, ...this.selectInRange(lastItemKey, row.key), ]); } else { // single select highlightedRows.clear(); 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: React.MouseEvent, 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) => typeof row.copyText === 'function' ? row.copyText() : 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}: {index: number; style: React.CSSProperties}) => { const {onAddFilter, multiline, zebra, rows} = this.props; const {columnKeys, columnSizes, highlightedRows} = this.state; return ( ); }; 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 (typeof width === 'number' && isNaN(width)) { // non-numeric columns with, can't caluclate computedWidth = 0; break; } else { computedWidth += parseInt(String(width), 10); } } } return ( {hideHeader !== true && ( )} {this.props.autoHeight ? ( {this.props.rows.map((_, index) => this.getRow({index, style: EMPTY_OBJECT}), )} ) : ( {({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});