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
@@ -34,7 +34,7 @@ exports[`load PluginInstaller list 1`] = `
|
|||||||
width="25%"
|
width="25%"
|
||||||
>
|
>
|
||||||
<div
|
<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%;"
|
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -50,7 +50,7 @@ exports[`load PluginInstaller list 1`] = `
|
|||||||
width="10%"
|
width="10%"
|
||||||
>
|
>
|
||||||
<div
|
<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%;"
|
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -66,7 +66,7 @@ exports[`load PluginInstaller list 1`] = `
|
|||||||
width="flex"
|
width="flex"
|
||||||
>
|
>
|
||||||
<div
|
<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%;"
|
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -82,7 +82,7 @@ exports[`load PluginInstaller list 1`] = `
|
|||||||
width="15%"
|
width="15%"
|
||||||
>
|
>
|
||||||
<div
|
<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%;"
|
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -336,7 +336,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
|
|||||||
width="25%"
|
width="25%"
|
||||||
>
|
>
|
||||||
<div
|
<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%;"
|
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -352,7 +352,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
|
|||||||
width="10%"
|
width="10%"
|
||||||
>
|
>
|
||||||
<div
|
<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%;"
|
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -368,7 +368,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
|
|||||||
width="flex"
|
width="flex"
|
||||||
>
|
>
|
||||||
<div
|
<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%;"
|
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -384,7 +384,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
|
|||||||
width="15%"
|
width="15%"
|
||||||
>
|
>
|
||||||
<div
|
<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%;"
|
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export {default as ErrorBlock} from './ui/components/ErrorBlock';
|
|||||||
export {ErrorBlockContainer} from './ui/components/ErrorBlock';
|
export {ErrorBlockContainer} from './ui/components/ErrorBlock';
|
||||||
export {default as ErrorBoundary} from './ui/components/ErrorBoundary';
|
export {default as ErrorBoundary} from './ui/components/ErrorBoundary';
|
||||||
export {OrderableOrder} from './ui/components/Orderable';
|
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 Orderable} from './ui/components/Orderable';
|
||||||
export {default as VirtualList} from './ui/components/VirtualList';
|
export {default as VirtualList} from './ui/components/VirtualList';
|
||||||
export {Component, PureComponent} from 'react';
|
export {Component, PureComponent} from 'react';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Interactive, {InteractiveProps} from './Interactive';
|
import {_Interactive, _InteractiveProps} from 'flipper-plugin';
|
||||||
import FlexColumn from './FlexColumn';
|
import FlexColumn from './FlexColumn';
|
||||||
import {colors} from './colors';
|
import {colors} from './colors';
|
||||||
import {Component, ReactNode} from 'react';
|
import {Component, ReactNode} from 'react';
|
||||||
@@ -18,7 +18,7 @@ import FlexRow from './FlexRow';
|
|||||||
import {MoreOutlined} from '@ant-design/icons';
|
import {MoreOutlined} from '@ant-design/icons';
|
||||||
import {theme} from 'flipper-plugin';
|
import {theme} from 'flipper-plugin';
|
||||||
|
|
||||||
const SidebarInteractiveContainer = styled(Interactive)<InteractiveProps>({
|
const SidebarInteractiveContainer = styled(_Interactive)<_InteractiveProps>({
|
||||||
flex: 'none',
|
flex: 'none',
|
||||||
});
|
});
|
||||||
SidebarInteractiveContainer.displayName = 'Sidebar:SidebarInteractiveContainer';
|
SidebarInteractiveContainer.displayName = 'Sidebar:SidebarInteractiveContainer';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import {normaliseColumnWidth, isPercentage} from './utils';
|
import {normaliseColumnWidth, isPercentage} from './utils';
|
||||||
import {PureComponent} from 'react';
|
import {PureComponent} from 'react';
|
||||||
import ContextMenu from '../ContextMenu';
|
import ContextMenu from '../ContextMenu';
|
||||||
import Interactive, {InteractiveProps} from '../Interactive';
|
import {_Interactive, _InteractiveProps} from 'flipper-plugin';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import {colors} from '../colors';
|
import {colors} from '../colors';
|
||||||
import FlexRow from '../FlexRow';
|
import FlexRow from '../FlexRow';
|
||||||
@@ -31,7 +31,7 @@ const TableHeaderArrow = styled.span({
|
|||||||
});
|
});
|
||||||
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
|
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
|
||||||
|
|
||||||
const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
|
const TableHeaderColumnInteractive = styled(_Interactive)<_InteractiveProps>({
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export {default as ErrorBoundary} from './components/ErrorBoundary';
|
|||||||
|
|
||||||
// interactive components
|
// interactive components
|
||||||
export {OrderableOrder} from './components/Orderable';
|
export {OrderableOrder} from './components/Orderable';
|
||||||
export {default as Interactive} from './components/Interactive';
|
|
||||||
export {default as Orderable} from './components/Orderable';
|
export {default as Orderable} from './components/Orderable';
|
||||||
export {default as VirtualList} from './components/VirtualList';
|
export {default as VirtualList} from './components/VirtualList';
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export {createDataSource, DataSource} from './state/datasource/DataSource';
|
|||||||
|
|
||||||
export {DataTable, DataTableColumn} from './ui/datatable/DataTable';
|
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,
|
// It's not ideal that this exists in flipper-plugin sources directly,
|
||||||
// but is the least pain for plugin authors.
|
// 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)
|
// Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin)
|
||||||
|
|||||||
@@ -7,17 +7,16 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Rect} from '../../utils/geometry';
|
import styled from '@emotion/styled';
|
||||||
import LowPassFilter from '../../utils/LowPassFilter';
|
import React from 'react';
|
||||||
|
import LowPassFilter from './utils/LowPassFilter';
|
||||||
import {
|
import {
|
||||||
getDistanceTo,
|
getDistanceTo,
|
||||||
maybeSnapLeft,
|
maybeSnapLeft,
|
||||||
maybeSnapTop,
|
maybeSnapTop,
|
||||||
SNAP_SIZE,
|
SNAP_SIZE,
|
||||||
} from '../../utils/snap';
|
} from './utils/snap';
|
||||||
import styled from '@emotion/styled';
|
import type {Rect} from './utils/Rect';
|
||||||
import invariant from 'invariant';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const WINDOW_CURSOR_BOUNDARY = 5;
|
const WINDOW_CURSOR_BOUNDARY = 5;
|
||||||
|
|
||||||
@@ -96,7 +95,7 @@ const InteractiveContainer = styled.div({
|
|||||||
});
|
});
|
||||||
InteractiveContainer.displayName = 'Interactive:InteractiveContainer';
|
InteractiveContainer.displayName = 'Interactive:InteractiveContainer';
|
||||||
|
|
||||||
export default class Interactive extends React.Component<
|
export class Interactive extends React.Component<
|
||||||
InteractiveProps,
|
InteractiveProps,
|
||||||
InteractiveState
|
InteractiveState
|
||||||
> {
|
> {
|
||||||
@@ -344,9 +343,6 @@ export default class Interactive extends React.Component<
|
|||||||
calculateMove(event: MouseEvent) {
|
calculateMove(event: MouseEvent) {
|
||||||
const {movingInitialCursor, movingInitialProps} = this.state;
|
const {movingInitialCursor, movingInitialProps} = this.state;
|
||||||
|
|
||||||
invariant(movingInitialProps, 'TODO');
|
|
||||||
invariant(movingInitialCursor, 'TODO');
|
|
||||||
|
|
||||||
const {clientX: cursorLeft, clientY: cursorTop} = event;
|
const {clientX: cursorLeft, clientY: cursorTop} = event;
|
||||||
|
|
||||||
const movedLeft = movingInitialCursor!.left - cursorLeft;
|
const movedLeft = movingInitialCursor!.left - cursorLeft;
|
||||||
@@ -414,10 +410,6 @@ export default class Interactive extends React.Component<
|
|||||||
resizingSides,
|
resizingSides,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
invariant(resizingInitialRect, 'TODO');
|
|
||||||
invariant(resizingInitialCursor, 'TODO');
|
|
||||||
invariant(resizingSides, 'TODO');
|
|
||||||
|
|
||||||
const deltaLeft = resizingInitialCursor!.left - event.clientX;
|
const deltaLeft = resizingInitialCursor!.left - event.clientX;
|
||||||
const deltaTop = resizingInitialCursor!.top - event.clientY;
|
const deltaTop = resizingInitialCursor!.top - event.clientY;
|
||||||
|
|
||||||
@@ -503,7 +495,7 @@ export default class Interactive extends React.Component<
|
|||||||
|
|
||||||
getRect(): Rect {
|
getRect(): Rect {
|
||||||
const {props, ref} = this;
|
const {props, ref} = this;
|
||||||
invariant(ref, 'expected ref');
|
if (!ref) throw new Error('expected ref');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
height: ref!.offsetHeight || 0,
|
height: ref!.offsetHeight || 0,
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import LowPassFilter from '../LowPassFilter';
|
import LowPassFilter from '../utils/LowPassFilter';
|
||||||
|
|
||||||
test('hasFullBuffer', () => {
|
test('hasFullBuffer', () => {
|
||||||
const lpf = new LowPassFilter();
|
const lpf = new LowPassFilter();
|
||||||
261
desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx
Normal file
261
desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx
Normal 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%',
|
||||||
|
}));
|
||||||
@@ -7,266 +7,90 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {
|
import React, {MutableRefObject, RefObject, useMemo} from 'react';
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useLayoutEffect,
|
|
||||||
} from 'react';
|
|
||||||
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
|
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
|
||||||
import {Property} from 'csstype';
|
|
||||||
import {DataSource} from '../../state/datasource/DataSource';
|
import {DataSource} from '../../state/datasource/DataSource';
|
||||||
import {useVirtual} from 'react-virtual';
|
import {Layout} from '../Layout';
|
||||||
import styled from '@emotion/styled';
|
import {TableHead} from './TableHead';
|
||||||
import {theme} from '../theme';
|
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)
|
interface DataTableProps<T = any> {
|
||||||
const DEBOUNCE = 500; //ms
|
|
||||||
|
|
||||||
interface DataTableProps<T extends object> {
|
|
||||||
columns: DataTableColumn<T>[];
|
columns: DataTableColumn<T>[];
|
||||||
dataSource: DataSource<T, any, any>;
|
dataSource: DataSource<T, any, any>;
|
||||||
zebra?: boolean;
|
|
||||||
autoScroll?: boolean;
|
autoScroll?: boolean;
|
||||||
|
tableManagerRef?: RefObject<TableManager>;
|
||||||
|
_testHeight?: number; // exposed for unit testing only
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataTableColumn<T> = (
|
export type DataTableColumn<T = any> = {
|
||||||
| {
|
key: keyof T & string;
|
||||||
// existing data
|
// possible future extension: getValue(row) (and free-form key) to support computed columns
|
||||||
key: keyof T;
|
onRender?: (row: T) => React.ReactNode;
|
||||||
}
|
title?: string;
|
||||||
| {
|
width?: number | Percentage | undefined; // undefined: use all remaining width
|
||||||
// derived data / custom rendering
|
|
||||||
key: string;
|
|
||||||
onRender?: (row: T) => React.ReactNode;
|
|
||||||
}
|
|
||||||
) & {
|
|
||||||
label?: string;
|
|
||||||
width?: number | '*';
|
|
||||||
wrap?: boolean;
|
wrap?: boolean;
|
||||||
align?: Property.JustifyContent;
|
align?: 'left' | 'right' | 'center';
|
||||||
defaultVisible?: boolean;
|
visible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface RenderingConfig<T extends object> {
|
export interface RenderingConfig<T = any> {
|
||||||
columns: DataTableColumn<T>[];
|
columns: DataTableColumn<T>[];
|
||||||
zebra: boolean;
|
|
||||||
onMouseDown: (e: React.MouseEvent, row: T) => void;
|
|
||||||
onMouseEnter: (e: React.MouseEvent, row: T) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UpdatePrio {
|
export function DataTable<T extends object>(props: DataTableProps<T>) {
|
||||||
NONE,
|
const tableManager = useDataTableManager<T>(props.dataSource, props.columns);
|
||||||
LOW,
|
if (props.tableManagerRef) {
|
||||||
HIGH,
|
(props.tableManagerRef as MutableRefObject<TableManager>).current = tableManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataTable: <T extends object>(
|
|
||||||
props: DataTableProps<T>,
|
|
||||||
) => React.ReactElement = memo(function DataSourceRenderer(
|
|
||||||
props: DataTableProps<any>,
|
|
||||||
) {
|
|
||||||
const {dataSource} = props;
|
|
||||||
|
|
||||||
const renderingConfig = useMemo(() => {
|
const renderingConfig = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
columns: props.columns,
|
columns: tableManager.visibleColumns,
|
||||||
zebra: props.zebra !== false,
|
|
||||||
onMouseDown() {
|
|
||||||
// TODO:
|
|
||||||
},
|
|
||||||
onMouseEnter() {
|
|
||||||
// TODO:
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}, [props.columns, props.zebra]);
|
}, [tableManager.visibleColumns]);
|
||||||
|
|
||||||
const usesWrapping = useMemo(() => props.columns.some((col) => col.wrap), [
|
const usesWrapping = useMemo(
|
||||||
props.columns,
|
() => tableManager.columns.some((col) => col.wrap),
|
||||||
]);
|
[tableManager.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],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<TableContainer onScroll={onScroll} ref={parentRef}>
|
<Layout.Top>
|
||||||
<TableWindow height={virtualizer.totalSize}>
|
<TableHead
|
||||||
{virtualizer.virtualItems.map((virtualRow) => (
|
columns={tableManager.columns}
|
||||||
// the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always.
|
visibleColumns={tableManager.visibleColumns}
|
||||||
// Also all row containers are renderd as part of same component to have 'less react' framework code in between*/}
|
onColumnResize={tableManager.resizeColumn}
|
||||||
<div
|
onReset={tableManager.reset}
|
||||||
key={virtualRow.index}
|
onColumnToggleVisibility={tableManager.toggleColumnVisibility}
|
||||||
className={virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'}
|
sorting={tableManager.sorting}
|
||||||
style={{
|
onColumnSort={tableManager.sortColumn}
|
||||||
position: 'absolute',
|
/>
|
||||||
top: 0,
|
<DataSourceRenderer<any, RenderContext>
|
||||||
left: 0,
|
dataSource={props.dataSource}
|
||||||
width: '100%',
|
autoScroll={props.autoScroll}
|
||||||
backgroundColor:
|
useFixedRowHeight={!usesWrapping}
|
||||||
renderingConfig.zebra && virtualRow.index % 2
|
defaultRowHeight={DEFAULT_ROW_HEIGHT}
|
||||||
? theme.backgroundWash
|
context={renderingConfig}
|
||||||
: theme.backgroundDefault,
|
itemRenderer={itemRenderer}
|
||||||
height: usesWrapping ? undefined : virtualRow.size,
|
_testHeight={props._testHeight}
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
/>
|
||||||
}}
|
</Layout.Top>
|
||||||
ref={usesWrapping ? virtualRow.measureRef : undefined}>
|
|
||||||
{
|
|
||||||
<TableRow
|
|
||||||
key={virtualRow.index}
|
|
||||||
config={renderingConfig}
|
|
||||||
row={dataSource.getItem(virtualRow.index)}
|
|
||||||
highlighted={false}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</TableWindow>
|
|
||||||
</TableContainer>
|
|
||||||
);
|
);
|
||||||
}) as any;
|
}
|
||||||
|
|
||||||
const TableContainer = styled.div({
|
export type RenderContext = {
|
||||||
overflowY: 'scroll',
|
columns: DataTableColumn<any>[];
|
||||||
overflowX: 'hidden',
|
};
|
||||||
display: 'flex',
|
|
||||||
flex: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const TableWindow = styled.div<{height: number}>(({height}) => ({
|
function itemRenderer(item: any, index: number, renderContext: RenderContext) {
|
||||||
height,
|
return (
|
||||||
position: 'relative',
|
<TableRow
|
||||||
width: '100%',
|
key={index}
|
||||||
}));
|
config={renderContext}
|
||||||
|
row={item}
|
||||||
|
highlighted={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
254
desktop/flipper-plugin/src/ui/datatable/TableHead.tsx
Normal file
254
desktop/flipper-plugin/src/ui/datatable/TableHead.tsx
Normal 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,
|
||||||
|
});
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
import React, {memo} from 'react';
|
import React, {memo} from 'react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import {Property} from 'csstype';
|
|
||||||
import {theme} from 'flipper-plugin';
|
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
|
// heuristic for row estimation, should match any future styling updates
|
||||||
export const DEFAULT_ROW_HEIGHT = 24;
|
export const DEFAULT_ROW_HEIGHT = 24;
|
||||||
@@ -32,7 +32,7 @@ const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
backgroundColor: backgroundColor(props),
|
backgroundColor: backgroundColor(props),
|
||||||
color: props.highlighted ? theme.white : theme.primaryColor,
|
color: props.highlighted ? theme.white : theme.textColorPrimary,
|
||||||
'& *': {
|
'& *': {
|
||||||
color: props.highlighted ? `${theme.white} !important` : undefined,
|
color: props.highlighted ? `${theme.white} !important` : undefined,
|
||||||
},
|
},
|
||||||
@@ -50,25 +50,26 @@ const TableBodyRowContainer = styled.div<TableBodyRowContainerProps>(
|
|||||||
TableBodyRowContainer.displayName = 'TableRow:TableBodyRowContainer';
|
TableBodyRowContainer.displayName = 'TableRow:TableBodyRowContainer';
|
||||||
|
|
||||||
const TableBodyColumnContainer = styled.div<{
|
const TableBodyColumnContainer = styled.div<{
|
||||||
width?: any;
|
width: Width;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
justifyContent: Property.JustifyContent;
|
justifyContent: 'left' | 'right' | 'center' | 'flex-start';
|
||||||
}>((props) => ({
|
}>((props) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexShrink: props.width === 'flex' ? 1 : 0,
|
flexShrink: props.width === undefined ? 1 : 0,
|
||||||
flexGrow: props.width === 'flex' ? 1 : 0,
|
flexGrow: props.width === undefined ? 1 : 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
padding: `0 ${theme.space.small}px`,
|
padding: `0 ${theme.space.small}px`,
|
||||||
verticalAlign: 'top',
|
verticalAlign: 'top',
|
||||||
whiteSpace: props.multiline ? 'normal' : 'nowrap',
|
whiteSpace: props.multiline ? 'normal' : 'nowrap',
|
||||||
wordWrap: props.multiline ? 'break-word' : 'normal',
|
wordWrap: props.multiline ? 'break-word' : 'normal',
|
||||||
width: props.width === 'flex' ? undefined : props.width,
|
width: props.width,
|
||||||
justifyContent: props.justifyContent,
|
justifyContent: props.justifyContent,
|
||||||
|
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||||
}));
|
}));
|
||||||
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
|
TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
config: RenderingConfig<any>;
|
config: RenderContext;
|
||||||
highlighted: boolean;
|
highlighted: boolean;
|
||||||
row: any;
|
row: any;
|
||||||
};
|
};
|
||||||
@@ -76,47 +77,31 @@ type Props = {
|
|||||||
export const TableRow = memo(function TableRow(props: Props) {
|
export const TableRow = memo(function TableRow(props: Props) {
|
||||||
const {config, highlighted, row} = props;
|
const {config, highlighted, row} = props;
|
||||||
return (
|
return (
|
||||||
<TableBodyRowContainer highlighted={highlighted} data-key={row.key}>
|
<TableBodyRowContainer
|
||||||
{config.columns.map((col) => {
|
highlighted={highlighted}
|
||||||
const value = (col as any).onRender
|
data-key={row.key}
|
||||||
? (col as any).onRender(row)
|
className="ant-table-row">
|
||||||
: (row as any)[col.key] ?? '';
|
{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 (
|
return (
|
||||||
<TableBodyColumnContainer
|
<TableBodyColumnContainer
|
||||||
key={col.key as string}
|
className="ant-table-cell"
|
||||||
multiline={col.wrap}
|
key={col.key as string}
|
||||||
justifyContent={col.align ? col.align : 'flex-start'}
|
multiline={col.wrap}
|
||||||
width={normaliseColumnWidth(col.width)}>
|
justifyContent={col.align ? col.align : 'flex-start'}
|
||||||
{value}
|
width={col.width}>
|
||||||
</TableBodyColumnContainer>
|
{value}
|
||||||
);
|
</TableBodyColumnContainer>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
</TableBodyRowContainer>
|
</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] === '%';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
116
desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx
Normal file
116
desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import invariant from 'invariant';
|
|
||||||
|
|
||||||
export default class LowPassFilter {
|
export default class LowPassFilter {
|
||||||
constructor(smoothing: number = 0.9) {
|
constructor(smoothing: number = 0.9) {
|
||||||
this.smoothing = smoothing;
|
this.smoothing = smoothing;
|
||||||
@@ -29,10 +27,10 @@ export default class LowPassFilter {
|
|||||||
|
|
||||||
if (this.hasFullBuffer()) {
|
if (this.hasFullBuffer()) {
|
||||||
const tmp: number | undefined = this.buffer.shift();
|
const tmp: number | undefined = this.buffer.shift();
|
||||||
invariant(
|
if (tmp === undefined)
|
||||||
tmp !== undefined,
|
throw new Error(
|
||||||
'Invariant violation: Buffer reported full but shift returned nothing.',
|
'Invariant violation: Buffer reported full but shift returned nothing.',
|
||||||
);
|
);
|
||||||
removed = tmp;
|
removed = tmp;
|
||||||
}
|
}
|
||||||
|
|
||||||
15
desktop/flipper-plugin/src/ui/utils/Rect.tsx
Normal file
15
desktop/flipper-plugin/src/ui/utils/Rect.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Rect} from './geometry';
|
import {Rect} from './Rect';
|
||||||
|
|
||||||
export const SNAP_SIZE = 16;
|
export const SNAP_SIZE = 16;
|
||||||
|
|
||||||
23
desktop/flipper-plugin/src/ui/utils/widthUtils.tsx
Normal file
23
desktop/flipper-plugin/src/ui/utils/widthUtils.tsx
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user