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:
committed by
Facebook GitHub Bot
parent
86ad413669
commit
44bb5b1beb
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user