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={