From 3fbf1215ec468b39823ec41152b645dc4f416b56 Mon Sep 17 00:00:00 2001 From: Feiyu Wong Date: Fri, 22 Jul 2022 09:16:37 -0700 Subject: [PATCH] Refactored DataView to be the primary data driver for DataTable instead Summary: In order to accomplish multi-panel mode, we need to use multiple data views on the same data source so that the filters can be applied differently, etc. This diff serves to refactor DataTable and some of its associated classes to use DataView as the primary driver for data management. Additionally, the diff refactored the state to allow multi-paneling to be on the DataPanel layer instead of the DataTable layer for ease of usage This is the last diff of the larger stack which introduces the multi-panel mode feature. A possible next step could be allowing infinite(up to a certain limit) panels to be populated. Changelog: Introduced side by side view feature for `DataTable`. There is now a new boolean for `DataTable` props called `enableMultiPanels`. If this is passed in, then the table will have an option to open a different "side panel" using a completely different dataview which allows different filters, searches, etc. Reviewed By: mweststrate Differential Revision: D37685390 fbshipit-source-id: 51e35f59da1ceba07ba8d379066970b57ab1734e --- .../src/data-source/DataSource.tsx | 132 ++++++--- .../data-source/DataSourceRendererStatic.tsx | 22 +- .../data-source/DataSourceRendererVirtual.tsx | 30 +- .../flipper-plugin/src/data-source/index.tsx | 1 + .../src/ui/data-table/DataTable.tsx | 134 ++++++--- .../src/ui/data-table/DataTableManager.tsx | 44 +-- .../src/ui/data-table/TableContextMenu.tsx | 18 +- .../data-table/__tests__/DataTable.node.tsx | 270 ++++++++++++++++++ .../public/logs/__tests__/logs.node.tsx | 1 + desktop/plugins/public/logs/index.tsx | 3 +- 10 files changed, 526 insertions(+), 129 deletions(-) diff --git a/desktop/flipper-plugin/src/data-source/DataSource.tsx b/desktop/flipper-plugin/src/data-source/DataSource.tsx index 3c1ac7a10..fbc6df931 100644 --- a/desktop/flipper-plugin/src/data-source/DataSource.tsx +++ b/desktop/flipper-plugin/src/data-source/DataSource.tsx @@ -20,6 +20,8 @@ const defaultLimit = 100 * 1000; // rather than search and remove the affected individual items const shiftRebuildTreshold = 0.05; +const DEFAULT_VIEW_ID = '0'; + type AppendEvent = { type: 'append'; entry: Entry; @@ -28,7 +30,9 @@ type UpdateEvent = { type: 'update'; entry: Entry; oldValue: T; - oldVisible: boolean; + oldVisible: { + [viewId: string]: boolean; + }; index: number; }; type RemoveEvent = { @@ -51,8 +55,12 @@ type DataEvent = type Entry = { value: T; id: number; // insertion based - visible: boolean; // matches current filter? - approxIndex: number; // we could possible live at this index in the output. No guarantees. + visible: { + [viewId: string]: boolean; + }; // matches current filter? + approxIndex: { + [viewId: string]: number; + }; // we could possible live at this index in the output. No guarantees. }; type Primitive = number | string | boolean | null | undefined; @@ -138,9 +146,14 @@ export class DataSource { */ public readonly view: DataSourceView; + public readonly additionalViews: { + [viewId: string]: DataSourceView; + }; + constructor(keyAttribute: keyof T | undefined) { this.keyAttribute = keyAttribute; - this.view = new DataSourceView(this); + this.view = new DataSourceView(this, DEFAULT_VIEW_ID); + this.additionalViews = {}; } public get size() { @@ -228,12 +241,17 @@ export class DataSource { this._recordsById.set(key, value); this.storeIndexOfKey(key, this._records.length); } + const visibleMap: {[viewId: string]: boolean} = {[DEFAULT_VIEW_ID]: false}; + const approxIndexMap: {[viewId: string]: number} = {[DEFAULT_VIEW_ID]: -1}; + Object.keys(this.additionalViews).forEach((viewId) => { + visibleMap[viewId] = false; + approxIndexMap[viewId] = -1; + }); const entry = { value, id: ++this.nextId, - // once we have multiple views, the following fields should be stored per view - visible: true, - approxIndex: -1, + visible: visibleMap, + approxIndex: approxIndexMap, }; this._records.push(entry); this.emitDataEvent({ @@ -268,7 +286,7 @@ export class DataSource { if (value === oldValue) { return; } - const oldVisible = entry.visible; + const oldVisible = {...entry.visible}; entry.value = value; if (this.keyAttribute) { const key = this.getKey(value); @@ -374,7 +392,7 @@ export class DataSource { // let's fallback to the async processing of all data instead // MWE: there is a risk here that rebuilding is too blocking, as this might happen // in background when new data arrives, and not explicitly on a user interaction - this.view.rebuild(); + this.rebuild(); } else { this.emitDataEvent({ type: 'shift', @@ -392,17 +410,51 @@ export class DataSource { this._recordsById = new Map(); this.shiftOffset = 0; this.idToIndex = new Map(); + this.rebuild(); + } + + /** + * The rebuild function that would support rebuilding multiple views all at once + */ + public rebuild() { this.view.rebuild(); + Object.entries(this.additionalViews).forEach(([, dataView]) => { + dataView.rebuild(); + }); } /** * Returns a fork of this dataSource, that shares the source data with this dataSource, * but has it's own FSRW pipeline, to allow multiple views on the same data */ - public fork(): DataSourceView { - throw new Error( - 'Not implemented. Please contact oncall if this feature is needed', - ); + private fork(viewId: string): DataSourceView { + this._records.forEach((entry) => { + entry.visible[viewId] = entry.visible[DEFAULT_VIEW_ID]; + entry.approxIndex[viewId] = entry.approxIndex[DEFAULT_VIEW_ID]; + }); + const newView = new DataSourceView(this, viewId); + // Refresh the new view so that it has all the existing records. + newView.rebuild(); + return newView; + } + + public getAdditionalView(viewId: string): DataSourceView { + if (viewId in this.additionalViews) { + return this.additionalViews[viewId]; + } + this.additionalViews[viewId] = this.fork(viewId); + return this.additionalViews[viewId]; + } + + public deleteView(viewId: string): void { + if (viewId in this.additionalViews) { + delete this.additionalViews[viewId]; + // TODO: Ideally remove the viewId in the visible and approxIndex of DataView outputs + this._records.forEach((entry) => { + delete entry.visible[viewId]; + delete entry.approxIndex[viewId]; + }); + } } private assertKeySet() { @@ -433,6 +485,9 @@ export class DataSource { // using a queue, // or only if there is an active view (although that could leak memory) this.view.processEvent(event); + Object.entries(this.additionalViews).forEach(([, dataView]) => { + dataView.processEvent(event); + }); } /** @@ -457,7 +512,7 @@ function unwrap(entry: Entry): T { return entry?.value; } -class DataSourceView { +export class DataSourceView { public readonly datasource: DataSource; private sortBy: undefined | ((a: T) => Primitive) = undefined; private reverse: boolean = false; @@ -471,6 +526,7 @@ class DataSourceView { * @readonly */ public windowEnd = 0; + private viewId; private outputChangeListeners = new Set<(change: OutputChange) => void>(); @@ -479,8 +535,9 @@ class DataSourceView { */ private _output: Entry[] = []; - constructor(datasource: DataSource) { + constructor(datasource: DataSource, viewId: string) { this.datasource = datasource; + this.viewId = viewId; } public get size() { @@ -591,8 +648,11 @@ class DataSourceView { // so any changes in the entry being moved around etc will be reflected in the original `entry` object, // and we just want to verify that this entry is indeed still the same element, visible, and still present in // the output data set. - if (entry.visible && entry.id === this._output[entry.approxIndex]?.id) { - return this.normalizeIndex(entry.approxIndex); + if ( + entry.visible[this.viewId] && + entry.id === this._output[entry.approxIndex[this.viewId]]?.id + ) { + return this.normalizeIndex(entry.approxIndex[this.viewId]); } return -1; } @@ -674,16 +734,16 @@ class DataSourceView { switch (event.type) { case 'append': { const {entry} = event; - entry.visible = filter ? filter(entry.value) : true; - if (!entry.visible) { + entry.visible[this.viewId] = filter ? filter(entry.value) : true; + if (!entry.visible[this.viewId]) { // not in filter? skip this entry return; } if (!sortBy) { // no sorting? insert at the end, or beginning - entry.approxIndex = output.length; + entry.approxIndex[this.viewId] = output.length; output.push(entry); - this.notifyItemShift(entry.approxIndex, 1); + this.notifyItemShift(entry.approxIndex[this.viewId], 1); } else { this.insertSorted(entry); } @@ -691,13 +751,13 @@ class DataSourceView { } case 'update': { const {entry} = event; - entry.visible = filter ? filter(entry.value) : true; + entry.visible[this.viewId] = filter ? filter(entry.value) : true; // short circuit; no view active so update straight away if (!filter && !sortBy) { - output[event.index].approxIndex = event.index; + output[event.index].approxIndex[this.viewId] = event.index; this.notifyItemUpdated(event.index); - } else if (!event.oldVisible) { - if (!entry.visible) { + } else if (!event.oldVisible[this.viewId]) { + if (!entry.visible[this.viewId]) { // Done! } else { // insertion, not visible before @@ -706,7 +766,7 @@ class DataSourceView { } else { // Entry was visible previously const existingIndex = this.getSortedIndex(entry, event.oldValue); - if (!entry.visible) { + if (!entry.visible[this.viewId]) { // Remove from output output.splice(existingIndex, 1); this.notifyItemShift(existingIndex, -1); @@ -744,7 +804,7 @@ class DataSourceView { } else { // if there is a filter, count the visibles and shift those for (let i = 0; i < event.entries.length; i++) - if (event.entries[i].visible) amount++; + if (event.entries[i].visible[this.viewId]) amount++; } output.splice(0, amount); this.notifyItemShift(0, -amount); @@ -766,7 +826,7 @@ class DataSourceView { const {_output: output, sortBy, filter} = this; // filter active, and not visible? short circuilt - if (!entry.visible) { + if (!entry.visible[this.viewId]) { return; } // no sorting, no filter? @@ -798,8 +858,8 @@ class DataSourceView { const records: Entry[] = this.datasource._records; let output = filter ? records.filter((entry) => { - entry.visible = filter(entry.value); - return entry.visible; + entry.visible[this.viewId] = filter(entry.value); + return entry.visible[this.viewId]; }) : records.slice(); if (sortBy) { @@ -818,7 +878,7 @@ class DataSourceView { // write approx indexes for faster lookup of entries in visible output for (let i = 0; i < output.length; i++) { - output[i].approxIndex = i; + output[i].approxIndex[this.viewId] = i; } this._output = output; this.notifyReset(output.length); @@ -829,17 +889,17 @@ class DataSourceView { private getSortedIndex(entry: Entry, oldValue: T) { const {_output: output} = this; - if (output[entry.approxIndex] === entry) { + if (output[entry.approxIndex[this.viewId]] === entry) { // yay! - return entry.approxIndex; + return entry.approxIndex[this.viewId]; } let index = sortedIndexBy( output, { value: oldValue, id: -1, - visible: true, - approxIndex: -1, + visible: entry.visible, + approxIndex: entry.approxIndex, }, this.sortHelper, ); @@ -862,7 +922,7 @@ class DataSourceView { entry, this.sortHelper, ); - entry.approxIndex = insertionIndex; + entry.approxIndex[this.viewId] = insertionIndex; this._output.splice(insertionIndex, 0, entry); this.notifyItemShift(insertionIndex, 1); } diff --git a/desktop/flipper-plugin/src/data-source/DataSourceRendererStatic.tsx b/desktop/flipper-plugin/src/data-source/DataSourceRendererStatic.tsx index 4de2bfb8a..59765d24d 100644 --- a/desktop/flipper-plugin/src/data-source/DataSourceRendererStatic.tsx +++ b/desktop/flipper-plugin/src/data-source/DataSourceRendererStatic.tsx @@ -7,16 +7,16 @@ * @format */ -import {DataSource} from './DataSource'; +import {DataSourceView} from './DataSource'; import React, {memo, useCallback, useEffect, useState} from 'react'; import {RedrawContext} from './DataSourceRendererVirtual'; type DataSourceProps = { /** - * The data source to render + * The data view to render */ - dataSource: DataSource; + dataView: DataSourceView; /** * additional context that will be passed verbatim to the itemRenderer, so that it can be easily memoized */ @@ -30,11 +30,12 @@ type DataSourceProps = { itemRenderer(item: T, index: number, context: C): React.ReactElement; useFixedRowHeight: boolean; defaultRowHeight: number; + maxRecords: number; onKeyDown?: React.KeyboardEventHandler; onUpdateAutoScroll?(autoScroll: boolean): void; emptyRenderer?: | null - | ((dataSource: DataSource) => React.ReactElement); + | ((dataView: DataSourceView) => React.ReactElement); }; /** @@ -44,7 +45,8 @@ type DataSourceProps = { export const DataSourceRendererStatic: ( props: DataSourceProps, ) => React.ReactElement = memo(function DataSourceRendererStatic({ - dataSource, + dataView, + maxRecords, useFixedRowHeight, context, itemRenderer, @@ -65,8 +67,8 @@ export const DataSourceRendererStatic: ( function subscribeToDataSource() { let unmounted = false; - dataSource.view.setWindow(0, dataSource.limit); - const unsubscribe = dataSource.view.addListener((_event) => { + dataView.setWindow(0, maxRecords); + const unsubscribe = dataView.addListener((_event) => { if (unmounted) { return; } @@ -78,7 +80,7 @@ export const DataSourceRendererStatic: ( unsubscribe(); }; }, - [dataSource, setForceUpdate, useFixedRowHeight], + [dataView, maxRecords, setForceUpdate, useFixedRowHeight], ); useEffect(() => { @@ -89,7 +91,7 @@ export const DataSourceRendererStatic: ( /** * Rendering */ - const records = dataSource.view.output(); + const records = dataView.output(); if (records.length > 500) { console.warn( "StaticDataSourceRenderer should only be used on small datasets. For large datasets the 'scrollable' flag should enabled on DataTable", @@ -100,7 +102,7 @@ export const DataSourceRendererStatic: (
{records.length === 0 - ? emptyRenderer?.(dataSource) + ? emptyRenderer?.(dataView) : records.map((item, index) => (
{itemRenderer(item, index, context)}
))} diff --git a/desktop/flipper-plugin/src/data-source/DataSourceRendererVirtual.tsx b/desktop/flipper-plugin/src/data-source/DataSourceRendererVirtual.tsx index 9e53b222a..32eae8610 100644 --- a/desktop/flipper-plugin/src/data-source/DataSourceRendererVirtual.tsx +++ b/desktop/flipper-plugin/src/data-source/DataSourceRendererVirtual.tsx @@ -18,7 +18,7 @@ import React, { useContext, createContext, } from 'react'; -import {DataSource} from './DataSource'; +import {DataSourceView} from './DataSource'; import {useVirtual} from 'react-virtual'; import observeRect from '@reach/observe-rect'; @@ -39,7 +39,7 @@ type DataSourceProps = { /** * The data source to render */ - dataSource: DataSource; + dataView: DataSourceView; /** * Automatically scroll if the user is near the end? */ @@ -68,7 +68,7 @@ type DataSourceProps = { onUpdateAutoScroll?(autoScroll: boolean): void; emptyRenderer?: | null - | ((dataSource: DataSource) => React.ReactElement); + | ((dataView: DataSourceView) => React.ReactElement); }; /** @@ -78,7 +78,7 @@ type DataSourceProps = { export const DataSourceRendererVirtual: ( props: DataSourceProps, ) => React.ReactElement = memo(function DataSourceRendererVirtual({ - dataSource, + dataView, defaultRowHeight, useFixedRowHeight, context, @@ -102,7 +102,7 @@ export const DataSourceRendererVirtual: ( const isUnitTest = useInUnitTest(); const virtualizer = useVirtual({ - size: dataSource.view.size, + size: dataView.size, parentRef, useObserver: isUnitTest ? () => ({height: 500, width: 1000}) : undefined, // eslint-disable-next-line @@ -170,20 +170,20 @@ export const DataSourceRendererVirtual: ( } } - const unsubscribe = dataSource.view.addListener((event) => { + const unsubscribe = dataView.addListener((event) => { switch (event.type) { case 'reset': rerender(UpdatePrio.HIGH, true); break; case 'shift': - if (dataSource.view.size < SMALL_DATASET) { + if (dataView.size < SMALL_DATASET) { rerender(UpdatePrio.HIGH, false); } else if ( event.location === 'in' || // to support smooth tailing we want to render on records directly at the end of the window immediately as well (event.location === 'after' && event.delta > 0 && - event.index === dataSource.view.windowEnd) + event.index === dataView.windowEnd) ) { rerender(UpdatePrio.HIGH, false); } else { @@ -204,7 +204,7 @@ export const DataSourceRendererVirtual: ( unsubscribe(); }; }, - [dataSource, setForceUpdate, useFixedRowHeight, isUnitTest], + [setForceUpdate, useFixedRowHeight, isUnitTest, dataView], ); useEffect(() => { @@ -215,15 +215,15 @@ export const DataSourceRendererVirtual: ( useLayoutEffect(function updateWindow() { const start = virtualizer.virtualItems[0]?.index ?? 0; const end = start + virtualizer.virtualItems.length; - if (start !== dataSource.view.windowStart && !autoScroll) { + if (start !== dataView.windowStart && !autoScroll) { onRangeChange?.( start, end, - dataSource.view.size, + dataView.size, parentRef.current?.scrollTop ?? 0, ); } - dataSource.view.setWindow(start, end); + dataView.setWindow(start, end); }); /** @@ -245,7 +245,7 @@ export const DataSourceRendererVirtual: ( useLayoutEffect(function scrollToEnd() { if (autoScroll) { virtualizer.scrollToIndex( - dataSource.view.size - 1, + dataView.size - 1, /* smooth is not typed by react-virtual, but passed on to the DOM as it should*/ { align: 'end', @@ -290,7 +290,7 @@ export const DataSourceRendererVirtual: (
{virtualizer.virtualItems.length === 0 - ? emptyRenderer?.(dataSource) + ? emptyRenderer?.(dataView) : null}
( onKeyDown={onKeyDown} tabIndex={0}> {virtualizer.virtualItems.map((virtualRow) => { - const value = dataSource.view.get(virtualRow.index); + const value = dataView.get(virtualRow.index); // 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*/} return ( diff --git a/desktop/flipper-plugin/src/data-source/index.tsx b/desktop/flipper-plugin/src/data-source/index.tsx index e5a3ab9db..785c343ed 100644 --- a/desktop/flipper-plugin/src/data-source/index.tsx +++ b/desktop/flipper-plugin/src/data-source/index.tsx @@ -9,6 +9,7 @@ export { DataSource, + DataSourceView, createDataSource, DataSourceOptions, DataSourceOptionKey, diff --git a/desktop/flipper-plugin/src/ui/data-table/DataTable.tsx b/desktop/flipper-plugin/src/ui/data-table/DataTable.tsx index 6462d8f30..d5331bdfa 100644 --- a/desktop/flipper-plugin/src/ui/data-table/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/data-table/DataTable.tsx @@ -27,6 +27,7 @@ import { DataSourceRendererVirtual, DataSourceRendererStatic, DataSource, + DataSourceView, DataSourceVirtualizer, } from '../../data-source/index'; import { @@ -45,7 +46,7 @@ import {TableSearch} from './TableSearch'; import styled from '@emotion/styled'; import {theme} from '../theme'; import {tableContextMenuFactory} from './TableContextMenu'; -import {Typography} from 'antd'; +import {Menu, Switch, Typography} from 'antd'; import {CoffeeOutlined, SearchOutlined, PushpinFilled} from '@ant-design/icons'; import {useAssertStableRef} from '../../utils/useAssertStableRef'; import {Formatter} from '../DataFormatter'; @@ -65,6 +66,7 @@ type DataTableBaseProps = { enableMultiSelect?: boolean; enableContextMenu?: boolean; enablePersistSettings?: boolean; + enableMultiPanels?: boolean; // if set (the default) will grow and become scrollable. Otherwise will use natural size scrollable?: boolean; extraActions?: React.ReactElement; @@ -75,7 +77,7 @@ type DataTableBaseProps = { onContextMenu?: (selection: undefined | T) => React.ReactElement; onRenderEmpty?: | null - | ((dataSource?: DataSource) => React.ReactElement); + | ((dataView?: DataSourceView) => React.ReactElement); }; export type ItemRenderer = ( @@ -87,12 +89,14 @@ export type ItemRenderer = ( type DataTableInput = | { dataSource: DataSource; + viewId?: string; records?: undefined; recordsKey?: undefined; } | { records: readonly T[]; recordsKey?: keyof T; + viewId?: string; dataSource?: undefined; }; @@ -139,6 +143,9 @@ export function DataTable( ): React.ReactElement { const {onRowStyle, onSelect, onCopyRows, onContextMenu} = props; const dataSource = normalizeDataSourceInput(props); + const dataView = props?.viewId + ? dataSource.getAdditionalView(props.viewId) + : dataSource.view; useAssertStableRef(dataSource, 'dataSource'); useAssertStableRef(onRowStyle, 'onRowStyle'); useAssertStableRef(props.onSelect, 'onRowSelect'); @@ -157,6 +164,7 @@ export function DataTable( () => createInitialState({ dataSource, + dataView, defaultColumns: props.columns, onSelect, scope, @@ -172,9 +180,10 @@ export function DataTable( const dragging = useRef(false); const [tableManager] = useState(() => - createDataTableManager(dataSource, dispatch, stateRef), + createDataTableManager(dataView, dispatch, stateRef), ); - if (props.tableManagerRef) { + // Make sure this is the main table + if (props.tableManagerRef && !props.viewId) { (props.tableManagerRef as MutableRefObject).current = tableManager; } @@ -183,22 +192,22 @@ export function DataTable( const latestSelectionRef = useLatestRef(selection); const latestOnSelectRef = useLatestRef(onSelect); useEffect(() => { - if (dataSource) { - const unsubscribe = dataSource.view.addListener((change) => { + if (dataView) { + const unsubscribe = dataView.addListener((change) => { if ( change.type === 'update' && latestSelectionRef.current.items.has(change.index) ) { latestOnSelectRef.current?.( - getSelectedItem(dataSource, latestSelectionRef.current), - getSelectedItems(dataSource, latestSelectionRef.current), + getSelectedItem(dataView, latestSelectionRef.current), + getSelectedItems(dataView, latestSelectionRef.current), ); } }); return unsubscribe; } - }, [dataSource, latestSelectionRef, latestOnSelectRef]); + }, [dataView, latestSelectionRef, latestOnSelectRef]); const visibleColumns = useMemo( () => columns.filter((column) => column.visible), @@ -290,10 +299,10 @@ export function DataTable( (e: React.KeyboardEvent) => { let handled = true; const shiftPressed = e.shiftKey; - const outputSize = dataSource.view.size; + const outputSize = dataView.size; const windowSize = props.scrollable ? virtualizerRef.current?.virtualItems.length ?? 0 - : dataSource.view.size; + : dataView.size; if (!windowSize) { return; } @@ -346,15 +355,15 @@ export function DataTable( e.preventDefault(); } }, - [dataSource, tableManager, props.scrollable], + [dataView, props.scrollable, tableManager], ); const [setFilter] = useState(() => (tableState: DataManagerState) => { const selectedEntry = tableState.selection.current >= 0 - ? dataSource.view.getEntry(tableState.selection.current) + ? dataView.getEntry(tableState.selection.current) : null; - dataSource.view.setFilter( + dataView.setFilter( computeDataTableFilter( tableState.searchValue, tableState.useRegex, @@ -362,10 +371,10 @@ export function DataTable( ), ); // TODO: in the future setFilter effects could be async, at the moment it isn't, - // so we can safely assume the internal state of the dataSource.view is updated with the + // so we can safely assume the internal state of the dataView is updated with the // filter changes and try to find the same entry back again if (selectedEntry) { - const selectionIndex = dataSource.view.getViewIndexOfEntry(selectedEntry); + const selectionIndex = dataView.getViewIndexOfEntry(selectedEntry); tableManager.selectItem(selectionIndex, false, false); // we disable autoScroll as is it can accidentally be annoying if it was never turned off and // filter causes items to not fill the available space @@ -391,7 +400,7 @@ export function DataTable( useEffect( function updateFilter() { - if (!dataSource.view.isFiltered) { + if (!dataView.isFiltered) { setFilter(tableState); } else { debouncedSetFilter(tableState); @@ -413,14 +422,14 @@ export function DataTable( useEffect( function updateSorting() { if (tableState.sorting === undefined) { - dataSource.view.setSortBy(undefined); - dataSource.view.setReversed(false); + dataView.setSortBy(undefined); + dataView.setReversed(false); } else { - dataSource.view.setSortBy(tableState.sorting.key); - dataSource.view.setReversed(tableState.sorting.direction === 'desc'); + dataView.setSortBy(tableState.sorting.key); + dataView.setReversed(tableState.sorting.direction === 'desc'); } }, - [dataSource, tableState.sorting], + [dataView, tableState.sorting], ); const isMounted = useRef(false); @@ -428,13 +437,13 @@ export function DataTable( function triggerSelection() { if (isMounted.current) { onSelect?.( - getSelectedItem(dataSource, tableState.selection), - getSelectedItems(dataSource, tableState.selection), + getSelectedItem(dataView, tableState.selection), + getSelectedItems(dataView, tableState.selection), ); } isMounted.current = true; }, - [onSelect, dataSource, tableState.selection], + [onSelect, dataView, tableState.selection], ); // The initialScrollPosition is used to both capture the initial px we want to scroll to, @@ -484,6 +493,30 @@ export function DataTable( [props.enableAutoScroll], ); + const sidePanelToggle = useMemo( + () => ( + + { + e.stopPropagation(); + e.preventDefault(); + }}> + Side By Side View + { + tableManager.toggleSideBySide(); + }} + /> + + + ), + [tableManager, tableState.sideBySide], + ); + /** Context menu */ const contexMenu = isUnitTest ? undefined @@ -491,7 +524,7 @@ export function DataTable( useCallback( () => tableContextMenuFactory( - dataSource, + dataView, dispatch, selection, tableState.highlightSearchSetting, @@ -500,17 +533,19 @@ export function DataTable( visibleColumns, onCopyRows, onContextMenu, + props.enableMultiPanels ? sidePanelToggle : undefined, ), [ - dataSource, - dispatch, + dataView, selection, - tableState.columns, tableState.highlightSearchSetting, tableState.filterSearchHistory, + tableState.columns, visibleColumns, onCopyRows, onContextMenu, + props.enableMultiPanels, + sidePanelToggle, ], ); @@ -523,9 +558,13 @@ export function DataTable( savePreferences(stateRef.current, lastOffset.current); // if the component unmounts, we reset the SFRW pipeline to // avoid wasting resources in the background - dataSource.view.reset(); - // clean ref - if (props.tableManagerRef) { + dataView.reset(); + if (props.viewId) { + // this is a side panel + dataSource.deleteView(props.viewId); + } + // clean ref && Make sure this is the main table + if (props.tableManagerRef && !props.viewId) { (props.tableManagerRef as MutableRefObject).current = undefined; } }; @@ -544,7 +583,7 @@ export function DataTable( dispatch={dispatch as any} searchHistory={tableState.searchHistory} contextMenu={props.enableContextMenu ? contexMenu : undefined} - extraActions={props.extraActions} + extraActions={!props.viewId ? props.extraActions : undefined} /> )} @@ -575,7 +614,7 @@ export function DataTable( if (props.scrollable) { const dataSourceRenderer = ( > - dataSource={dataSource} + dataView={dataView} autoScroll={tableState.autoScroll && !dragging.current} useFixedRowHeight={!tableState.usesWrapping} defaultRowHeight={DEFAULT_ROW_HEIGHT} @@ -614,10 +653,11 @@ export function DataTable( {header} {columnHeaders} > - dataSource={dataSource} + dataView={dataView} useFixedRowHeight={!tableState.usesWrapping} defaultRowHeight={DEFAULT_ROW_HEIGHT} context={renderingConfig} + maxRecords={dataSource.limit} itemRenderer={itemRenderer} onKeyDown={onKeyDown} emptyRenderer={emptyRenderer} @@ -625,9 +665,8 @@ export function DataTable( ); } - - return ( - + const mainPanel = ( + ( {range && !isUnitTest && {range}} ); + return props.enableMultiPanels && tableState.sideBySide ? ( + //TODO: Make the panels resizable by having a dynamic maxWidth for Layout.Right/Left possibly? + + {mainPanel} + { viewId={'1'} {...props} enableMultiPanels={false} />} + + ) : ( + mainPanel + ); } DataTable.defaultProps = { @@ -709,16 +757,16 @@ function syncRecordsToDataSource( } function createDefaultEmptyRenderer(dataTableManager?: DataTableManager) { - return (dataSource?: DataSource) => ( - + return (dataView?: DataSourceView) => ( + ); } function EmptyTable({ - dataSource, + dataView, dataManager, }: { - dataSource?: DataSource; + dataView?: DataSourceView; dataManager?: DataTableManager; }) { const resetFilters = useCallback(() => { @@ -728,7 +776,7 @@ function EmptyTable({ - {dataSource?.size === 0 ? ( + {dataView?.size === 0 ? ( <> No records yet diff --git a/desktop/flipper-plugin/src/ui/data-table/DataTableManager.tsx b/desktop/flipper-plugin/src/ui/data-table/DataTableManager.tsx index ab6cb99af..fc1ff362a 100644 --- a/desktop/flipper-plugin/src/ui/data-table/DataTableManager.tsx +++ b/desktop/flipper-plugin/src/ui/data-table/DataTableManager.tsx @@ -10,7 +10,11 @@ import type {DataTableColumn} from './DataTable'; import {Percentage} from '../../utils/widthUtils'; import {MutableRefObject, Reducer} from 'react'; -import {DataSource, DataSourceVirtualizer} from '../../data-source/index'; +import { + DataSource, + DataSourceView, + DataSourceVirtualizer, +} from '../../data-source/index'; import produce, {castDraft, immerable, original} from 'immer'; import {theme} from '../theme'; @@ -110,10 +114,12 @@ type DataManagerActions = | Action<'clearSearchHistory'> | Action<'toggleHighlightSearch'> | Action<'setSearchHighlightColor', {color: string}> - | Action<'toggleFilterSearchHistory'>; + | Action<'toggleFilterSearchHistory'> + | Action<'toggleSideBySide'>; type DataManagerConfig = { dataSource: DataSource; + dataView: DataSourceView; defaultColumns: DataTableColumn[]; scope: string; onSelect: undefined | ((item: T | undefined, items: T[]) => void); @@ -138,6 +144,7 @@ export type DataManagerState = { previousSearchValue: string; searchHistory: string[]; highlightSearchSetting: SearchHighlightSetting; + sideBySide: boolean; }; export type DataTableReducer = Reducer< @@ -288,7 +295,7 @@ export const dataTableManagerReducer = produce< } case 'setColumnFilterFromSelection': { const items = getSelectedItems( - config.dataSource as DataSource, + config.dataView as DataSourceView, draft.selection, ); items.forEach((item, index) => { @@ -324,6 +331,10 @@ export const dataTableManagerReducer = produce< } break; } + case 'toggleSideBySide': { + draft.sideBySide = !draft.sideBySide; + break; + } default: { throw new Error('Unknown action ' + (action as any).type); } @@ -353,14 +364,15 @@ export type DataTableManager = { toggleColumnVisibility(column: keyof T): void; sortColumn(column: keyof T, direction?: SortDirection): void; setSearchValue(value: string, addToHistory?: boolean): void; - dataSource: DataSource; + dataView: DataSourceView; toggleSearchValue(): void; toggleHighlightSearch(): void; setSearchHighlightColor(color: string): void; + toggleSideBySide(): void; }; export function createDataTableManager( - dataSource: DataSource, + dataView: DataSourceView, dispatch: DataTableDispatch, stateRef: MutableRefObject>, ): DataTableManager { @@ -389,10 +401,10 @@ export function createDataTableManager( dispatch({type: 'clearSelection'}); }, getSelectedItem() { - return getSelectedItem(dataSource, stateRef.current.selection); + return getSelectedItem(dataView, stateRef.current.selection); }, getSelectedItems() { - return getSelectedItems(dataSource, stateRef.current.selection); + return getSelectedItems(dataView, stateRef.current.selection); }, toggleColumnVisibility(column) { dispatch({type: 'toggleColumnVisibility', column}); @@ -412,7 +424,10 @@ export function createDataTableManager( setSearchHighlightColor(color) { dispatch({type: 'setSearchHighlightColor', color}); }, - dataSource, + toggleSideBySide() { + dispatch({type: 'toggleSideBySide'}); + }, + dataView, }; } @@ -462,6 +477,7 @@ export function createInitialState( highlightEnabled: false, color: theme.searchHighlightBackground.yellow, }, + sideBySide: false, }; // @ts-ignore res.config[immerable] = false; // optimization: never proxy anything in config @@ -497,21 +513,19 @@ function addColumnFilter( } export function getSelectedItem( - dataSource: DataSource, + dataView: DataSourceView, selection: Selection, ): T | undefined { - return selection.current < 0 - ? undefined - : dataSource.view.get(selection.current); + return selection.current < 0 ? undefined : dataView.get(selection.current); } export function getSelectedItems( - dataSource: DataSource, + dataView: DataSourceView, selection: Selection, ): T[] { return [...selection.items] .sort((a, b) => a - b) // https://stackoverflow.com/a/15765283/1983583 - .map((i) => dataSource.view.get(i)) + .map((i) => dataView.get(i)) .filter(Boolean) as any[]; } @@ -645,12 +659,10 @@ export function computeDataTableFilter( return false; } } - //free search all top level keys as well as any (nested) columns in the table const nestedColumns = columns .map((col) => col.key) .filter((path) => path.includes('.')); - return [...Object.keys(item), ...nestedColumns] .map((key) => getValueAtPath(item, key)) .filter((val) => typeof val !== 'object') diff --git a/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx b/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx index 16edd9c99..ee8535846 100644 --- a/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx +++ b/desktop/flipper-plugin/src/ui/data-table/TableContextMenu.tsx @@ -22,7 +22,7 @@ import React from 'react'; import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; import {DataTableColumn} from './DataTable'; import {toFirstUpper} from '../../utils/toFirstUpper'; -import {DataSource} from '../../data-source/index'; +import {DataSourceView} from '../../data-source/index'; import {renderColumnValue} from './TableRow'; import {textContent} from '../../utils/textContent'; import {theme} from '../theme'; @@ -31,7 +31,7 @@ const {Item, SubMenu} = Menu; const {Option} = Select; export function tableContextMenuFactory( - datasource: DataSource, + dataView: DataSourceView, dispatch: DataTableDispatch, selection: Selection, highlightSearchSetting: SearchHighlightSetting, @@ -43,6 +43,7 @@ export function tableContextMenuFactory( visibleColumns: DataTableColumn[], ) => string = defaultOnCopyRows, onContextMenu?: (selection: undefined | T) => React.ReactElement, + sideBySideOption?: React.ReactElement, ) { const lib = tryGetFlipperLibImplementation(); if (!lib) { @@ -56,7 +57,7 @@ export function tableContextMenuFactory( return ( {onContextMenu - ? onContextMenu(getSelectedItem(datasource, selection)) + ? onContextMenu(getSelectedItem(dataView, selection)) : null} ( key="copyToClipboard" disabled={!hasSelection} onClick={() => { - const items = getSelectedItems(datasource, selection); + const items = getSelectedItems(dataView, selection); if (items.length) { lib.writeTextToClipboard(onCopyRows(items, visibleColumns)); } @@ -97,7 +98,7 @@ export function tableContextMenuFactory( key="createPaste" disabled={!hasSelection} onClick={() => { - const items = getSelectedItems(datasource, selection); + const items = getSelectedItems(dataView, selection); if (items.length) { lib.createPaste(onCopyRows(items, visibleColumns)); } @@ -109,7 +110,7 @@ export function tableContextMenuFactory( key="copyToClipboardJSON" disabled={!hasSelection} onClick={() => { - const items = getSelectedItems(datasource, selection); + const items = getSelectedItems(dataView, selection); if (items.length) { lib.writeTextToClipboard(rowsToJson(items)); } @@ -121,7 +122,7 @@ export function tableContextMenuFactory( key="createPasteJSON" disabled={!hasSelection} onClick={() => { - const items = getSelectedItems(datasource, selection); + const items = getSelectedItems(dataView, selection); if (items.length) { lib.createPaste(rowsToJson(items)); } @@ -140,7 +141,7 @@ export function tableContextMenuFactory( { - const items = getSelectedItems(datasource, selection); + const items = getSelectedItems(dataView, selection); if (items.length) { lib.writeTextToClipboard( items @@ -269,6 +270,7 @@ export function tableContextMenuFactory( + {sideBySideOption} ); } diff --git a/desktop/flipper-plugin/src/ui/data-table/__tests__/DataTable.node.tsx b/desktop/flipper-plugin/src/ui/data-table/__tests__/DataTable.node.tsx index 5967c62fc..3992cfb05 100644 --- a/desktop/flipper-plugin/src/ui/data-table/__tests__/DataTable.node.tsx +++ b/desktop/flipper-plugin/src/ui/data-table/__tests__/DataTable.node.tsx @@ -764,3 +764,273 @@ test('selection always has the latest state', () => { rendering.unmount(); }); + +test('open second panel 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?.parentElement).toMatchInlineSnapshot(` +
+
+ + + test DataTable + +
+
+ + + true + +
+
+ `); + } + // hide done + act(() => { + ref.current?.toggleSideBySide(); + }); + expect(Object.keys(ds.additionalViews).length).toBeGreaterThan(0); + act(() => { + ds.append({ + title: 'Drink coffee', + done: false, + }); + }); + { + const elem = await rendering.findAllByText('Drink coffee'); + expect(elem.length).toBe(2); + } + act(() => { + ds.append({ + title: 'Drink tea', + done: false, + }); + }); + { + const elem = await rendering.findAllByText('Drink tea'); + expect(elem.length).toBe(2); + } +}); + +test('open second panel and update', 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?.parentElement).toMatchInlineSnapshot(` +
+
+ + + test DataTable + +
+
+ + + true + +
+
+ `); + } + // hide done + act(() => { + ds.append({ + title: 'Drink coffee', + done: false, + }); + }); + { + const elems = await rendering.findAllByText('Drink coffee'); + expect(elems.length).toBe(1); + } + act(() => { + ref.current?.toggleSideBySide(); + }); + expect(Object.keys(ds.additionalViews).length).toBeGreaterThan(0); + { + const elems = await rendering.findAllByText('Drink coffee'); + expect(elems.length).toBe(2); + } + act(() => { + ds.update(0, { + title: 'DataTable tested', + done: false, + }); + }); + { + const elems = await rendering.findAllByText('Drink coffee'); + expect(elems.length).toBe(2); + expect(rendering.queryByText('test DataTable')).toBeNull(); + const newElems = await rendering.findAllByText('DataTable tested'); + expect(newElems.length).toBe(2); + } +}); + +test('open second panel and 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?.parentElement).toMatchInlineSnapshot(` +
+
+ + + test DataTable + +
+
+ + + true + +
+
+ `); + } + + // toggle column visibility of first table(main panel) + act(() => { + ref.current?.toggleSideBySide(); + ref.current?.toggleColumnVisibility('done'); + }); + { + const elem = await rendering.findAllByText('test DataTable'); + expect(elem.length).toBe(2); + expect(elem[0].parentElement?.parentElement).toMatchInlineSnapshot(` +
+
+ + + test DataTable + +
+
+ `); + } + + act(() => { + ds.update(0, { + title: 'DataTable tested', + done: false, + }); + }); + { + expect(rendering.queryByText('test DataTable')).toBeNull(); + const elem = await rendering.findAllByText('DataTable tested'); + expect(elem.length).toBe(2); + expect(elem[0].parentElement?.parentElement).toMatchInlineSnapshot(` +
+
+ + + DataTable tested + +
+
+ `); + } +}); + +test('open second panel and closing deletes dataView', async () => { + const ds = createTestDataSource(); + const ref = createRef>(); + render( + , + ); + expect(Object.keys(ds.additionalViews).length).toBe(0); + act(() => { + ref.current?.toggleSideBySide(); + }); + expect(Object.keys(ds.additionalViews).length).toBe(1); + act(() => { + ref.current?.toggleSideBySide(); + }); + expect(Object.keys(ds.additionalViews).length).toBe(0); +}); diff --git a/desktop/plugins/public/logs/__tests__/logs.node.tsx b/desktop/plugins/public/logs/__tests__/logs.node.tsx index 54e7e01c4..09b476a5a 100644 --- a/desktop/plugins/public/logs/__tests__/logs.node.tsx +++ b/desktop/plugins/public/logs/__tests__/logs.node.tsx @@ -94,6 +94,7 @@ test('it supports deeplink and select nodes + navigating to bottom', async () => sendLogEntry(entry3); expect(instance.tableManagerRef).not.toBeUndefined(); + expect(instance.tableManagerRef.current).not.toBeNull(); expect(instance.tableManagerRef.current?.getSelectedItems()).toEqual([]); act(() => { diff --git a/desktop/plugins/public/logs/index.tsx b/desktop/plugins/public/logs/index.tsx index 517596672..ccea40ca4 100644 --- a/desktop/plugins/public/logs/index.tsx +++ b/desktop/plugins/public/logs/index.tsx @@ -12,13 +12,13 @@ import { DeviceLogEntry, usePlugin, createDataSource, - DataTable, DataTableColumn, theme, DataTableManager, createState, useValue, DataFormatter, + DataTable, } from 'flipper-plugin'; import { PlayCircleOutlined, @@ -227,6 +227,7 @@ export function Component() { dataSource={plugin.rows} columns={plugin.columns} enableAutoScroll + enableMultiPanels onRowStyle={getRowStyle} enableHorizontalScroll={false} extraActions={