From 86ad4136696cfcd56e54d3897bff4f8df60d4285 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Tue, 16 Mar 2021 14:54:53 -0700 Subject: [PATCH] Initial logs with datasource / datatable setup Summary: First rudementary setup of DataTable component that follows a data source. Initially used react-virtuose library, but it performed really badly by doing expensive layout shifts and having troublesome scroll handling. Switched to react-virtual library, which is a bit more level, but much more efficient, and the source code is actually understandable :) Features: - hook up to window events of datasource - high and low prio rendering, based on where the change is happening (should be optimized further) - sticky scrolling support - initial column configuration (custom rendering, styling, columns etc will follow in next diffs) Reviewed By: nikoant Differential Revision: D26175665 fbshipit-source-id: 224be13b1b32d35e7e01c1dc4198811e2af31102 --- desktop/flipper-plugin/package.json | 3 +- .../flipper-plugin/src/__tests__/api.node.tsx | 3 + desktop/flipper-plugin/src/index.ts | 4 +- .../src/state/datasource/DataSource.tsx | 16 +- .../src/ui/datatable/DataTable.tsx | 272 ++++++++++++++++++ .../src/ui/datatable/TableRow.tsx | 122 ++++++++ desktop/yarn.lock | 18 ++ docs/extending/flipper-plugin.mdx | 5 + 8 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 desktop/flipper-plugin/src/ui/datatable/DataTable.tsx create mode 100644 desktop/flipper-plugin/src/ui/datatable/TableRow.tsx diff --git a/desktop/flipper-plugin/package.json b/desktop/flipper-plugin/package.json index aafe031b8..930194924 100644 --- a/desktop/flipper-plugin/package.json +++ b/desktop/flipper-plugin/package.json @@ -13,7 +13,8 @@ "@emotion/react": "^11.1.1", "immer": "^8.0.1", "lodash": "^4.17.20", - "react-element-to-jsx-string": "^14.3.2" + "react-element-to-jsx-string": "^14.3.2", + "react-virtual": "^2.4.0" }, "devDependencies": { "@types/jest": "^26.0.20", diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index 1ed280d75..0309e212d 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -28,6 +28,8 @@ test('Correct top level API exposed', () => { // Note, all `exposedAPIs` should be documented in `flipper-plugin.mdx` expect(exposedAPIs.sort()).toMatchInlineSnapshot(` Array [ + "DataSource", + "DataTable", "Layout", "NUX", "TestUtils", @@ -52,6 +54,7 @@ test('Correct top level API exposed', () => { expect(exposedTypes.sort()).toMatchInlineSnapshot(` Array [ "Atom", + "DataTableColumn", "DefaultKeyboardAction", "Device", "DeviceLogEntry", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 35da44b4e..d78028934 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -71,7 +71,9 @@ export { } from './utils/Logger'; export {Idler} from './utils/Idler'; -export {createDataSource} from './state/datasource/DataSource'; +export {createDataSource, DataSource} from './state/datasource/DataSource'; + +export {DataTable, DataTableColumn} from './ui/datatable/DataTable'; // It's not ideal that this exists in flipper-plugin sources directly, // but is the least pain for plugin authors. diff --git a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx index 83f82e147..bf00ffa98 100644 --- a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx +++ b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx @@ -70,9 +70,10 @@ type OutputChange = newCount: number; }; +// TODO: remove class, export interface instead export class DataSource< T, - KEY extends keyof T, + KEY extends keyof T = any, KEY_TYPE extends string | number | never = ExtractKeyType > { private nextId = 0; @@ -267,6 +268,9 @@ export class DataSource< setOutputChangeListener( listener: typeof DataSource['prototype']['outputChangeListener'], ) { + if (this.outputChangeListener && listener) { + console.warn('outputChangeListener already set'); + } this.outputChangeListener = listener; } @@ -303,11 +307,14 @@ export class DataSource< * The clear operation removes any records stored, but will keep the current view preferences such as sorting and filtering */ clear() { + this.windowStart = 0; + this.windowEnd = 0; this._records = []; this._recordsById = new Map(); this.idToIndex = new Map(); this.dataUpdateQueue = []; this.output = []; + this.notifyReset(0); } /** @@ -372,6 +379,13 @@ export class DataSource< }); } + notifyReset(count: number) { + this.outputChangeListener?.({ + type: 'reset', + newCount: count, + }); + } + processEvents() { const events = this.dataUpdateQueue.splice(0); events.forEach(this.processEvent); diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx new file mode 100644 index 000000000..006acd106 --- /dev/null +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -0,0 +1,272 @@ +/** + * 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, + useMemo, + useRef, + useState, + useLayoutEffect, +} 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'; + +// how fast we update if updates are low-prio (e.g. out of window and not super significant) +const DEBOUNCE = 500; //ms + +interface DataTableProps { + columns: DataTableColumn[]; + dataSource: DataSource; + zebra?: boolean; + autoScroll?: boolean; +} + +export type DataTableColumn = ( + | { + // existing data + key: keyof T; + } + | { + // derived data / custom rendering + key: string; + onRender?: (row: T) => React.ReactNode; + } +) & { + label?: string; + width?: number | '*'; + wrap?: boolean; + align?: Property.JustifyContent; + defaultVisible?: boolean; +}; + +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; + + const renderingConfig = useMemo(() => { + return { + columns: props.columns, + zebra: props.zebra !== false, + onMouseDown() { + // TODO: + }, + onMouseEnter() { + // TODO: + }, + }; + }, [props.columns, props.zebra]); + + 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], + ); + + 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*/} +
+ { + + } +
+ ))} +
+
+ ); +}) 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/TableRow.tsx b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx new file mode 100644 index 000000000..1c59ae6df --- /dev/null +++ b/desktop/flipper-plugin/src/ui/datatable/TableRow.tsx @@ -0,0 +1,122 @@ +/** + * 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} from 'react'; +import styled from '@emotion/styled'; +import {Property} from 'csstype'; +import {theme} from 'flipper-plugin'; +import {RenderingConfig} from './DataTable'; + +// heuristic for row estimation, should match any future styling updates +export const DEFAULT_ROW_HEIGHT = 24; + +type TableBodyRowContainerProps = { + highlighted?: boolean; +}; + +const backgroundColor = (props: TableBodyRowContainerProps) => { + if (props.highlighted) { + return theme.primaryColor; + } + return undefined; +}; + +const TableBodyRowContainer = styled.div( + (props) => ({ + display: 'flex', + flexDirection: 'row', + backgroundColor: backgroundColor(props), + color: props.highlighted ? theme.white : theme.primaryColor, + '& *': { + color: props.highlighted ? `${theme.white} !important` : undefined, + }, + '& img': { + backgroundColor: props.highlighted + ? `${theme.white} !important` + : undefined, + }, + minHeight: DEFAULT_ROW_HEIGHT, + overflow: 'hidden', + width: '100%', + flexShrink: 0, + }), +); +TableBodyRowContainer.displayName = 'TableRow:TableBodyRowContainer'; + +const TableBodyColumnContainer = styled.div<{ + width?: any; + multiline?: boolean; + justifyContent: Property.JustifyContent; +}>((props) => ({ + display: 'flex', + flexShrink: props.width === 'flex' ? 1 : 0, + flexGrow: props.width === 'flex' ? 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, + justifyContent: props.justifyContent, +})); +TableBodyColumnContainer.displayName = 'TableRow:TableBodyColumnContainer'; + +type Props = { + config: RenderingConfig; + highlighted: boolean; + row: any; +}; + +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] ?? ''; + + 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/yarn.lock b/desktop/yarn.lock index 2c4002f12..d586e4ee9 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -1936,6 +1936,11 @@ resolved "https://registry.yarnpkg.com/@oclif/screen/-/screen-1.0.4.tgz#b740f68609dfae8aa71c3a6cab15d816407ba493" integrity sha512-60CHpq+eqnTxLZQ4PGHYNwUX572hgpMHGPtTWMjdTMsAvlm69lZV/4ly6O3sAYkomo4NggGcomrDpBe34rxUqw== +"@reach/observe-rect@^1.1.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2" + integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ== + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -10637,6 +10642,14 @@ react-transition-group@^4.4.1: loose-envify "^1.4.0" prop-types "^15.6.2" +react-virtual@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.4.0.tgz#151d39a91b311f684229235604fe75f1d096cd12" + integrity sha512-D5hy0XM+SN2pPUCPXIEVs1aCcGgvkLVw8sTikeKQF4jW4ZS1vIDyGUKFMvW1ZS3Yysw5yFCSBWl8yc7IEsGPag== + dependencies: + "@reach/observe-rect" "^1.1.0" + ts-toolbelt "^6.4.2" + react-virtualized-auto-sizer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd" @@ -12233,6 +12246,11 @@ ts-node@^8, ts-node@^8.8.1: source-map-support "^0.5.6" yn "3.1.1" +ts-toolbelt@^6.4.2: + version "6.15.5" + resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz#cb3b43ed725cb63644782c64fbcad7d8f28c0a83" + integrity sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A== + tsconfig-paths@^3.9.0: version "3.9.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b" diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index a76810d41..2a73d5515 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -439,6 +439,7 @@ console.log(rows.get().length) // 2 ``` ### createDataSource +### DataSource Coming soon. @@ -500,6 +501,10 @@ See [Tracked](#tracked) for more info. Layout elements can be used to organize the screen layout. See `View > Flipper Style Guide` inside the Flipper application for more details. +### DataTable + +Coming soon. + ### NUX An element that can be used to provide a New User eXperience: Hints that give a one time introduction to new features to the current user.