Files
flipper/desktop/app/src/ui/components/table/ManagedTable.tsx
Michel Weststrate 64e791e253 Remove usage of Electron context menus
Summary: Removed the usage of electron's native context menus, and replaces it with Antd based context menu's.

Reviewed By: passy

Differential Revision: D31990756

fbshipit-source-id: 0312cbac5fd20a1a30603ce1058c03f4291b23b1
2021-10-28 07:41:00 -07:00

754 lines
20 KiB
TypeScript

/**
* 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 {ContextMenuItem, 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 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 {notNull} from '../../../utils/typeUtils';
import {getFlipperLib, textContent} from 'flipper-plugin';
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<string>;
/**
* 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<ManagedTable | undefined>
| ((ref: ManagedTable | undefined) => void);
};
type ManagedTableState = {
highlightedRows: Set<string>;
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<List>();
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() {
if (typeof this.props.innerRef === 'function') {
this.props.innerRef(this);
} else if (this.props.innerRef) {
this.props.innerRef.current = this;
}
}
componentWillUnmount() {
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.stickyBottom !== false &&
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) => {
getFlipperLib().writeTextToClipboard(
[
...(withHeader ? [this.getHeaderText()] : []),
this.getSelectedText(),
].join('\n'),
);
};
onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const {highlightedRows} = this.state;
if (highlightedRows.size === 0) {
return;
}
if (
((e.metaKey && process.platform === 'darwin') ||
(e.ctrlKey && process.platform !== 'darwin')) &&
e.keyCode === 67
) {
e.stopPropagation();
this.onCopy(false);
} else if (
(e.keyCode === 38 || e.keyCode === 40) &&
this.props.highlightableRows &&
this.props.enableKeyboardNavigation
) {
e.stopPropagation();
// 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<string>, 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<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: 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];
getFlipperLib().writeTextToClipboard(cellText);
};
buildContextMenuItems: () => Array<ContextMenuItem> = () => {
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<string> => {
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 (
<TableRow
key={rows[index].key}
columnSizes={columnSizes}
columnKeys={columnKeys}
onMouseDown={this.onHighlight}
onMouseEnter={this.onMouseEnterRow}
multiline={multiline}
rowLineHeight={24}
highlighted={highlightedRows.has(rows[index].key)}
row={rows[index]}
index={index}
style={style}
onAddFilter={onAddFilter}
zebra={zebra}
/>
);
};
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 (
<Container
canOverflow={horizontallyScrollable}
onKeyDown={this.onKeyDown}
tabIndex={0}>
{hideHeader !== true && (
<TableHead
columnOrder={columnOrder}
onColumnOrder={this.onColumnOrder}
columns={columns}
onColumnResize={this.onColumnResize}
sortOrder={this.state.sortOrder}
columnSizes={columnSizes}
onSort={this.onSort}
horizontallyScrollable={horizontallyScrollable}
/>
)}
<Container>
{this.props.autoHeight ? (
<ContextMenu
buildItems={
this.props.buildContextMenuItems || this.buildContextMenuItems
}>
{this.props.rows.map((_, index) =>
this.getRow({index, style: EMPTY_OBJECT}),
)}
</ContextMenu>
) : (
<AutoSizer>
{({width, height}) => (
<ContextMenu
buildItems={
this.props.buildContextMenuItems ||
this.buildContextMenuItems
}>
<List
itemCount={rows.length}
itemSize={(index) =>
(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}
</List>
</ContextMenu>
)}
</AutoSizer>
)}
</Container>
</Container>
);
}
}
export default debounceRender(ManagedTable, 150, {maxWait: 250});