From 44bb5b1beb5bb0ac6827ba92dbf933113dd9d568 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Tue, 16 Mar 2021 14:54:53 -0700 Subject: [PATCH] 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 --- .../PluginInstaller.node.tsx.snap | 16 +- desktop/app/src/index.tsx | 2 +- desktop/app/src/ui/components/Sidebar.tsx | 4 +- .../app/src/ui/components/table/TableHead.tsx | 4 +- desktop/app/src/ui/index.tsx | 1 - desktop/flipper-plugin/src/index.ts | 5 + .../src/ui}/Interactive.tsx | 22 +- .../src/ui}/__tests__/LowPassFilter.node.tsx | 2 +- .../src/ui/datatable/DataSourceRenderer.tsx | 261 +++++++++++++++ .../src/ui/datatable/DataTable.tsx | 300 ++++-------------- .../src/ui/datatable/TableHead.tsx | 254 +++++++++++++++ .../src/ui/datatable/TableRow.tsx | 83 ++--- .../ui/datatable/__tests__/DataTable.node.tsx | 224 +++++++++++++ .../src/ui/datatable/useDataTableManager.tsx | 116 +++++++ .../src/ui}/utils/LowPassFilter.tsx | 10 +- desktop/flipper-plugin/src/ui/utils/Rect.tsx | 15 + .../src/ui}/utils/snap.tsx | 2 +- .../src/ui/utils/widthUtils.tsx | 23 ++ 18 files changed, 1020 insertions(+), 324 deletions(-) rename desktop/{app/src/ui/components => flipper-plugin/src/ui}/Interactive.tsx (97%) rename desktop/{app/src/utils => flipper-plugin/src/ui}/__tests__/LowPassFilter.node.tsx (95%) create mode 100644 desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx create mode 100644 desktop/flipper-plugin/src/ui/datatable/TableHead.tsx create mode 100644 desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx create mode 100644 desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx rename desktop/{app/src => flipper-plugin/src/ui}/utils/LowPassFilter.tsx (88%) create mode 100644 desktop/flipper-plugin/src/ui/utils/Rect.tsx rename desktop/{app/src => flipper-plugin/src/ui}/utils/snap.tsx (99%) create mode 100644 desktop/flipper-plugin/src/ui/utils/widthUtils.tsx diff --git a/desktop/app/src/chrome/plugin-manager/__tests__/__snapshots__/PluginInstaller.node.tsx.snap b/desktop/app/src/chrome/plugin-manager/__tests__/__snapshots__/PluginInstaller.node.tsx.snap index 7f67264e2..cc59fa442 100644 --- a/desktop/app/src/chrome/plugin-manager/__tests__/__snapshots__/PluginInstaller.node.tsx.snap +++ b/desktop/app/src/chrome/plugin-manager/__tests__/__snapshots__/PluginInstaller.node.tsx.snap @@ -34,7 +34,7 @@ exports[`load PluginInstaller list 1`] = ` width="25%" >
({ +const SidebarInteractiveContainer = styled(_Interactive)<_InteractiveProps>({ flex: 'none', }); SidebarInteractiveContainer.displayName = 'Sidebar:SidebarInteractiveContainer'; diff --git a/desktop/app/src/ui/components/table/TableHead.tsx b/desktop/app/src/ui/components/table/TableHead.tsx index dfd3ed396..214e78312 100644 --- a/desktop/app/src/ui/components/table/TableHead.tsx +++ b/desktop/app/src/ui/components/table/TableHead.tsx @@ -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)({ +const TableHeaderColumnInteractive = styled(_Interactive)<_InteractiveProps>({ display: 'inline-block', overflow: 'hidden', textOverflow: 'ellipsis', diff --git a/desktop/app/src/ui/index.tsx b/desktop/app/src/ui/index.tsx index 01a233602..f0a4332a3 100644 --- a/desktop/app/src/ui/index.tsx +++ b/desktop/app/src/ui/index.tsx @@ -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'; diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index d78028934..f42ec4cb8 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -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) diff --git a/desktop/app/src/ui/components/Interactive.tsx b/desktop/flipper-plugin/src/ui/Interactive.tsx similarity index 97% rename from desktop/app/src/ui/components/Interactive.tsx rename to desktop/flipper-plugin/src/ui/Interactive.tsx index 531dd99fc..78698cebf 100644 --- a/desktop/app/src/ui/components/Interactive.tsx +++ b/desktop/flipper-plugin/src/ui/Interactive.tsx @@ -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, diff --git a/desktop/app/src/utils/__tests__/LowPassFilter.node.tsx b/desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx similarity index 95% rename from desktop/app/src/utils/__tests__/LowPassFilter.node.tsx rename to desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx index 44538775a..a897b3fe6 100644 --- a/desktop/app/src/utils/__tests__/LowPassFilter.node.tsx +++ b/desktop/flipper-plugin/src/ui/__tests__/LowPassFilter.node.tsx @@ -7,7 +7,7 @@ * @format */ -import LowPassFilter from '../LowPassFilter'; +import LowPassFilter from '../utils/LowPassFilter'; test('hasFullBuffer', () => { const lpf = new LowPassFilter(); diff --git a/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx new file mode 100644 index 000000000..0ae7c78ba --- /dev/null +++ b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx @@ -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 = { + /** + * The data source to render + */ + dataSource: DataSource; + /** + * 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: ( + props: DataSourceProps, +) => React.ReactElement = memo(function DataSourceRenderer({ + dataSource, + defaultRowHeight, + useFixedRowHeight, + context, + itemRenderer, + autoScroll, + _testHeight, +}: DataSourceProps) { + /** + * 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); + + 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 ( + + + {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*/} +
+ {itemRenderer( + dataSource.getItem(virtualRow.index), + virtualRow.index, + context, + )} +
+ ))} +
+
+ ); +}) 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%', +})); diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx index 006acd106..09947f1dc 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -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 { +interface DataTableProps { columns: DataTableColumn[]; dataSource: DataSource; - zebra?: boolean; autoScroll?: boolean; + tableManagerRef?: RefObject; + _testHeight?: number; // exposed for unit testing only } -export type DataTableColumn = ( - | { - // existing data - key: keyof T; - } - | { - // derived data / custom rendering - key: string; - onRender?: (row: T) => React.ReactNode; - } -) & { - label?: string; - width?: number | '*'; +export type DataTableColumn = { + 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 { +export interface RenderingConfig { columns: DataTableColumn[]; - zebra: boolean; - onMouseDown: (e: React.MouseEvent, row: T) => void; - onMouseEnter: (e: React.MouseEvent, row: T) => void; } -enum UpdatePrio { - NONE, - LOW, - HIGH, -} - -export const DataTable: ( - props: DataTableProps, -) => React.ReactElement = memo(function DataSourceRenderer( - props: DataTableProps, -) { - const {dataSource} = props; +export function DataTable(props: DataTableProps) { + const tableManager = useDataTableManager(props.dataSource, props.columns); + if (props.tableManagerRef) { + (props.tableManagerRef as MutableRefObject).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); - - 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 ( - - - {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*/} -
- { - - } -
- ))} -
-
+ + + + dataSource={props.dataSource} + autoScroll={props.autoScroll} + useFixedRowHeight={!usesWrapping} + defaultRowHeight={DEFAULT_ROW_HEIGHT} + context={renderingConfig} + itemRenderer={itemRenderer} + _testHeight={props._testHeight} + /> + ); -}) as any; +} -const TableContainer = styled.div({ - overflowY: 'scroll', - overflowX: 'hidden', - display: 'flex', - flex: 1, -}); +export type RenderContext = { + columns: DataTableColumn[]; +}; -const TableWindow = styled.div<{height: number}>(({height}) => ({ - height, - position: 'relative', - width: '100%', -})); +function itemRenderer(item: any, index: number, renderContext: RenderContext) { + return ( + + ); +} diff --git a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx new file mode 100644 index 000000000..fb5f41a9e --- /dev/null +++ b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx @@ -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 ( + + + + + ); +} + +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)({ + 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(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 = ( + + {title} + {isSortable && } + + ); + + if (isResizable) { + children = ( + + {children} + + ); + } + + return ( + onSort(id) : undefined} + ref={ref}> + {children} + + ); +} + +export function TableHead({ + columns, + visibleColumns, + ...props +}: { + columns: DataTableColumn[]; + visibleColumns: DataTableColumn[]; + onColumnResize: OnColumnResize; + onColumnToggleVisibility: (key: string) => void; + onReset: () => void; + sorting: Sorting | undefined; + onColumnSort: (key: string) => void; +}) { + const menu = ( + + {columns.map((column) => ( + { + e.domEvent.stopPropagation(); + e.domEvent.preventDefault(); + props.onColumnToggleVisibility(column.key); + }}> + + {column.title || column.key} + + + ))} + + + Reset + + + ); + + return ( + + {visibleColumns.map((column, i) => ( + + ))} + + + + + + + ); +} + +const SettingsButton = styled(Button)({ + padding: 4, + position: 'absolute', + right: 0, + top: 0, + backgroundColor: theme.backgroundWash, +}); diff --git a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx index 1c59ae6df..e63b751dd 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx @@ -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( 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( 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; + 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 ( - - {config.columns.map((col) => { - const value = (col as any).onRender - ? (col as any).onRender(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 ( - - {value} - - ); - })} + return ( + + {value} + + ); + })} ); }); - -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] === '%'; -} diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx new file mode 100644 index 000000000..174f7e6e9 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx @@ -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([ + { + 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(); + const rendering = render( + , + ); + { + const elem = await rendering.findAllByText('test DataTable'); + expect(elem.length).toBe(1); + expect(elem[0].parentElement).toMatchInlineSnapshot(` +
+
+ test DataTable +
+
+ true +
+
+ `); + } + + 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(); + const rendering = render( + , + ); + { + const elem = await rendering.findAllByText('test DataTable'); + expect(elem.length).toBe(1); + expect(elem[0].parentElement).toMatchInlineSnapshot(` +
+
+ test DataTable +
+
+ true +
+
+ `); + } + + // hide done + act(() => { + ref.current?.toggleColumnVisibility('done'); + }); + { + const elem = await rendering.findAllByText('test DataTable'); + expect(elem.length).toBe(1); + expect(elem[0].parentElement).toMatchInlineSnapshot(` +
+
+ test DataTable +
+
+ `); + } + + // 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(); + const rendering = render( + , + ); + // 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', + ]); + } +}); diff --git a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx new file mode 100644 index 000000000..acb272776 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx @@ -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; + +/** + * A hook that coordinates filtering, sorting etc for a DataSource + */ +export function useDataTableManager( + dataSource: DataSource, + defaultColumns: DataTableColumn[], +) { + // TODO: restore from local storage + const [columns, setEffectiveColumns] = useState( + computeInitialColumns(defaultColumns), + ); + const [sorting, setSorting] = useState(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[]) => { + 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[]) => { + 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[], +): DataTableColumn[] { + return columns.map((c) => ({ + ...c, + visible: c.visible !== false, + })); +} diff --git a/desktop/app/src/utils/LowPassFilter.tsx b/desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx similarity index 88% rename from desktop/app/src/utils/LowPassFilter.tsx rename to desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx index 7c5047c6f..72c06fcc5 100644 --- a/desktop/app/src/utils/LowPassFilter.tsx +++ b/desktop/flipper-plugin/src/ui/utils/LowPassFilter.tsx @@ -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; } diff --git a/desktop/flipper-plugin/src/ui/utils/Rect.tsx b/desktop/flipper-plugin/src/ui/utils/Rect.tsx new file mode 100644 index 000000000..7df2160f5 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/utils/Rect.tsx @@ -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; +}; diff --git a/desktop/app/src/utils/snap.tsx b/desktop/flipper-plugin/src/ui/utils/snap.tsx similarity index 99% rename from desktop/app/src/utils/snap.tsx rename to desktop/flipper-plugin/src/ui/utils/snap.tsx index 3ba886e26..d195b842b 100644 --- a/desktop/app/src/utils/snap.tsx +++ b/desktop/flipper-plugin/src/ui/utils/snap.tsx @@ -7,7 +7,7 @@ * @format */ -import {Rect} from './geometry'; +import {Rect} from './Rect'; export const SNAP_SIZE = 16; diff --git a/desktop/flipper-plugin/src/ui/utils/widthUtils.tsx b/desktop/flipper-plugin/src/ui/utils/widthUtils.tsx new file mode 100644 index 000000000..02904133f --- /dev/null +++ b/desktop/flipper-plugin/src/ui/utils/widthUtils.tsx @@ -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; +}