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
This commit is contained in:
Feiyu Wong
2022-07-22 09:16:37 -07:00
committed by Facebook GitHub Bot
parent 96a23495c9
commit 3fbf1215ec
10 changed files with 526 additions and 129 deletions

View File

@@ -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<T> = {
type: 'append';
entry: Entry<T>;
@@ -28,7 +30,9 @@ type UpdateEvent<T> = {
type: 'update';
entry: Entry<T>;
oldValue: T;
oldVisible: boolean;
oldVisible: {
[viewId: string]: boolean;
};
index: number;
};
type RemoveEvent<T> = {
@@ -51,8 +55,12 @@ type DataEvent<T> =
type Entry<T> = {
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<T extends any, KeyType = never> {
*/
public readonly view: DataSourceView<T, KeyType>;
public readonly additionalViews: {
[viewId: string]: DataSourceView<T, KeyType>;
};
constructor(keyAttribute: keyof T | undefined) {
this.keyAttribute = keyAttribute;
this.view = new DataSourceView<T, KeyType>(this);
this.view = new DataSourceView<T, KeyType>(this, DEFAULT_VIEW_ID);
this.additionalViews = {};
}
public get size() {
@@ -228,12 +241,17 @@ export class DataSource<T extends any, KeyType = never> {
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<T extends any, KeyType = never> {
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<T extends any, KeyType = never> {
// 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<T extends any, KeyType = never> {
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<T, KeyType> {
throw new Error(
'Not implemented. Please contact oncall if this feature is needed',
);
private fork(viewId: string): DataSourceView<T, KeyType> {
this._records.forEach((entry) => {
entry.visible[viewId] = entry.visible[DEFAULT_VIEW_ID];
entry.approxIndex[viewId] = entry.approxIndex[DEFAULT_VIEW_ID];
});
const newView = new DataSourceView<T, KeyType>(this, viewId);
// Refresh the new view so that it has all the existing records.
newView.rebuild();
return newView;
}
public getAdditionalView(viewId: string): DataSourceView<T, KeyType> {
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<T extends any, KeyType = never> {
// 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<T>(entry: Entry<T>): T {
return entry?.value;
}
class DataSourceView<T, KeyType> {
export class DataSourceView<T, KeyType> {
public readonly datasource: DataSource<T, KeyType>;
private sortBy: undefined | ((a: T) => Primitive) = undefined;
private reverse: boolean = false;
@@ -471,6 +526,7 @@ class DataSourceView<T, KeyType> {
* @readonly
*/
public windowEnd = 0;
private viewId;
private outputChangeListeners = new Set<(change: OutputChange) => void>();
@@ -479,8 +535,9 @@ class DataSourceView<T, KeyType> {
*/
private _output: Entry<T>[] = [];
constructor(datasource: DataSource<T, KeyType>) {
constructor(datasource: DataSource<T, KeyType>, viewId: string) {
this.datasource = datasource;
this.viewId = viewId;
}
public get size() {
@@ -591,8 +648,11 @@ class DataSourceView<T, KeyType> {
// 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<T, KeyType> {
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<T, KeyType> {
}
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<T, KeyType> {
} 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<T, KeyType> {
} 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<T, KeyType> {
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<T, KeyType> {
const records: Entry<T>[] = 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<T, KeyType> {
// 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<T, KeyType> {
private getSortedIndex(entry: Entry<T>, 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<T, KeyType> {
entry,
this.sortHelper,
);
entry.approxIndex = insertionIndex;
entry.approxIndex[this.viewId] = insertionIndex;
this._output.splice(insertionIndex, 0, entry);
this.notifyItemShift(insertionIndex, 1);
}

View File

@@ -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<T extends object, C> = {
/**
* The data source to render
* The data view to render
*/
dataSource: DataSource<T, T[keyof T]>;
dataView: DataSourceView<T, T[keyof T]>;
/**
* additional context that will be passed verbatim to the itemRenderer, so that it can be easily memoized
*/
@@ -30,11 +30,12 @@ type DataSourceProps<T extends object, C> = {
itemRenderer(item: T, index: number, context: C): React.ReactElement;
useFixedRowHeight: boolean;
defaultRowHeight: number;
maxRecords: number;
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
onUpdateAutoScroll?(autoScroll: boolean): void;
emptyRenderer?:
| null
| ((dataSource: DataSource<T, T[keyof T]>) => React.ReactElement);
| ((dataView: DataSourceView<T, T[keyof T]>) => React.ReactElement);
};
/**
@@ -44,7 +45,8 @@ type DataSourceProps<T extends object, C> = {
export const DataSourceRendererStatic: <T extends object, C>(
props: DataSourceProps<T, C>,
) => React.ReactElement = memo(function DataSourceRendererStatic({
dataSource,
dataView,
maxRecords,
useFixedRowHeight,
context,
itemRenderer,
@@ -65,8 +67,8 @@ export const DataSourceRendererStatic: <T extends object, C>(
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: <T extends object, C>(
unsubscribe();
};
},
[dataSource, setForceUpdate, useFixedRowHeight],
[dataView, maxRecords, setForceUpdate, useFixedRowHeight],
);
useEffect(() => {
@@ -89,7 +91,7 @@ export const DataSourceRendererStatic: <T extends object, C>(
/**
* 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: <T extends object, C>(
<RedrawContext.Provider value={redraw}>
<div onKeyDown={onKeyDown} tabIndex={0}>
{records.length === 0
? emptyRenderer?.(dataSource)
? emptyRenderer?.(dataView)
: records.map((item, index) => (
<div key={index}>{itemRenderer(item, index, context)}</div>
))}

View File

@@ -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<T extends object, C> = {
/**
* The data source to render
*/
dataSource: DataSource<T, T[keyof T]>;
dataView: DataSourceView<T, T[keyof T]>;
/**
* Automatically scroll if the user is near the end?
*/
@@ -68,7 +68,7 @@ type DataSourceProps<T extends object, C> = {
onUpdateAutoScroll?(autoScroll: boolean): void;
emptyRenderer?:
| null
| ((dataSource: DataSource<T, T[keyof T]>) => React.ReactElement);
| ((dataView: DataSourceView<T, T[keyof T]>) => React.ReactElement);
};
/**
@@ -78,7 +78,7 @@ type DataSourceProps<T extends object, C> = {
export const DataSourceRendererVirtual: <T extends object, C>(
props: DataSourceProps<T, C>,
) => React.ReactElement = memo(function DataSourceRendererVirtual({
dataSource,
dataView,
defaultRowHeight,
useFixedRowHeight,
context,
@@ -102,7 +102,7 @@ export const DataSourceRendererVirtual: <T extends object, C>(
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: <T extends object, C>(
}
}
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: <T extends object, C>(
unsubscribe();
};
},
[dataSource, setForceUpdate, useFixedRowHeight, isUnitTest],
[setForceUpdate, useFixedRowHeight, isUnitTest, dataView],
);
useEffect(() => {
@@ -215,15 +215,15 @@ export const DataSourceRendererVirtual: <T extends object, C>(
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: <T extends object, C>(
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: <T extends object, C>(
<RedrawContext.Provider value={redraw}>
<div ref={parentRef} onScroll={onScroll} style={tableContainerStyle}>
{virtualizer.virtualItems.length === 0
? emptyRenderer?.(dataSource)
? emptyRenderer?.(dataView)
: null}
<div
style={{
@@ -300,7 +300,7 @@ export const DataSourceRendererVirtual: <T extends object, C>(
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 (

View File

@@ -9,6 +9,7 @@
export {
DataSource,
DataSourceView,
createDataSource,
DataSourceOptions,
DataSourceOptionKey,

View File

@@ -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<T = any> = {
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<T = any> = {
onContextMenu?: (selection: undefined | T) => React.ReactElement;
onRenderEmpty?:
| null
| ((dataSource?: DataSource<T, T[keyof T]>) => React.ReactElement);
| ((dataView?: DataSourceView<T, T[keyof T]>) => React.ReactElement);
};
export type ItemRenderer<T> = (
@@ -87,12 +89,14 @@ export type ItemRenderer<T> = (
type DataTableInput<T = any> =
| {
dataSource: DataSource<T, T[keyof T]>;
viewId?: string;
records?: undefined;
recordsKey?: undefined;
}
| {
records: readonly T[];
recordsKey?: keyof T;
viewId?: string;
dataSource?: undefined;
};
@@ -139,6 +143,9 @@ export function DataTable<T extends object>(
): 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<T extends object>(
() =>
createInitialState({
dataSource,
dataView,
defaultColumns: props.columns,
onSelect,
scope,
@@ -172,9 +180,10 @@ export function DataTable<T extends object>(
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<any>).current = tableManager;
}
@@ -183,22 +192,22 @@ export function DataTable<T extends object>(
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<T extends object>(
(e: React.KeyboardEvent<any>) => {
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<T extends object>(
e.preventDefault();
}
},
[dataSource, tableManager, props.scrollable],
[dataView, props.scrollable, tableManager],
);
const [setFilter] = useState(() => (tableState: DataManagerState<T>) => {
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<T extends object>(
),
);
// 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<T extends object>(
useEffect(
function updateFilter() {
if (!dataSource.view.isFiltered) {
if (!dataView.isFiltered) {
setFilter(tableState);
} else {
debouncedSetFilter(tableState);
@@ -413,14 +422,14 @@ export function DataTable<T extends object>(
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<T extends object>(
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<T extends object>(
[props.enableAutoScroll],
);
const sidePanelToggle = useMemo(
() => (
<Menu.Item key="toggle side by side">
<Layout.Horizontal
gap
center
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}>
Side By Side View
<Switch
checked={tableState.sideBySide}
size="small"
onChange={() => {
tableManager.toggleSideBySide();
}}
/>
</Layout.Horizontal>
</Menu.Item>
),
[tableManager, tableState.sideBySide],
);
/** Context menu */
const contexMenu = isUnitTest
? undefined
@@ -491,7 +524,7 @@ export function DataTable<T extends object>(
useCallback(
() =>
tableContextMenuFactory(
dataSource,
dataView,
dispatch,
selection,
tableState.highlightSearchSetting,
@@ -500,17 +533,19 @@ export function DataTable<T extends object>(
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<T extends object>(
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<any>).current = undefined;
}
};
@@ -544,7 +583,7 @@ export function DataTable<T extends object>(
dispatch={dispatch as any}
searchHistory={tableState.searchHistory}
contextMenu={props.enableContextMenu ? contexMenu : undefined}
extraActions={props.extraActions}
extraActions={!props.viewId ? props.extraActions : undefined}
/>
)}
</Layout.Container>
@@ -575,7 +614,7 @@ export function DataTable<T extends object>(
if (props.scrollable) {
const dataSourceRenderer = (
<DataSourceRendererVirtual<T, TableRowRenderContext<T>>
dataSource={dataSource}
dataView={dataView}
autoScroll={tableState.autoScroll && !dragging.current}
useFixedRowHeight={!tableState.usesWrapping}
defaultRowHeight={DEFAULT_ROW_HEIGHT}
@@ -614,10 +653,11 @@ export function DataTable<T extends object>(
{header}
{columnHeaders}
<DataSourceRendererStatic<T, TableRowRenderContext<T>>
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<T extends object>(
</Layout.Container>
);
}
return (
<Layout.Container grow={props.scrollable}>
const mainPanel = (
<Layout.Container grow={props.scrollable} style={{position: 'relative'}}>
<HighlightProvider
text={
tableState.highlightSearchSetting.highlightEnabled
@@ -655,6 +694,15 @@ export function DataTable<T extends object>(
{range && !isUnitTest && <RangeFinder>{range}</RangeFinder>}
</Layout.Container>
);
return props.enableMultiPanels && tableState.sideBySide ? (
//TODO: Make the panels resizable by having a dynamic maxWidth for Layout.Right/Left possibly?
<Layout.Horizontal style={{height: '100%'}}>
{mainPanel}
{<DataTable<T> viewId={'1'} {...props} enableMultiPanels={false} />}
</Layout.Horizontal>
) : (
mainPanel
);
}
DataTable.defaultProps = {
@@ -709,16 +757,16 @@ function syncRecordsToDataSource<T>(
}
function createDefaultEmptyRenderer<T>(dataTableManager?: DataTableManager<T>) {
return (dataSource?: DataSource<T, T[keyof T]>) => (
<EmptyTable dataSource={dataSource} dataManager={dataTableManager} />
return (dataView?: DataSourceView<T, T[keyof T]>) => (
<EmptyTable dataView={dataView} dataManager={dataTableManager} />
);
}
function EmptyTable<T>({
dataSource,
dataView,
dataManager,
}: {
dataSource?: DataSource<T, T[keyof T]>;
dataView?: DataSourceView<T, T[keyof T]>;
dataManager?: DataTableManager<T>;
}) {
const resetFilters = useCallback(() => {
@@ -728,7 +776,7 @@ function EmptyTable<T>({
<Layout.Container
center
style={{width: '100%', padding: 40, color: theme.textColorSecondary}}>
{dataSource?.size === 0 ? (
{dataView?.size === 0 ? (
<>
<CoffeeOutlined style={{fontSize: '2em', margin: 8}} />
<Typography.Text type="secondary">No records yet</Typography.Text>

View File

@@ -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<T> =
| Action<'clearSearchHistory'>
| Action<'toggleHighlightSearch'>
| Action<'setSearchHighlightColor', {color: string}>
| Action<'toggleFilterSearchHistory'>;
| Action<'toggleFilterSearchHistory'>
| Action<'toggleSideBySide'>;
type DataManagerConfig<T> = {
dataSource: DataSource<T, T[keyof T]>;
dataView: DataSourceView<T, T[keyof T]>;
defaultColumns: DataTableColumn<T>[];
scope: string;
onSelect: undefined | ((item: T | undefined, items: T[]) => void);
@@ -138,6 +144,7 @@ export type DataManagerState<T> = {
previousSearchValue: string;
searchHistory: string[];
highlightSearchSetting: SearchHighlightSetting;
sideBySide: boolean;
};
export type DataTableReducer<T> = Reducer<
@@ -288,7 +295,7 @@ export const dataTableManagerReducer = produce<
}
case 'setColumnFilterFromSelection': {
const items = getSelectedItems(
config.dataSource as DataSource<any>,
config.dataView as DataSourceView<any, any>,
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<T> = {
toggleColumnVisibility(column: keyof T): void;
sortColumn(column: keyof T, direction?: SortDirection): void;
setSearchValue(value: string, addToHistory?: boolean): void;
dataSource: DataSource<T, T[keyof T]>;
dataView: DataSourceView<T, T[keyof T]>;
toggleSearchValue(): void;
toggleHighlightSearch(): void;
setSearchHighlightColor(color: string): void;
toggleSideBySide(): void;
};
export function createDataTableManager<T>(
dataSource: DataSource<T, T[keyof T]>,
dataView: DataSourceView<T, T[keyof T]>,
dispatch: DataTableDispatch<T>,
stateRef: MutableRefObject<DataManagerState<T>>,
): DataTableManager<T> {
@@ -389,10 +401,10 @@ export function createDataTableManager<T>(
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<T>(
setSearchHighlightColor(color) {
dispatch({type: 'setSearchHighlightColor', color});
},
dataSource,
toggleSideBySide() {
dispatch({type: 'toggleSideBySide'});
},
dataView,
};
}
@@ -462,6 +477,7 @@ export function createInitialState<T>(
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<T>(
}
export function getSelectedItem<T>(
dataSource: DataSource<T, T[keyof T]>,
dataView: DataSourceView<T, T[keyof T]>,
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<T>(
dataSource: DataSource<T, T[keyof T]>,
dataView: DataSourceView<T, T[keyof T]>,
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')

View File

@@ -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<T>(
datasource: DataSource<T, T[keyof T]>,
dataView: DataSourceView<T, T[keyof T]>,
dispatch: DataTableDispatch<T>,
selection: Selection,
highlightSearchSetting: SearchHighlightSetting,
@@ -43,6 +43,7 @@ export function tableContextMenuFactory<T>(
visibleColumns: DataTableColumn<T>[],
) => string = defaultOnCopyRows,
onContextMenu?: (selection: undefined | T) => React.ReactElement,
sideBySideOption?: React.ReactElement,
) {
const lib = tryGetFlipperLibImplementation();
if (!lib) {
@@ -56,7 +57,7 @@ export function tableContextMenuFactory<T>(
return (
<Menu>
{onContextMenu
? onContextMenu(getSelectedItem(datasource, selection))
? onContextMenu(getSelectedItem(dataView, selection))
: null}
<SubMenu
key="filter same"
@@ -85,7 +86,7 @@ export function tableContextMenuFactory<T>(
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<T>(
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<T>(
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<T>(
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<T>(
<Item
key={'copy cell' + (column.key ?? idx)}
onClick={() => {
const items = getSelectedItems(datasource, selection);
const items = getSelectedItems(dataView, selection);
if (items.length) {
lib.writeTextToClipboard(
items
@@ -269,6 +270,7 @@ export function tableContextMenuFactory<T>(
</Layout.Horizontal>
</Menu.Item>
</SubMenu>
{sideBySideOption}
</Menu>
);
}

View File

@@ -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<DataTableManager<Todo>>();
const rendering = render(
<DataTable
enableMultiPanels
dataSource={ds}
columns={columns}
tableManagerRef={ref}
/>,
);
{
const elem = await rendering.findAllByText('test DataTable');
expect(elem.length).toBe(1);
expect(elem[0].parentElement?.parentElement).toMatchInlineSnapshot(`
<div
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
>
<div
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
width="50%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
test DataTable
</span>
</div>
<div
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
width="50%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
true
</span>
</div>
</div>
`);
}
// 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<DataTableManager<Todo>>();
const rendering = render(
<DataTable
enableMultiPanels
dataSource={ds}
columns={columns}
tableManagerRef={ref}
/>,
);
{
const elem = await rendering.findAllByText('test DataTable');
expect(elem.length).toBe(1);
expect(elem[0].parentElement?.parentElement).toMatchInlineSnapshot(`
<div
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
>
<div
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
width="50%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
test DataTable
</span>
</div>
<div
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
width="50%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
true
</span>
</div>
</div>
`);
}
// 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<DataTableManager<Todo>>();
const rendering = render(
<DataTable
enableMultiPanels
dataSource={ds}
columns={columns}
tableManagerRef={ref}
/>,
);
{
const elem = await rendering.findAllByText('test DataTable');
expect(elem.length).toBe(1);
expect(elem[0].parentElement?.parentElement).toMatchInlineSnapshot(`
<div
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
>
<div
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
width="50%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
test DataTable
</span>
</div>
<div
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
width="50%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
true
</span>
</div>
</div>
`);
}
// 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(`
<div
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
>
<div
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
width="50%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
test DataTable
</span>
</div>
</div>
`);
}
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(`
<div
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
>
<div
class="css-1baxqcf-TableBodyColumnContainer e1luu51r0"
width="50%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
DataTable tested
</span>
</div>
</div>
`);
}
});
test('open second panel and closing deletes dataView', async () => {
const ds = createTestDataSource();
const ref = createRef<DataTableManager<Todo>>();
render(
<DataTable
enableMultiPanels
dataSource={ds}
columns={columns}
tableManagerRef={ref}
/>,
);
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);
});

View File

@@ -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(() => {

View File

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