Introduced sorting, column visibility and column resizing

Summary:
Add support for resizable columns, column sorting, and hiding / showing columns

Moved some utilities from Flipper to flipper-plugin, such as Interactive and LowPassFilter

Split DataTable into two components; DataSourceRenderer which takes care of purely rendering the virtualization, and DataTable that has the Chrome around that, such as column headers, search bar, etc.

Reviewed By: nikoant

Differential Revision: D26321105

fbshipit-source-id: 32b8fc03b4fb97b3af52b23e273c3e5b8cbc4498
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent 86ad413669
commit 44bb5b1beb
18 changed files with 1020 additions and 324 deletions

View File

@@ -7,266 +7,90 @@
* @format
*/
import React, {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useLayoutEffect,
} from 'react';
import React, {MutableRefObject, RefObject, useMemo} from 'react';
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
import {Property} from 'csstype';
import {DataSource} from '../../state/datasource/DataSource';
import {useVirtual} from 'react-virtual';
import styled from '@emotion/styled';
import {theme} from '../theme';
import {Layout} from '../Layout';
import {TableHead} from './TableHead';
import {Percentage} from '../utils/widthUtils';
import {DataSourceRenderer} from './DataSourceRenderer';
import {useDataTableManager, TableManager} from './useDataTableManager';
// how fast we update if updates are low-prio (e.g. out of window and not super significant)
const DEBOUNCE = 500; //ms
interface DataTableProps<T extends object> {
interface DataTableProps<T = any> {
columns: DataTableColumn<T>[];
dataSource: DataSource<T, any, any>;
zebra?: boolean;
autoScroll?: boolean;
tableManagerRef?: RefObject<TableManager>;
_testHeight?: number; // exposed for unit testing only
}
export type DataTableColumn<T> = (
| {
// existing data
key: keyof T;
}
| {
// derived data / custom rendering
key: string;
onRender?: (row: T) => React.ReactNode;
}
) & {
label?: string;
width?: number | '*';
export type DataTableColumn<T = any> = {
key: keyof T & string;
// possible future extension: getValue(row) (and free-form key) to support computed columns
onRender?: (row: T) => React.ReactNode;
title?: string;
width?: number | Percentage | undefined; // undefined: use all remaining width
wrap?: boolean;
align?: Property.JustifyContent;
defaultVisible?: boolean;
align?: 'left' | 'right' | 'center';
visible?: boolean;
};
export interface RenderingConfig<T extends object> {
export interface RenderingConfig<T = any> {
columns: DataTableColumn<T>[];
zebra: boolean;
onMouseDown: (e: React.MouseEvent, row: T) => void;
onMouseEnter: (e: React.MouseEvent, row: T) => void;
}
enum UpdatePrio {
NONE,
LOW,
HIGH,
}
export const DataTable: <T extends object>(
props: DataTableProps<T>,
) => React.ReactElement = memo(function DataSourceRenderer(
props: DataTableProps<any>,
) {
const {dataSource} = props;
export function DataTable<T extends object>(props: DataTableProps<T>) {
const tableManager = useDataTableManager<T>(props.dataSource, props.columns);
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager;
}
const renderingConfig = useMemo(() => {
return {
columns: props.columns,
zebra: props.zebra !== false,
onMouseDown() {
// TODO:
},
onMouseEnter() {
// TODO:
},
columns: tableManager.visibleColumns,
};
}, [props.columns, props.zebra]);
}, [tableManager.visibleColumns]);
const usesWrapping = useMemo(() => props.columns.some((col) => col.wrap), [
props.columns,
]);
/**
* Virtualization
*/
// render scheduling
const renderPending = useRef(UpdatePrio.NONE);
const lastRender = useRef(Date.now());
const setForceUpdate = useState(0)[1];
const parentRef = React.useRef<null | HTMLDivElement>(null);
const virtualizer = useVirtual({
size: dataSource.output.length,
parentRef,
// eslint-disable-next-line
estimateSize: useCallback(() => DEFAULT_ROW_HEIGHT, []),
overscan: 0,
});
useEffect(
function subscribeToDataSource() {
const forceUpdate = () => {
if (unmounted) {
return;
}
setForceUpdate((x) => x + 1);
};
let unmounted = false;
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
function rerender(prio: 1 | 2) {
if (renderPending.current >= prio) {
// already scheduled an update with equal or higher prio
return;
}
renderPending.current = prio;
if (prio === UpdatePrio.LOW) {
// TODO: make DEBOUNCE depend on how big the relative change is
timeoutHandle = setTimeout(forceUpdate, DEBOUNCE);
} else {
// High
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
requestAnimationFrame(forceUpdate);
}
}
dataSource.setOutputChangeListener((event) => {
switch (event.type) {
case 'reset':
rerender(UpdatePrio.HIGH);
break;
case 'shift':
// console.log(event.type, event.location);
if (event.location === 'in') {
rerender(UpdatePrio.HIGH);
} else {
// optimization: we don't want to listen to every count change, especially after window
// and in some cases before window
rerender(UpdatePrio.LOW);
}
break;
case 'update':
// in visible range, so let's force update
rerender(UpdatePrio.HIGH);
break;
}
});
return () => {
unmounted = true;
dataSource.setOutputChangeListener(undefined);
};
},
[dataSource, setForceUpdate],
const usesWrapping = useMemo(
() => tableManager.columns.some((col) => col.wrap),
[tableManager.columns],
);
useLayoutEffect(function updateWindow() {
const start = virtualizer.virtualItems[0]?.index ?? 0;
const end = start + virtualizer.virtualItems.length;
dataSource.setWindow(start, end);
});
/**
* Scrolling
*/
// if true, scroll if new items are appended
const followOutput = useRef(false);
// if true, the next scroll event will be fired as result of a size change,
// ignore it
const suppressScroll = useRef(false);
suppressScroll.current = true;
const onScroll = useCallback(() => {
// scroll event is firing as a result of painting new items?
if (suppressScroll.current) {
return;
}
const elem = parentRef.current!;
// make bottom 1/3 of screen sticky
if (elem.scrollTop < elem.scrollHeight - elem.clientHeight * 1.3) {
followOutput.current = false;
} else {
followOutput.current = true;
}
}, []);
useLayoutEffect(function scrollToEnd() {
if (followOutput.current) {
virtualizer.scrollToIndex(
dataSource.output.length - 1,
/* smooth is not typed by react-virtual, but passed on to the DOM as it should*/
{
align: 'end',
behavior: 'smooth',
} as any,
);
}
});
/**
* Render finalization
*/
useEffect(function renderCompleted() {
suppressScroll.current = false;
renderPending.current = UpdatePrio.NONE;
lastRender.current = Date.now();
});
/**
* Rendering
*/
return (
<TableContainer onScroll={onScroll} ref={parentRef}>
<TableWindow height={virtualizer.totalSize}>
{virtualizer.virtualItems.map((virtualRow) => (
// the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always.
// Also all row containers are renderd as part of same component to have 'less react' framework code in between*/}
<div
key={virtualRow.index}
className={virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
backgroundColor:
renderingConfig.zebra && virtualRow.index % 2
? theme.backgroundWash
: theme.backgroundDefault,
height: usesWrapping ? undefined : virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}}
ref={usesWrapping ? virtualRow.measureRef : undefined}>
{
<TableRow
key={virtualRow.index}
config={renderingConfig}
row={dataSource.getItem(virtualRow.index)}
highlighted={false}
/>
}
</div>
))}
</TableWindow>
</TableContainer>
<Layout.Top>
<TableHead
columns={tableManager.columns}
visibleColumns={tableManager.visibleColumns}
onColumnResize={tableManager.resizeColumn}
onReset={tableManager.reset}
onColumnToggleVisibility={tableManager.toggleColumnVisibility}
sorting={tableManager.sorting}
onColumnSort={tableManager.sortColumn}
/>
<DataSourceRenderer<any, RenderContext>
dataSource={props.dataSource}
autoScroll={props.autoScroll}
useFixedRowHeight={!usesWrapping}
defaultRowHeight={DEFAULT_ROW_HEIGHT}
context={renderingConfig}
itemRenderer={itemRenderer}
_testHeight={props._testHeight}
/>
</Layout.Top>
);
}) as any;
}
const TableContainer = styled.div({
overflowY: 'scroll',
overflowX: 'hidden',
display: 'flex',
flex: 1,
});
export type RenderContext = {
columns: DataTableColumn<any>[];
};
const TableWindow = styled.div<{height: number}>(({height}) => ({
height,
position: 'relative',
width: '100%',
}));
function itemRenderer(item: any, index: number, renderContext: RenderContext) {
return (
<TableRow
key={index}
config={renderContext}
row={item}
highlighted={false}
/>
);
}