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

@@ -34,7 +34,7 @@ exports[`load PluginInstaller list 1`] = `
width="25%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -50,7 +50,7 @@ exports[`load PluginInstaller list 1`] = `
width="10%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -66,7 +66,7 @@ exports[`load PluginInstaller list 1`] = `
width="flex"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -82,7 +82,7 @@ exports[`load PluginInstaller list 1`] = `
width="15%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -336,7 +336,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
width="25%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -352,7 +352,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
width="10%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -368,7 +368,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
width="flex"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -384,7 +384,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
width="15%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div

View File

@@ -113,7 +113,7 @@ export {default as ErrorBlock} from './ui/components/ErrorBlock';
export {ErrorBlockContainer} from './ui/components/ErrorBlock';
export {default as ErrorBoundary} from './ui/components/ErrorBoundary';
export {OrderableOrder} from './ui/components/Orderable';
export {default as Interactive} from './ui/components/Interactive';
export {_Interactive as Interactive} from 'flipper-plugin';
export {default as Orderable} from './ui/components/Orderable';
export {default as VirtualList} from './ui/components/VirtualList';
export {Component, PureComponent} from 'react';

View File

@@ -7,7 +7,7 @@
* @format
*/
import Interactive, {InteractiveProps} from './Interactive';
import {_Interactive, _InteractiveProps} from 'flipper-plugin';
import FlexColumn from './FlexColumn';
import {colors} from './colors';
import {Component, ReactNode} from 'react';
@@ -18,7 +18,7 @@ import FlexRow from './FlexRow';
import {MoreOutlined} from '@ant-design/icons';
import {theme} from 'flipper-plugin';
const SidebarInteractiveContainer = styled(Interactive)<InteractiveProps>({
const SidebarInteractiveContainer = styled(_Interactive)<_InteractiveProps>({
flex: 'none',
});
SidebarInteractiveContainer.displayName = 'Sidebar:SidebarInteractiveContainer';

View File

@@ -18,7 +18,7 @@ import {
import {normaliseColumnWidth, isPercentage} from './utils';
import {PureComponent} from 'react';
import ContextMenu from '../ContextMenu';
import Interactive, {InteractiveProps} from '../Interactive';
import {_Interactive, _InteractiveProps} from 'flipper-plugin';
import styled from '@emotion/styled';
import {colors} from '../colors';
import FlexRow from '../FlexRow';
@@ -31,7 +31,7 @@ const TableHeaderArrow = styled.span({
});
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
const TableHeaderColumnInteractive = styled(_Interactive)<_InteractiveProps>({
display: 'inline-block',
overflow: 'hidden',
textOverflow: 'ellipsis',

View File

@@ -75,7 +75,6 @@ export {default as ErrorBoundary} from './components/ErrorBoundary';
// interactive components
export {OrderableOrder} from './components/Orderable';
export {default as Interactive} from './components/Interactive';
export {default as Orderable} from './components/Orderable';
export {default as VirtualList} from './components/VirtualList';

View File

@@ -75,6 +75,11 @@ export {createDataSource, DataSource} from './state/datasource/DataSource';
export {DataTable, DataTableColumn} from './ui/datatable/DataTable';
export {
Interactive as _Interactive,
InteractiveProps as _InteractiveProps,
} from './ui/Interactive';
// It's not ideal that this exists in flipper-plugin sources directly,
// but is the least pain for plugin authors.
// Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin)

View File

@@ -7,17 +7,16 @@
* @format
*/
import {Rect} from '../../utils/geometry';
import LowPassFilter from '../../utils/LowPassFilter';
import styled from '@emotion/styled';
import React from 'react';
import LowPassFilter from './utils/LowPassFilter';
import {
getDistanceTo,
maybeSnapLeft,
maybeSnapTop,
SNAP_SIZE,
} from '../../utils/snap';
import styled from '@emotion/styled';
import invariant from 'invariant';
import React from 'react';
} from './utils/snap';
import type {Rect} from './utils/Rect';
const WINDOW_CURSOR_BOUNDARY = 5;
@@ -96,7 +95,7 @@ const InteractiveContainer = styled.div({
});
InteractiveContainer.displayName = 'Interactive:InteractiveContainer';
export default class Interactive extends React.Component<
export class Interactive extends React.Component<
InteractiveProps,
InteractiveState
> {
@@ -344,9 +343,6 @@ export default class Interactive extends React.Component<
calculateMove(event: MouseEvent) {
const {movingInitialCursor, movingInitialProps} = this.state;
invariant(movingInitialProps, 'TODO');
invariant(movingInitialCursor, 'TODO');
const {clientX: cursorLeft, clientY: cursorTop} = event;
const movedLeft = movingInitialCursor!.left - cursorLeft;
@@ -414,10 +410,6 @@ export default class Interactive extends React.Component<
resizingSides,
} = this.state;
invariant(resizingInitialRect, 'TODO');
invariant(resizingInitialCursor, 'TODO');
invariant(resizingSides, 'TODO');
const deltaLeft = resizingInitialCursor!.left - event.clientX;
const deltaTop = resizingInitialCursor!.top - event.clientY;
@@ -503,7 +495,7 @@ export default class Interactive extends React.Component<
getRect(): Rect {
const {props, ref} = this;
invariant(ref, 'expected ref');
if (!ref) throw new Error('expected ref');
return {
height: ref!.offsetHeight || 0,

View File

@@ -7,7 +7,7 @@
* @format
*/
import LowPassFilter from '../LowPassFilter';
import LowPassFilter from '../utils/LowPassFilter';
test('hasFullBuffer', () => {
const lpf = new LowPassFilter();

View File

@@ -0,0 +1,261 @@
/**
* 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 React, {
memo,
useCallback,
useEffect,
useRef,
useState,
useLayoutEffect,
} from 'react';
import {DataSource} from '../../state/datasource/DataSource';
import {useVirtual} from 'react-virtual';
import styled from '@emotion/styled';
// how fast we update if updates are low-prio (e.g. out of window and not super significant)
const DEBOUNCE = 500; //ms
enum UpdatePrio {
NONE,
LOW,
HIGH,
}
type DataSourceProps<T extends object, C> = {
/**
* The data source to render
*/
dataSource: DataSource<T, any, any>;
/**
* Automatically scroll if the user is near the end?
*/
autoScroll?: boolean;
/**
* additional context that will be passed verbatim to the itemRenderer, so that it can be easily memoized
*/
context?: C;
/**
* Takes care of rendering an item
* @param item The item as stored in the dataSource
* @param index The index of the item being rendered. The index represents the offset in the _visible_ items of the dataSource
* @param context The optional context passed into this DataSourceRenderer
*/
itemRenderer(item: T, index: number, context: C): React.ReactElement;
useFixedRowHeight: boolean;
defaultRowHeight: number;
_testHeight?: number; // exposed for unit testing only
};
/**
* This component is UI agnostic, and just takes care of virtualizing the provided dataSource, and render it as efficiently a possibible,
* de priorizing off screen updates etc.
*/
export const DataSourceRenderer: <T extends object, C>(
props: DataSourceProps<T, C>,
) => React.ReactElement = memo(function DataSourceRenderer({
dataSource,
defaultRowHeight,
useFixedRowHeight,
context,
itemRenderer,
autoScroll,
_testHeight,
}: DataSourceProps<any, any>) {
/**
* Virtualization
*/
// render scheduling
const renderPending = useRef(UpdatePrio.NONE);
const lastRender = useRef(Date.now());
const setForceUpdate = useState(0)[1];
const forceHeightRecalculation = useRef(0);
const parentRef = React.useRef<null | HTMLDivElement>(null);
const virtualizer = useVirtual({
size: dataSource.output.length,
parentRef,
useObserver: _testHeight
? () => ({height: _testHeight, width: 1000})
: undefined,
// eslint-disable-next-line
estimateSize: useCallback(() => defaultRowHeight, [forceHeightRecalculation.current, defaultRowHeight]),
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, invalidateHeights = false) {
if (invalidateHeights && !useFixedRowHeight) {
// the height of some existing rows might have changed
forceHeightRecalculation.current++;
}
if (_testHeight) {
// test environment, update immediately
forceUpdate();
return;
}
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, true);
break;
case 'shift':
if (event.location === 'in') {
rerender(UpdatePrio.HIGH, false);
} else {
// optimization: we don't want to listen to every count change, especially after window
// and in some cases before window
rerender(UpdatePrio.LOW, false);
}
break;
case 'update':
// in visible range, so let's force update
rerender(UpdatePrio.HIGH, true);
break;
}
});
return () => {
unmounted = true;
dataSource.setOutputChangeListener(undefined);
};
},
[dataSource, setForceUpdate, useFixedRowHeight, _testHeight],
);
useEffect(() => {
// initial virtualization is incorrect because the parent ref is not yet set, so trigger render after mount
setForceUpdate((x) => x + 1);
}, [setForceUpdate]);
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 || !autoScroll) {
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;
}
}, [autoScroll]);
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}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: useFixedRowHeight ? virtualRow.size : undefined,
transform: `translateY(${virtualRow.start}px)`,
}}
ref={useFixedRowHeight ? undefined : virtualRow.measureRef}>
{itemRenderer(
dataSource.getItem(virtualRow.index),
virtualRow.index,
context,
)}
</div>
))}
</TableWindow>
</TableContainer>
);
}) as any;
const TableContainer = styled.div({
overflowY: 'scroll',
overflowX: 'hidden',
display: 'flex',
flex: 1,
});
const TableWindow = styled.div<{height: number}>(({height}) => ({
height,
position: 'relative',
width: '100%',
}));

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}
/>
);
}

View File

@@ -0,0 +1,254 @@
/**
* 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 {
calculatePercentage,
isPercentage,
Percentage,
Width,
} from '../utils/widthUtils';
import {useRef} from 'react';
import {Interactive, InteractiveProps} from '../Interactive';
import styled from '@emotion/styled';
import React from 'react';
import {theme} from '../theme';
import type {DataTableColumn} from './DataTable';
import {Button, Checkbox, Dropdown, Menu, Typography} from 'antd';
import {CaretDownFilled, CaretUpFilled, DownOutlined} from '@ant-design/icons';
import {Layout} from '../Layout';
import {Sorting, OnColumnResize} from './useDataTableManager';
const {Text} = Typography;
const TableHeaderArrow = styled.span({
float: 'right',
});
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
function SortIcons({direction}: {direction?: 'up' | 'down'}) {
return (
<SortIconsContainer direction={direction}>
<CaretUpFilled
className={
'ant-table-column-sorter-up ' + (direction === 'up' ? 'active' : '')
}
/>
<CaretDownFilled
className={
'ant-table-column-sorter-down ' +
(direction === 'down' ? 'active' : '')
}
/>
</SortIconsContainer>
);
}
const SortIconsContainer = styled.span<{direction?: 'up' | 'down'}>(
({direction}) => ({
visibility: direction === undefined ? 'hidden' : undefined,
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
marginLeft: 4,
color: theme.disabledColor,
}),
);
const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
overflow: 'hidden',
whiteSpace: 'nowrap',
width: '100%',
});
TableHeaderColumnInteractive.displayName =
'TableHead:TableHeaderColumnInteractive';
const TableHeaderColumnContainer = styled(Layout.Horizontal)({
padding: '4px 8px',
':hover': {
backgroundColor: theme.buttonDefaultBackground,
},
[`:hover ${SortIconsContainer}`]: {
visibility: 'visible',
},
});
TableHeaderColumnContainer.displayName = 'TableHead:TableHeaderColumnContainer';
const TableHeadContainer = styled.div<{horizontallyScrollable?: boolean}>({
position: 'relative',
display: 'flex',
flexDirection: 'row',
borderBottom: `1px solid ${theme.dividerColor}`,
backgroundColor: theme.backgroundWash,
userSelect: 'none',
});
TableHeadContainer.displayName = 'TableHead:TableHeadContainer';
const TableHeadColumnContainer = styled.div<{
width: Width;
}>((props) => ({
flexShrink: props.width === undefined ? 1 : 0,
flexGrow: props.width === undefined ? 1 : 0,
width: props.width === undefined ? '100%' : props.width,
}));
TableHeadColumnContainer.displayName = 'TableHead:TableHeadColumnContainer';
const RIGHT_RESIZABLE = {right: true};
function TableHeadColumn({
id,
title,
width,
isResizable,
isSortable,
onColumnResize,
isSortable: sortable,
onSort,
sorted,
}: {
id: string;
width: Width;
isSortable?: boolean;
sorted: 'up' | 'down' | undefined;
isResizable: boolean;
onSort: (id: string) => void;
sortOrder: undefined | Sorting;
onColumnResize: OnColumnResize;
title?: string;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const onResize = (newWidth: number) => {
if (!isResizable) {
return;
}
let normalizedWidth: number | Percentage = newWidth;
// normalise number to a percentage if we were originally passed a percentage
if (isPercentage(width) && ref.current) {
const {parentElement} = ref.current;
const parentWidth = parentElement!.clientWidth;
const {childNodes} = parentElement!;
const lastElem = childNodes[childNodes.length - 1];
const right =
lastElem instanceof HTMLElement
? lastElem.offsetLeft + lastElem.clientWidth + 1
: 0;
if (right < parentWidth) {
normalizedWidth = calculatePercentage(parentWidth, newWidth);
}
}
onColumnResize(id, normalizedWidth);
};
let children = (
<TableHeaderColumnContainer center>
<Text strong>{title}</Text>
{isSortable && <SortIcons direction={sorted} />}
</TableHeaderColumnContainer>
);
if (isResizable) {
children = (
<TableHeaderColumnInteractive
grow={true}
resizable={RIGHT_RESIZABLE}
onResize={onResize}
minWidth={20}>
{children}
</TableHeaderColumnInteractive>
);
}
return (
<TableHeadColumnContainer
width={width}
title={title}
onClick={sortable ? () => onSort(id) : undefined}
ref={ref}>
{children}
</TableHeadColumnContainer>
);
}
export function TableHead({
columns,
visibleColumns,
...props
}: {
columns: DataTableColumn<any>[];
visibleColumns: DataTableColumn<any>[];
onColumnResize: OnColumnResize;
onColumnToggleVisibility: (key: string) => void;
onReset: () => void;
sorting: Sorting | undefined;
onColumnSort: (key: string) => void;
}) {
const menu = (
<Menu style={{minWidth: 200}}>
{columns.map((column) => (
<Menu.Item
key={column.key}
onClick={(e) => {
e.domEvent.stopPropagation();
e.domEvent.preventDefault();
props.onColumnToggleVisibility(column.key);
}}>
<Checkbox checked={column.visible}>
{column.title || column.key}
</Checkbox>
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item key="reset" onClick={props.onReset}>
Reset
</Menu.Item>
</Menu>
);
return (
<TableHeadContainer>
{visibleColumns.map((column, i) => (
<TableHeadColumn
key={column.key}
id={column.key}
isResizable={i < visibleColumns.length - 1}
width={column.width}
isSortable={true} // might depend in the future on for example .getValue()
sortOrder={props.sorting}
onSort={props.onColumnSort}
onColumnResize={props.onColumnResize}
sorted={
props.sorting?.key === column.key
? props.sorting!.direction
: undefined
}
title={column.title}
/>
))}
<Dropdown overlay={menu} trigger={['click']}>
<SettingsButton type="text">
<DownOutlined />
</SettingsButton>
</Dropdown>
</TableHeadContainer>
);
}
const SettingsButton = styled(Button)({
padding: 4,
position: 'absolute',
right: 0,
top: 0,
backgroundColor: theme.backgroundWash,
});

View File

@@ -9,9 +9,9 @@
import React, {memo} from 'react';
import styled from '@emotion/styled';
import {Property} from 'csstype';
import {theme} from 'flipper-plugin';
import {RenderingConfig} from './DataTable';
import type {RenderContext} from './DataTable';
import {Width} from '../utils/widthUtils';
// heuristic for row estimation, should match any future styling updates
export const DEFAULT_ROW_HEIGHT = 24;
@@ -32,7 +32,7 @@ const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
display: 'flex',
flexDirection: 'row',
backgroundColor: backgroundColor(props),
color: props.highlighted ? theme.white : theme.primaryColor,
color: props.highlighted ? theme.white : theme.textColorPrimary,
'& *': {
color: props.highlighted ? `${theme.white} !important` : undefined,
},
@@ -50,25 +50,26 @@ const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
TableBodyRowContainer.displayName = 'TableRow:TableBodyRowContainer';
const TableBodyColumnContainer = styled.div<{
width?: any;
width: Width;
multiline?: boolean;
justifyContent: Property.JustifyContent;
justifyContent: 'left' | 'right' | 'center' | 'flex-start';
}>((props) => ({
display: 'flex',
flexShrink: props.width === 'flex' ? 1 : 0,
flexGrow: props.width === 'flex' ? 1 : 0,
flexShrink: props.width === undefined ? 1 : 0,
flexGrow: props.width === undefined ? 1 : 0,
overflow: 'hidden',
padding: `0 ${theme.space.small}px`,
verticalAlign: 'top',
whiteSpace: props.multiline ? 'normal' : 'nowrap',
wordWrap: props.multiline ? 'break-word' : 'normal',
width: props.width === 'flex' ? undefined : props.width,
width: props.width,
justifyContent: props.justifyContent,
borderBottom: `1px solid ${theme.dividerColor}`,
}));
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
type Props = {
config: RenderingConfig<any>;
config: RenderContext;
highlighted: boolean;
row: any;
};
@@ -76,47 +77,31 @@ type Props = {
export const TableRow = memo(function TableRow(props: Props) {
const {config, highlighted, row} = props;
return (
<TableBodyRowContainer highlighted={highlighted} data-key={row.key}>
{config.columns.map((col) => {
const value = (col as any).onRender
? (col as any).onRender(row)
: (row as any)[col.key] ?? '';
<TableBodyRowContainer
highlighted={highlighted}
data-key={row.key}
className="ant-table-row">
{config.columns
.filter((col) => col.visible)
.map((col) => {
let value = (col as any).onRender
? (col as any).onRender(row)
: (row as any)[col.key] ?? '';
if (typeof value === 'boolean') {
value = value ? 'true' : 'false';
}
return (
<TableBodyColumnContainer
key={col.key as string}
multiline={col.wrap}
justifyContent={col.align ? col.align : 'flex-start'}
width={normaliseColumnWidth(col.width)}>
{value}
</TableBodyColumnContainer>
);
})}
return (
<TableBodyColumnContainer
className="ant-table-cell"
key={col.key as string}
multiline={col.wrap}
justifyContent={col.align ? col.align : 'flex-start'}
width={col.width}>
{value}
</TableBodyColumnContainer>
);
})}
</TableBodyRowContainer>
);
});
function normaliseColumnWidth(
width: string | number | null | undefined | '*',
): number | string {
if (width == null || width === '*') {
// default
return 'flex';
}
if (isPercentage(width)) {
// percentage eg. 50%
return width;
}
if (typeof width === 'number') {
// pixel width
return width;
}
throw new TypeError(`Unknown value ${width} for table column width`);
}
function isPercentage(width: any): boolean {
return typeof width === 'string' && width[width.length - 1] === '%';
}

View File

@@ -0,0 +1,224 @@
/**
* 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 React, {createRef} from 'react';
import {DataTable, DataTableColumn} from '../DataTable';
import {render, act} from '@testing-library/react';
import {createDataSource} from '../../../state/datasource/DataSource';
import {TableManager} from '../useDataTableManager';
type Todo = {
title: string;
done: boolean;
};
function createTestDataSource() {
return createDataSource<Todo>([
{
title: 'test DataTable',
done: true,
},
]);
}
const columns: DataTableColumn[] = [
{
key: 'title',
wrap: false,
},
{
key: 'done',
wrap: false,
},
];
test('update and append', async () => {
const ds = createTestDataSource();
const ref = createRef<TableManager>();
const rendering = render(
<DataTable dataSource={ds} columns={columns} tableManagerRef={ref} />,
);
{
const elem = await rendering.findAllByText('test DataTable');
expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01"
>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
>
true
</div>
</div>
`);
}
act(() => {
ds.append({
title: 'Drink coffee',
done: false,
});
});
{
const elem = await rendering.findAllByText('Drink coffee');
expect(elem.length).toBe(1);
}
// update
act(() => {
ds.update(0, {
title: 'DataTable tested',
done: false,
});
});
{
const elem = await rendering.findAllByText('DataTable tested');
expect(elem.length).toBe(1);
expect(rendering.queryByText('test DataTable')).toBeNull();
}
});
test('column visibility', async () => {
const ds = createTestDataSource();
const ref = createRef<TableManager>();
const rendering = render(
<DataTable dataSource={ds} columns={columns} tableManagerRef={ref} />,
);
{
const elem = await rendering.findAllByText('test DataTable');
expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01"
>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
>
true
</div>
</div>
`);
}
// hide done
act(() => {
ref.current?.toggleColumnVisibility('done');
});
{
const elem = await rendering.findAllByText('test DataTable');
expect(elem.length).toBe(1);
expect(elem[0].parentElement).toMatchInlineSnapshot(`
<div
class="ant-table-row css-4f2ebr-TableBodyRowContainer efe0za01"
>
<div
class="ant-table-cell css-1g4z4wd-TableBodyColumnContainer efe0za00"
>
test DataTable
</div>
</div>
`);
}
// reset
act(() => {
ref.current?.reset();
});
{
const elem = await rendering.findAllByText('test DataTable');
expect(elem.length).toBe(1);
expect(elem[0].parentElement?.children.length).toBe(2);
}
});
test('sorting', async () => {
const ds = createTestDataSource();
ds.clear();
ds.append({
title: 'item a',
done: false,
});
ds.append({
title: 'item x',
done: false,
});
ds.append({
title: 'item b',
done: false,
});
const ref = createRef<TableManager>();
const rendering = render(
<DataTable
dataSource={ds}
columns={columns}
tableManagerRef={ref}
_testHeight={400}
/>,
);
// insertion order
{
const elem = await rendering.findAllByText(/item/);
expect(elem.length).toBe(3);
expect(elem.map((e) => e.textContent)).toEqual([
'item a',
'item x',
'item b',
]);
}
// sort asc
act(() => {
ref.current?.sortColumn('title');
});
{
const elem = await rendering.findAllByText(/item/);
expect(elem.length).toBe(3);
expect(elem.map((e) => e.textContent)).toEqual([
'item a',
'item b',
'item x',
]);
}
// sort desc
act(() => {
ref.current?.sortColumn('title');
});
{
const elem = await rendering.findAllByText(/item/);
expect(elem.length).toBe(3);
expect(elem.map((e) => e.textContent)).toEqual([
'item x',
'item b',
'item a',
]);
}
// another click resets again
act(() => {
ref.current?.sortColumn('title');
});
{
const elem = await rendering.findAllByText(/item/);
expect(elem.length).toBe(3);
expect(elem.map((e) => e.textContent)).toEqual([
'item a',
'item x',
'item b',
]);
}
});

View File

@@ -0,0 +1,116 @@
/**
* 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 {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable';
import {Percentage} from '../utils/widthUtils';
import produce from 'immer';
import {useCallback, useMemo, useState} from 'react';
import {DataSource} from '../../state/datasource/DataSource';
export type OnColumnResize = (id: string, size: number | Percentage) => void;
export type Sorting = {
key: string;
direction: 'up' | 'down';
};
export type TableManager = ReturnType<typeof useDataTableManager>;
/**
* A hook that coordinates filtering, sorting etc for a DataSource
*/
export function useDataTableManager<T extends object>(
dataSource: DataSource<T>,
defaultColumns: DataTableColumn<T>[],
) {
// TODO: restore from local storage
const [columns, setEffectiveColumns] = useState(
computeInitialColumns(defaultColumns),
);
const [sorting, setSorting] = useState<Sorting | undefined>(undefined);
const visibleColumns = useMemo(
() => columns.filter((column) => column.visible),
[columns],
);
const reset = useCallback(() => {
setEffectiveColumns(computeInitialColumns(defaultColumns));
setSorting(undefined);
dataSource.reset();
// TODO: local storage
}, [dataSource, defaultColumns]);
const resizeColumn = useCallback((id: string, width: number | Percentage) => {
setEffectiveColumns(
// TODO: fix typing of produce
produce((columns: DataTableColumn<any>[]) => {
const col = columns.find((c) => c.key === id)!;
col.width = width;
}),
);
}, []);
const sortColumn = useCallback(
(key: string) => {
if (sorting?.key === key) {
if (sorting.direction === 'down') {
setSorting({key, direction: 'up'});
dataSource.setReversed(true);
} else {
setSorting(undefined);
dataSource.setSortBy(undefined);
dataSource.setReversed(false);
}
} else {
setSorting({
key,
direction: 'down',
});
dataSource.setSortBy(key as any);
dataSource.setReversed(false);
}
},
[dataSource, sorting],
);
const toggleColumnVisibility = useCallback((id: string) => {
setEffectiveColumns(
// TODO: fix typing of produce
produce((columns: DataTableColumn<any>[]) => {
const col = columns.find((c) => c.key === id)!;
col.visible = !col.visible;
}),
);
}, []);
return {
/** The default columns, but normalized */
columns,
/** The effective columns to be rendererd */
visibleColumns,
/** The currently applicable sorting, if any */
sorting,
/** Reset the current table preferences, including column widths an visibility, back to the default */
reset,
/** Resizes the column with the given key to the given width */
resizeColumn,
/** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */
sortColumn,
/** Show / hide the given column */
toggleColumnVisibility,
};
}
function computeInitialColumns(
columns: DataTableColumn<any>[],
): DataTableColumn<any>[] {
return columns.map((c) => ({
...c,
visible: c.visible !== false,
}));
}

View File

@@ -7,8 +7,6 @@
* @format
*/
import invariant from 'invariant';
export default class LowPassFilter {
constructor(smoothing: number = 0.9) {
this.smoothing = smoothing;
@@ -29,10 +27,10 @@ export default class LowPassFilter {
if (this.hasFullBuffer()) {
const tmp: number | undefined = this.buffer.shift();
invariant(
tmp !== undefined,
'Invariant violation: Buffer reported full but shift returned nothing.',
);
if (tmp === undefined)
throw new Error(
'Invariant violation: Buffer reported full but shift returned nothing.',
);
removed = tmp;
}

View File

@@ -0,0 +1,15 @@
/**
* 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
*/
export type Rect = {
top: number;
left: number;
height: number;
width: number;
};

View File

@@ -7,7 +7,7 @@
* @format
*/
import {Rect} from './geometry';
import {Rect} from './Rect';
export const SNAP_SIZE = 16;

View File

@@ -0,0 +1,23 @@
/**
* 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
*/
export type Percentage = string; // currently broken, see https://github.com/microsoft/TypeScript/issues/41651. Should be `${number}%`;
export type Width = undefined | number | Percentage; // undefined represents auto flex
export function isPercentage(width: any): width is Percentage {
return typeof width === 'string' && width[width.length - 1] === '%';
}
export function calculatePercentage(
parentWidth: number,
selfWidth: number,
): Percentage {
return `${(100 / parentWidth) * selfWidth}%` as const;
}