Store preferences and scrolling, refactor to useReducer

Reviewed By: priteshrnandgaonkar

Differential Revision: D26848266

fbshipit-source-id: 738d52556b9fb65ec5b5de7c727467227167b9b9
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent 55981b5259
commit a610c821d3
14 changed files with 854 additions and 627 deletions

View File

@@ -78,7 +78,7 @@ export {Idler} from './utils/Idler';
export {createDataSource, DataSource} from './state/datasource/DataSource'; export {createDataSource, DataSource} from './state/datasource/DataSource';
export {DataTable, DataTableColumn} from './ui/datatable/DataTable'; export {DataTable, DataTableColumn} from './ui/datatable/DataTable';
export {DataTableManager} from './ui/datatable/useDataTableManager'; export {DataTableManager} from './ui/datatable/DataTableManager';
export { export {
Interactive as _Interactive, Interactive as _Interactive,

View File

@@ -297,6 +297,7 @@ export class DataSource<
setFilter(filter: undefined | ((value: T) => boolean)) { setFilter(filter: undefined | ((value: T) => boolean)) {
if (this.filter !== filter) { if (this.filter !== filter) {
this.filter = filter; this.filter = filter;
// TODO: this needs debouncing!
this.rebuildOutput(); this.rebuildOutput();
} }
} }

View File

@@ -203,11 +203,11 @@ const SandySplitContainer = styled.div<{
alignItems: props.center ? 'center' : 'stretch', alignItems: props.center ? 'center' : 'stretch',
gap: normalizeSpace(props.gap, theme.space.small), gap: normalizeSpace(props.gap, theme.space.small),
overflow: props.center ? undefined : 'hidden', // only use overflow hidden in container mode, to avoid weird resizing issues overflow: props.center ? undefined : 'hidden', // only use overflow hidden in container mode, to avoid weird resizing issues
'>:nth-of-type(1)': { '>:nth-child(1)': {
flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle, flex: props.grow === 1 ? splitGrowStyle : splitFixedStyle,
minWidth: props.grow === 1 ? 0 : undefined, minWidth: props.grow === 1 ? 0 : undefined,
}, },
'>:nth-of-type(2)': { '>:nth-child(2)': {
flex: props.grow === 2 ? splitGrowStyle : splitFixedStyle, flex: props.grow === 2 ? splitGrowStyle : splitFixedStyle,
minWidth: props.grow === 2 ? 0 : undefined, minWidth: props.grow === 2 ? 0 : undefined,
}, },

View File

@@ -39,7 +39,7 @@ export function resetGlobalInteractionReporter() {
const DEFAULT_SCOPE = 'Flipper'; const DEFAULT_SCOPE = 'Flipper';
const TrackingScopeContext = createContext(DEFAULT_SCOPE); export const TrackingScopeContext = createContext(DEFAULT_SCOPE);
export function TrackingScope({ export function TrackingScope({
scope, scope,

View File

@@ -11,32 +11,37 @@ import {useMemo, useState} from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import React from 'react'; import React from 'react';
import {Button, Checkbox, Dropdown, Menu, Typography, Input} from 'antd'; import {Button, Checkbox, Dropdown, Menu, Typography, Input} from 'antd';
import {FilterFilled, MinusCircleOutlined} from '@ant-design/icons'; import {
FilterFilled,
MinusCircleOutlined,
PlusCircleOutlined,
} from '@ant-design/icons';
import {theme} from '../theme'; import {theme} from '../theme';
import type {DataTableColumn} from './DataTable'; import type {DataTableColumn} from './DataTable';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
import type {DataTableDispatch} from './DataTableManager';
const {Text} = Typography; const {Text} = Typography;
export type ColumnFilterHandlers = {
onAddColumnFilter(columnId: string, value: string): void;
onRemoveColumnFilter(columnId: string, index: number): void;
onToggleColumnFilter(columnId: string, index: number): void;
onSetColumnFilterFromSelection(columnId: string): void;
};
export function FilterIcon({ export function FilterIcon({
column, column,
...props dispatch,
}: {column: DataTableColumn<any>} & ColumnFilterHandlers) { }: {
column: DataTableColumn<any>;
dispatch: DataTableDispatch;
}) {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const {filters} = column; const {filters} = column;
const isActive = useMemo(() => filters?.some((f) => f.enabled), [filters]); const isActive = useMemo(() => filters?.some((f) => f.enabled), [filters]);
const onAddFilter = (e: React.MouseEvent | React.KeyboardEvent) => { const onAddFilter = (e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation(); e.stopPropagation();
props.onAddColumnFilter(column.key, input); dispatch({
type: 'addColumnFilter',
column: column.key,
value: input,
});
setInput(''); setInput('');
}; };
@@ -60,7 +65,13 @@ export function FilterIcon({
onPressEnter={onAddFilter} onPressEnter={onAddFilter}
disabled={false} disabled={false}
/> />
<Button onClick={onAddFilter}>Add</Button> <Button
onClick={onAddFilter}
title="Add filter"
type="ghost"
style={{padding: '4px 8px'}}>
<PlusCircleOutlined />
</Button>
</Layout.Right> </Layout.Right>
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
@@ -73,7 +84,11 @@ export function FilterIcon({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
props.onToggleColumnFilter(column.key, index); dispatch({
type: 'toggleColumnFilter',
column: column.key,
index,
});
}}> }}>
{filter.label} {filter.label}
</Checkbox> </Checkbox>
@@ -81,7 +96,11 @@ export function FilterIcon({
<MinusCircleOutlined <MinusCircleOutlined
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
props.onRemoveColumnFilter(column.key, index); dispatch({
type: 'removeColumnFilter',
column: column.key,
index,
});
}} }}
/> />
)} )}
@@ -89,52 +108,68 @@ export function FilterIcon({
</Menu.Item> </Menu.Item>
)) ))
) : ( ) : (
<Text type="secondary" style={{margin: 12}}> <Menu.Item disabled>
No active filters <Text type="secondary" style={{margin: 12}}>
</Text> No active filters
</Text>
</Menu.Item>
)} )}
<Menu.Divider /> <Menu.Divider />
<div style={{textAlign: 'right'}}> <Menu.Item disabled>
<Button <div style={{textAlign: 'right'}}>
type="link" <Button
style={{fontWeight: 'unset'}} type="link"
onClick={() => { style={{fontWeight: 'unset'}}
props.onSetColumnFilterFromSelection(column.key); onClick={() => {
}}> dispatch({
From selection type: 'setColumnFilterFromSelection',
</Button> column: column.key,
<Button });
type="link" }}>
style={{fontWeight: 'unset'}} From selection
onClick={() => { </Button>
filters?.forEach((f, index) => { <Button
if (!f.enabled) props.onToggleColumnFilter(column.key, index); type="link"
}); style={{fontWeight: 'unset'}}
}}> onClick={() => {
All filters?.forEach((f, index) => {
</Button> if (!f.enabled) {
<Button dispatch({
type="link" type: 'toggleColumnFilter',
style={{fontWeight: 'unset'}} column: column.key,
onClick={() => { index,
filters?.forEach((f, index) => { });
if (f.enabled) props.onToggleColumnFilter(column.key, index); }
}); });
}}> }}>
None All
</Button> </Button>
</div> <Button
type="link"
style={{fontWeight: 'unset'}}
onClick={() => {
filters?.forEach((f, index) => {
if (f.enabled)
dispatch({
type: 'toggleColumnFilter',
column: column.key,
index,
});
});
}}>
None
</Button>
</div>
</Menu.Item>
</Menu> </Menu>
); );
return ( return (
<div> <Dropdown overlay={menu} trigger={['click']}>
<Dropdown overlay={menu} trigger={['click']}> <FilterButton isActive={isActive}>
<FilterButton isActive={isActive}> <FilterFilled />
<FilterFilled /> </FilterButton>
</FilterButton> </Dropdown>
</Dropdown>
</div>
); );
} }

View File

@@ -55,7 +55,12 @@ type DataSourceProps<T extends object, C> = {
defaultRowHeight: number; defaultRowHeight: number;
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>; onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
virtualizerRef?: MutableRefObject<DataSourceVirtualizer | undefined>; virtualizerRef?: MutableRefObject<DataSourceVirtualizer | undefined>;
onRangeChange?(start: number, end: number, total: number): void; onRangeChange?(
start: number,
end: number,
total: number,
offset: number,
): void;
emptyRenderer?(dataSource: DataSource<T>): React.ReactElement; emptyRenderer?(dataSource: DataSource<T>): React.ReactElement;
_testHeight?: number; // exposed for unit testing only _testHeight?: number; // exposed for unit testing only
}; };
@@ -181,7 +186,12 @@ export const DataSourceRenderer: <T extends object, C>(
const start = virtualizer.virtualItems[0]?.index ?? 0; const start = virtualizer.virtualItems[0]?.index ?? 0;
const end = start + virtualizer.virtualItems.length; const end = start + virtualizer.virtualItems.length;
if (start !== dataSource.windowStart && !followOutput.current) { if (start !== dataSource.windowStart && !followOutput.current) {
onRangeChange?.(start, end, dataSource.output.length); onRangeChange?.(
start,
end,
dataSource.output.length,
parentRef.current?.scrollTop ?? 0,
);
} }
dataSource.setWindow(start, end); dataSource.setWindow(start, end);
}); });
@@ -208,7 +218,7 @@ export const DataSourceRenderer: <T extends object, C>(
} else { } else {
followOutput.current = true; followOutput.current = true;
} }
}, [autoScroll]); }, [autoScroll, parentRef]);
useLayoutEffect(function scrollToEnd() { useLayoutEffect(function scrollToEnd() {
if (followOutput.current) { if (followOutput.current) {

View File

@@ -17,6 +17,8 @@ import React, {
MutableRefObject, MutableRefObject,
CSSProperties, CSSProperties,
useEffect, useEffect,
useContext,
useReducer,
} from 'react'; } from 'react';
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow'; import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
import {DataSource} from '../../state/datasource/DataSource'; import {DataSource} from '../../state/datasource/DataSource';
@@ -24,7 +26,17 @@ import {Layout} from '../Layout';
import {TableHead} from './TableHead'; import {TableHead} from './TableHead';
import {Percentage} from '../../utils/widthUtils'; import {Percentage} from '../../utils/widthUtils';
import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer'; import {DataSourceRenderer, DataSourceVirtualizer} from './DataSourceRenderer';
import {useDataTableManager, DataTableManager} from './useDataTableManager'; import {
computeDataTableFilter,
createDataTableManager,
createInitialState,
DataTableManager,
dataTableManagerReducer,
DataTableReducer,
getSelectedItem,
getSelectedItems,
savePreferences,
} from './DataTableManager';
import {TableSearch} from './TableSearch'; import {TableSearch} from './TableSearch';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {theme} from '../theme'; import {theme} from '../theme';
@@ -32,6 +44,7 @@ import {tableContextMenuFactory} from './TableContextMenu';
import {Typography} from 'antd'; import {Typography} from 'antd';
import {CoffeeOutlined, SearchOutlined} from '@ant-design/icons'; import {CoffeeOutlined, SearchOutlined} from '@ant-design/icons';
import {useAssertStableRef} from '../../utils/useAssertStableRef'; import {useAssertStableRef} from '../../utils/useAssertStableRef';
import {TrackingScopeContext} from 'flipper-plugin/src/ui/Tracked';
interface DataTableProps<T = any> { interface DataTableProps<T = any> {
columns: DataTableColumn<T>[]; columns: DataTableColumn<T>[];
@@ -79,30 +92,44 @@ export interface RenderContext<T = any> {
export function DataTable<T extends object>( export function DataTable<T extends object>(
props: DataTableProps<T>, props: DataTableProps<T>,
): React.ReactElement { ): React.ReactElement {
const {dataSource, onRowStyle} = props; const {dataSource, onRowStyle, onSelect} = props;
useAssertStableRef(dataSource, 'dataSource'); useAssertStableRef(dataSource, 'dataSource');
useAssertStableRef(onRowStyle, 'onRowStyle'); useAssertStableRef(onRowStyle, 'onRowStyle');
useAssertStableRef(props.onSelect, 'onRowSelect'); useAssertStableRef(props.onSelect, 'onRowSelect');
useAssertStableRef(props.columns, 'columns'); useAssertStableRef(props.columns, 'columns');
useAssertStableRef(props._testHeight, '_testHeight'); useAssertStableRef(props._testHeight, '_testHeight');
// lint disabled for conditional inclusion of a hook (_testHeight is asserted to be stable)
// eslint-disable-next-line
const scope = props._testHeight ? "" : useContext(TrackingScopeContext); // TODO + plugin id
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>(); const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
const tableManager = useDataTableManager( const [state, dispatch] = useReducer(
dataSource, dataTableManagerReducer as DataTableReducer<T>,
props.columns, undefined,
props.onSelect, () =>
createInitialState({
dataSource,
defaultColumns: props.columns,
onSelect,
scope,
virtualizerRef,
}),
);
const stateRef = useRef(state);
stateRef.current = state;
const lastOffset = useRef(0);
const [tableManager] = useState(() =>
createDataTableManager(dataSource, dispatch, stateRef),
);
const {columns, selection, searchValue, sorting} = state;
const visibleColumns = useMemo(
() => columns.filter((column) => column.visible),
[columns],
); );
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<
DataTableManager<T>
>).current = tableManager;
}
const {
visibleColumns,
selectItem,
selection,
addRangeToSelection,
} = tableManager;
const renderingConfig = useMemo<RenderContext<T>>(() => { const renderingConfig = useMemo<RenderContext<T>>(() => {
let dragging = false; let dragging = false;
@@ -112,17 +139,17 @@ export function DataTable<T extends object>(
onMouseEnter(_e, _item, index) { onMouseEnter(_e, _item, index) {
if (dragging) { if (dragging) {
// by computing range we make sure no intermediate items are missed when scrolling fast // by computing range we make sure no intermediate items are missed when scrolling fast
addRangeToSelection(startIndex, index); tableManager.addRangeToSelection(startIndex, index);
} }
}, },
onMouseDown(e, _item, index) { onMouseDown(e, _item, index) {
if (!dragging) { if (!dragging) {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
addRangeToSelection(index, index, true); tableManager.addRangeToSelection(index, index, true);
} else if (e.shiftKey) { } else if (e.shiftKey) {
selectItem(index, true); tableManager.selectItem(index, true);
} else { } else {
selectItem(index); tableManager.selectItem(index);
} }
dragging = true; dragging = true;
@@ -137,12 +164,7 @@ export function DataTable<T extends object>(
} }
}, },
}; };
}, [visibleColumns, selectItem, addRangeToSelection]); }, [visibleColumns, tableManager]);
const usesWrapping = useMemo(
() => tableManager.columns.some((col) => col.wrap),
[tableManager.columns],
);
const itemRenderer = useCallback( const itemRenderer = useCallback(
function itemRenderer( function itemRenderer(
@@ -177,29 +199,35 @@ export function DataTable<T extends object>(
const windowSize = virtualizerRef.current!.virtualItems.length; const windowSize = virtualizerRef.current!.virtualItems.length;
switch (e.key) { switch (e.key) {
case 'ArrowUp': case 'ArrowUp':
selectItem((idx) => (idx > 0 ? idx - 1 : 0), shiftPressed); tableManager.selectItem(
(idx) => (idx > 0 ? idx - 1 : 0),
shiftPressed,
);
break; break;
case 'ArrowDown': case 'ArrowDown':
selectItem( tableManager.selectItem(
(idx) => (idx < outputSize - 1 ? idx + 1 : idx), (idx) => (idx < outputSize - 1 ? idx + 1 : idx),
shiftPressed, shiftPressed,
); );
break; break;
case 'Home': case 'Home':
selectItem(0, shiftPressed); tableManager.selectItem(0, shiftPressed);
break; break;
case 'End': case 'End':
selectItem(outputSize - 1, shiftPressed); tableManager.selectItem(outputSize - 1, shiftPressed);
break; break;
case ' ': // yes, that is a space case ' ': // yes, that is a space
case 'PageDown': case 'PageDown':
selectItem( tableManager.selectItem(
(idx) => Math.min(outputSize - 1, idx + windowSize - 1), (idx) => Math.min(outputSize - 1, idx + windowSize - 1),
shiftPressed, shiftPressed,
); );
break; break;
case 'PageUp': case 'PageUp':
selectItem((idx) => Math.max(0, idx - windowSize + 1), shiftPressed); tableManager.selectItem(
(idx) => Math.max(0, idx - windowSize + 1),
shiftPressed,
);
break; break;
default: default:
handled = false; handled = false;
@@ -209,17 +237,63 @@ export function DataTable<T extends object>(
e.preventDefault(); e.preventDefault();
} }
}, },
[selectItem, dataSource], [dataSource, tableManager],
); );
useEffect(
function updateFilter() {
dataSource.setFilter(
computeDataTableFilter(state.searchValue, state.columns),
);
},
// Important dep optimization: we don't want to recalc filters if just the width or visibility changes!
// We pass entire state.columns to computeDataTableFilter, but only changes in the filter are a valid cause to compute a new filter function
// eslint-disable-next-line
[state.searchValue, ...state.columns.map((c) => c.filters)],
);
useEffect(
function updateSorting() {
if (state.sorting === undefined) {
dataSource.setSortBy(undefined);
dataSource.setReversed(false);
} else {
dataSource.setSortBy(state.sorting.key);
dataSource.setReversed(state.sorting.direction === 'desc');
}
},
[dataSource, state.sorting],
);
useEffect(
function triggerSelection() {
onSelect?.(
getSelectedItem(dataSource, state.selection),
getSelectedItems(dataSource, state.selection),
);
},
[onSelect, dataSource, state.selection],
);
// The initialScrollPosition is used to both capture the initial px we want to scroll to,
// and whether we performed that scrolling already (if so, it will be 0)
// const initialScrollPosition = useRef(scrollOffset.current);
useLayoutEffect( useLayoutEffect(
function scrollSelectionIntoView() { function scrollSelectionIntoView() {
if (selection && selection.current >= 0) { if (state.initialOffset) {
virtualizerRef.current?.scrollToOffset(state.initialOffset);
dispatch({
type: 'appliedInitialScroll',
});
} else if (selection && selection.current >= 0) {
virtualizerRef.current?.scrollToIndex(selection!.current, { virtualizerRef.current?.scrollToIndex(selection!.current, {
align: 'auto', align: 'auto',
}); });
} }
}, },
// initialOffset is relevant for the first run,
// but should not trigger the efffect in general
// eslint-disable-next-line
[selection], [selection],
); );
@@ -228,9 +302,10 @@ export function DataTable<T extends object>(
const hideRange = useRef<NodeJS.Timeout>(); const hideRange = useRef<NodeJS.Timeout>();
const onRangeChange = useCallback( const onRangeChange = useCallback(
(start: number, end: number, total: number) => { (start: number, end: number, total: number, offset) => {
// TODO: figure out if we don't trigger this callback to often hurting perf // TODO: figure out if we don't trigger this callback to often hurting perf
setRange(`${start} - ${end} / ${total}`); setRange(`${start} - ${end} / ${total}`);
lastOffset.current = offset;
clearTimeout(hideRange.current!); clearTimeout(hideRange.current!);
hideRange.current = setTimeout(() => { hideRange.current = setTimeout(() => {
setRange(''); setRange('');
@@ -240,53 +315,62 @@ export function DataTable<T extends object>(
); );
/** Context menu */ /** Context menu */
// TODO: support customizing context menu
const contexMenu = props._testHeight const contexMenu = props._testHeight
? undefined // don't render context menu in tests ? undefined
: tableContextMenuFactory(tableManager); : // eslint-disable-next-line
useCallback(
() =>
tableContextMenuFactory(
dataSource,
dispatch,
selection,
state.columns,
visibleColumns,
),
[dataSource, dispatch, selection, state.columns, visibleColumns],
);
const emptyRenderer = useCallback((dataSource: DataSource<T>) => { useEffect(function initialSetup() {
return <EmptyTable dataSource={dataSource} />; if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<any>).current = tableManager;
}
return function cleanup() {
// write current prefs to local storage
savePreferences(stateRef.current, lastOffset.current);
// if the component unmounts, we reset the SFRW pipeline to
// avoid wasting resources in the background
dataSource.reset();
// clean ref
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<any>).current = undefined;
}
};
// one-time setup and cleanup effect, everything in here is asserted to be stable:
// dataSource, tableManager, tableManagerRef
// eslint-disable-next-line
}, []); }, []);
useEffect(
function cleanup() {
return () => {
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<undefined>).current = undefined;
}
};
},
[props.tableManagerRef],
);
return ( return (
<Layout.Container grow> <Layout.Container grow>
<Layout.Top> <Layout.Top>
<Layout.Container> <Layout.Container>
<TableSearch <TableSearch
onSearch={tableManager.setSearchValue} searchValue={searchValue}
extraActions={props.extraActions} dispatch={dispatch as any}
contextMenu={contexMenu} contextMenu={contexMenu}
extraActions={props.extraActions}
/> />
<TableHead <TableHead
visibleColumns={tableManager.visibleColumns} visibleColumns={visibleColumns}
onColumnResize={tableManager.resizeColumn} dispatch={dispatch as any}
onReset={tableManager.reset} sorting={sorting}
sorting={tableManager.sorting}
onColumnSort={tableManager.sortColumn}
onAddColumnFilter={tableManager.addColumnFilter}
onRemoveColumnFilter={tableManager.removeColumnFilter}
onToggleColumnFilter={tableManager.toggleColumnFilter}
onSetColumnFilterFromSelection={
tableManager.setColumnFilterFromSelection
}
/> />
</Layout.Container> </Layout.Container>
<DataSourceRenderer<T, RenderContext<T>> <DataSourceRenderer<T, RenderContext<T>>
dataSource={dataSource} dataSource={dataSource}
autoScroll={props.autoScroll} autoScroll={props.autoScroll}
useFixedRowHeight={!usesWrapping} useFixedRowHeight={!state.usesWrapping}
defaultRowHeight={DEFAULT_ROW_HEIGHT} defaultRowHeight={DEFAULT_ROW_HEIGHT}
context={renderingConfig} context={renderingConfig}
itemRenderer={itemRenderer} itemRenderer={itemRenderer}
@@ -302,6 +386,10 @@ export function DataTable<T extends object>(
); );
} }
function emptyRenderer(dataSource: DataSource<any>) {
return <EmptyTable dataSource={dataSource} />;
}
function EmptyTable({dataSource}: {dataSource: DataSource<any>}) { function EmptyTable({dataSource}: {dataSource: DataSource<any>}) {
return ( return (
<Layout.Container <Layout.Container

View File

@@ -0,0 +1,520 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import type {DataTableColumn} from './DataTable';
import {Percentage} from '../../utils/widthUtils';
import {MutableRefObject, Reducer} from 'react';
import {DataSource} from '../../state/datasource/DataSource';
import {DataSourceVirtualizer} from './DataSourceRenderer';
import produce, {immerable, original} from 'immer';
export type OnColumnResize = (id: string, size: number | Percentage) => void;
export type Sorting<T = any> = {
key: keyof T;
direction: Exclude<SortDirection, undefined>;
};
export type SortDirection = 'asc' | 'desc' | undefined;
export type Selection = {items: ReadonlySet<number>; current: number};
const emptySelection: Selection = {
items: new Set(),
current: -1,
};
type PersistedState = {
/** Active search value */
search: string;
/** current selection, describes the index index in the datasources's current output (not window!) */
selection: {current: number; items: number[]};
/** The currently applicable sorting, if any */
sorting: Sorting | undefined;
/** The default columns, but normalized */
columns: Pick<DataTableColumn, 'key' | 'width' | 'filters' | 'visible'>[];
scrollOffset: number;
};
type Action<Name extends string, Args = {}> = {type: Name} & Args;
type DataManagerActions<T> =
/** Reset the current table preferences, including column widths an visibility, back to the default */
| Action<'reset'>
/** Resizes the column with the given key to the given width */
| Action<'resizeColumn', {column: keyof T; width: number | Percentage}>
/** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */
| Action<'sortColumn', {column: keyof T; direction: SortDirection}>
/** Show / hide the given column */
| Action<'toggleColumnVisibility', {column: keyof T}>
| Action<'setSearchValue', {value: string}>
| Action<
'selectItem',
{
nextIndex: number | ((currentIndex: number) => number);
addToSelection?: boolean;
}
>
| Action<
'addRangeToSelection',
{
start: number;
end: number;
allowUnselect?: boolean;
}
>
| Action<'clearSelection', {}>
/** Changing column filters */
| Action<
'addColumnFilter',
{column: keyof T; value: string; disableOthers?: boolean}
>
| Action<'removeColumnFilter', {column: keyof T; index: number}>
| Action<'toggleColumnFilter', {column: keyof T; index: number}>
| Action<'setColumnFilterFromSelection', {column: keyof T}>
| Action<'appliedInitialScroll'>;
type DataManagerConfig<T> = {
dataSource: DataSource<T>;
defaultColumns: DataTableColumn<T>[];
scope: string;
onSelect: undefined | ((item: T | undefined, items: T[]) => void);
virtualizerRef: MutableRefObject<DataSourceVirtualizer | undefined>;
};
type DataManagerState<T> = {
config: DataManagerConfig<T>;
usesWrapping: boolean;
storageKey: string;
initialOffset: number;
columns: DataTableColumn[];
sorting: Sorting<T> | undefined;
selection: Selection;
searchValue: string;
};
export type DataTableReducer<T> = Reducer<
DataManagerState<T>,
DataManagerActions<T>
>;
export type DataTableDispatch<T = any> = React.Dispatch<DataManagerActions<T>>;
// TODO: make argu inference correct
export const dataTableManagerReducer = produce(function <T>(
draft: DataManagerState<T>,
action: DataManagerActions<T>,
) {
const config = original(draft.config)!;
switch (action.type) {
case 'reset': {
draft.columns = computeInitialColumns(config.defaultColumns);
draft.sorting = undefined;
draft.searchValue = '';
draft.selection = emptySelection;
break;
}
case 'resizeColumn': {
const {column, width} = action;
const col = draft.columns.find((c) => c.key === column)!;
col.width = width;
break;
}
case 'sortColumn': {
const {column, direction} = action;
if (direction === undefined) {
draft.sorting = undefined;
} else {
draft.sorting = {key: column, direction};
}
break;
}
case 'toggleColumnVisibility': {
const {column} = action;
const col = draft.columns.find((c) => c.key === column)!;
col.visible = !col.visible;
break;
}
case 'setSearchValue': {
draft.searchValue = action.value;
break;
}
case 'selectItem': {
const {nextIndex, addToSelection} = action;
draft.selection = computeSetSelection(
draft.selection,
nextIndex,
addToSelection,
);
break;
}
case 'addRangeToSelection': {
const {start, end, allowUnselect} = action;
draft.selection = computeAddRangeToSelection(
draft.selection,
start,
end,
allowUnselect,
);
break;
}
case 'clearSelection': {
draft.selection = emptySelection;
break;
}
case 'addColumnFilter': {
addColumnFilter(
draft.columns,
action.column,
action.value,
action.disableOthers,
);
break;
}
case 'removeColumnFilter': {
draft.columns
.find((c) => c.key === action.column)!
.filters?.splice(action.index, 1);
break;
}
case 'toggleColumnFilter': {
const f = draft.columns.find((c) => c.key === action.column)!.filters![
action.index
];
f.enabled = !f.enabled;
break;
}
case 'setColumnFilterFromSelection': {
const items = getSelectedItems(config.dataSource, draft.selection);
items.forEach((item, index) => {
addColumnFilter(
draft.columns,
action.column,
(item as any)[action.column],
index === 0, // remove existing filters before adding the first
);
});
break;
}
case 'appliedInitialScroll': {
draft.initialOffset = 0;
break;
}
default: {
throw new Error('Unknown action ' + (action as any).type);
}
}
}) as any; // TODO: remove
/**
* Public only imperative convienience API for DataTable
*/
export type DataTableManager<T> = {
reset(): void;
selectItem(
index: number | ((currentSelection: number) => number),
addToSelection?: boolean,
): void;
addRangeToSelection(
start: number,
end: number,
allowUnselect?: boolean,
): void;
clearSelection(): void;
getSelectedItem(): T | undefined;
getSelectedItems(): readonly T[];
toggleColumnVisibility(column: keyof T): void;
sortColumn(column: keyof T, direction?: SortDirection): void;
setSearchValue(value: string): void;
};
export function createDataTableManager<T>(
dataSource: DataSource<T>,
dispatch: DataTableDispatch<T>,
stateRef: MutableRefObject<DataManagerState<T>>,
): DataTableManager<T> {
return {
reset() {
dispatch({type: 'reset'});
},
selectItem(index: number, addToSelection = false) {
dispatch({type: 'selectItem', nextIndex: index, addToSelection});
},
addRangeToSelection(start, end, allowUnselect = false) {
dispatch({type: 'addRangeToSelection', start, end, allowUnselect});
},
clearSelection() {
dispatch({type: 'clearSelection'});
},
getSelectedItem() {
return getSelectedItem(dataSource, stateRef.current.selection);
},
getSelectedItems() {
return getSelectedItems(dataSource, stateRef.current.selection);
},
toggleColumnVisibility(column) {
dispatch({type: 'toggleColumnVisibility', column});
},
sortColumn(column, direction) {
dispatch({type: 'sortColumn', column, direction});
},
setSearchValue(value) {
dispatch({type: 'setSearchValue', value});
},
};
}
export function createInitialState<T>(
config: DataManagerConfig<T>,
): DataManagerState<T> {
const storageKey = `${config.scope}:DataTable:${config.defaultColumns
.map((c) => c.key)
.join(',')}`;
const prefs = loadStateFromStorage(storageKey);
let initialColumns = computeInitialColumns(config.defaultColumns);
if (prefs) {
// merge prefs with the default column config
initialColumns = produce(initialColumns, (draft) => {
prefs.columns.forEach((pref) => {
const existing = draft.find((c) => c.key === pref.key);
if (existing) {
Object.assign(existing, pref);
}
});
});
}
const res: DataManagerState<T> = {
config,
storageKey,
initialOffset: prefs?.scrollOffset ?? 0,
usesWrapping: config.defaultColumns.some((col) => col.wrap),
columns: initialColumns,
sorting: prefs?.sorting,
selection: prefs?.selection
? {
current: prefs!.selection.current,
items: new Set(prefs!.selection.items),
}
: emptySelection,
searchValue: prefs?.search ?? '',
};
// @ts-ignore
res.config[immerable] = false; // optimization: never proxy anything in config
Object.freeze(res.config);
return res;
}
function addColumnFilter<T>(
columns: DataTableColumn<T>[],
columnId: keyof T,
value: string,
disableOthers: boolean = false,
): void {
const column = columns.find((c) => c.key === columnId)!;
const filterValue = value.toLowerCase();
const existing = column.filters!.find((c) => c.value === filterValue);
if (existing) {
existing.enabled = true;
} else {
column.filters!.push({
label: value,
value: filterValue,
enabled: true,
});
}
if (disableOthers) {
column.filters!.forEach((c) => {
if (c.value !== filterValue) {
c.enabled = false;
}
});
}
}
export function getSelectedItem<T>(
dataSource: DataSource<T>,
selection: Selection,
): T | undefined {
return selection.current < 0
? undefined
: dataSource.getItem(selection.current);
}
export function getSelectedItems<T>(
dataSource: DataSource<T>,
selection: Selection,
): T[] {
return [...selection.items]
.sort()
.map((i) => dataSource.getItem(i))
.filter(Boolean) as any[];
}
export function savePreferences(
state: DataManagerState<any>,
scrollOffset: number,
) {
if (!state.config.scope) {
return;
}
const prefs: PersistedState = {
search: state.searchValue,
selection: {
current: state.selection.current,
items: Array.from(state.selection.items),
},
sorting: state.sorting,
columns: state.columns.map((c) => ({
key: c.key,
width: c.width,
filters: c.filters,
visible: c.visible,
})),
scrollOffset,
};
localStorage.setItem(state.storageKey, JSON.stringify(prefs));
}
function loadStateFromStorage(storageKey: string): PersistedState | undefined {
if (!storageKey) {
return undefined;
}
const state = localStorage.getItem(storageKey);
if (!state) {
return undefined;
}
try {
return JSON.parse(state) as PersistedState;
} catch (e) {
// forget about this state
return undefined;
}
}
function computeInitialColumns(
columns: DataTableColumn<any>[],
): DataTableColumn<any>[] {
return columns.map((c) => ({
...c,
filters:
c.filters?.map((f) => ({
...f,
predefined: true,
})) ?? [],
visible: c.visible !== false,
}));
}
export function computeDataTableFilter(
searchValue: string,
columns: DataTableColumn[],
) {
const searchString = searchValue.toLowerCase();
// the columns with an active filter are those that have filters defined,
// with at least one enabled
const filteringColumns = columns.filter((c) =>
c.filters?.some((f) => f.enabled),
);
if (searchValue === '' && !filteringColumns.length) {
// unset
return undefined;
}
return function dataTableFilter(item: any) {
for (const column of filteringColumns) {
if (
!column.filters!.some(
(f) =>
f.enabled &&
String(item[column.key]).toLowerCase().includes(f.value),
)
) {
return false; // there are filters, but none matches
}
}
return Object.values(item).some((v) =>
String(v).toLowerCase().includes(searchString),
);
};
}
export function computeSetSelection(
base: Selection,
nextIndex: number | ((currentIndex: number) => number),
addToSelection?: boolean,
): Selection {
const newIndex =
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
// special case: toggle existing selection off
if (!addToSelection && base.items.size === 1 && base.current === newIndex) {
return emptySelection;
}
if (newIndex < 0) {
return emptySelection;
}
if (base.current < 0 || !addToSelection) {
return {
current: newIndex,
items: new Set([newIndex]),
};
} else {
const lowest = Math.min(base.current, newIndex);
const highest = Math.max(base.current, newIndex);
return {
current: newIndex,
items: addIndicesToMultiSelection(base.items, lowest, highest),
};
}
}
export function computeAddRangeToSelection(
base: Selection,
start: number,
end: number,
allowUnselect?: boolean,
): Selection {
// special case: unselectiong a single item with the selection
if (start === end && allowUnselect) {
if (base?.items.has(start)) {
const copy = new Set(base.items);
copy.delete(start);
const current = [...copy];
if (current.length === 0) {
return emptySelection;
}
return {
items: copy,
current: current[current.length - 1], // back to the last selected one
};
}
// intentional fall-through
}
// N.B. start and end can be reverted if selecting backwards
const lowest = Math.min(start, end);
const highest = Math.max(start, end);
const current = end;
return {
items: addIndicesToMultiSelection(base.items, lowest, highest),
current,
};
}
function addIndicesToMultiSelection(
base: ReadonlySet<number>,
lowest: number,
highest: number,
): ReadonlySet<number> {
const copy = new Set(base);
for (let i = lowest; i <= highest; i++) {
copy.add(i);
}
return copy;
}

View File

@@ -9,15 +9,26 @@
import {CopyOutlined, FilterOutlined} from '@ant-design/icons'; import {CopyOutlined, FilterOutlined} from '@ant-design/icons';
import {Checkbox, Menu} from 'antd'; import {Checkbox, Menu} from 'antd';
import {DataTableManager} from './useDataTableManager'; import {
DataTableDispatch,
getSelectedItems,
Selection,
} from './DataTableManager';
import React from 'react'; import React from 'react';
import {normalizeCellValue} from './TableRow'; import {normalizeCellValue} from './TableRow';
import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib';
import {DataTableColumn} from './DataTable'; import {DataTableColumn} from './DataTable';
import {DataSource} from '../../state/datasource/DataSource';
const {Item, SubMenu} = Menu; const {Item, SubMenu} = Menu;
export function tableContextMenuFactory(tableManager: DataTableManager<any>) { export function tableContextMenuFactory<T>(
datasource: DataSource<T>,
dispatch: DataTableDispatch<T>,
selection: Selection,
columns: DataTableColumn<T>[],
visibleColumns: DataTableColumn<T>[],
) {
const lib = tryGetFlipperLibImplementation(); const lib = tryGetFlipperLibImplementation();
if (!lib) { if (!lib) {
return ( return (
@@ -26,18 +37,22 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
</Menu> </Menu>
); );
} }
const hasSelection = tableManager.selection?.items.size > 0 ?? false; const hasSelection = selection.items.size > 0 ?? false;
return ( return (
<Menu> <Menu>
<SubMenu <SubMenu
title="Filter on same" title="Filter on same"
icon={<FilterOutlined />} icon={<FilterOutlined />}
disabled={!hasSelection}> disabled={!hasSelection}>
{tableManager.visibleColumns.map((column) => ( {visibleColumns.map((column) => (
<Item <Item
key={column.key} key={column.key}
onClick={() => { onClick={() => {
tableManager.setColumnFilterFromSelection(column.key); dispatch({
type: 'setColumnFilterFromSelection',
column: column.key,
});
}}> }}>
{friendlyColumnTitle(column)} {friendlyColumnTitle(column)}
</Item> </Item>
@@ -47,11 +62,11 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
title="Copy cell(s)" title="Copy cell(s)"
icon={<CopyOutlined />} icon={<CopyOutlined />}
disabled={!hasSelection}> disabled={!hasSelection}>
{tableManager.visibleColumns.map((column) => ( {visibleColumns.map((column) => (
<Item <Item
key={column.key} key={column.key}
onClick={() => { onClick={() => {
const items = tableManager.getSelectedItems(); const items = getSelectedItems(datasource, selection);
if (items.length) { if (items.length) {
lib.writeTextToClipboard( lib.writeTextToClipboard(
items items
@@ -67,7 +82,7 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
<Item <Item
disabled={!hasSelection} disabled={!hasSelection}
onClick={() => { onClick={() => {
const items = tableManager.getSelectedItems(); const items = getSelectedItems(datasource, selection);
if (items.length) { if (items.length) {
lib.writeTextToClipboard( lib.writeTextToClipboard(
JSON.stringify(items.length > 1 ? items : items[0], null, 2), JSON.stringify(items.length > 1 ? items : items[0], null, 2),
@@ -80,7 +95,7 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
<Item <Item
disabled={!hasSelection} disabled={!hasSelection}
onClick={() => { onClick={() => {
const items = tableManager.getSelectedItems(); const items = getSelectedItems(datasource, selection);
if (items.length) { if (items.length) {
lib.createPaste( lib.createPaste(
JSON.stringify(items.length > 1 ? items : items[0], null, 2), JSON.stringify(items.length > 1 ? items : items[0], null, 2),
@@ -92,21 +107,25 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
)} )}
<Menu.Divider /> <Menu.Divider />
<SubMenu title="Visible columns"> <SubMenu title="Visible columns">
{tableManager.columns.map((column) => ( {columns.map((column) => (
<Menu.Item key={column.key}> <Menu.Item key={column.key}>
<Checkbox <Checkbox
checked={column.visible} checked={column.visible}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
tableManager.toggleColumnVisibility(column.key); dispatch({type: 'toggleColumnVisibility', column: column.key});
}}> }}>
{friendlyColumnTitle(column)} {friendlyColumnTitle(column)}
</Checkbox> </Checkbox>
</Menu.Item> </Menu.Item>
))} ))}
</SubMenu> </SubMenu>
<Menu.Item key="reset" onClick={tableManager.reset}> <Menu.Item
key="reset"
onClick={() => {
dispatch({type: 'reset'});
}}>
Reset view Reset view
</Menu.Item> </Menu.Item>
</Menu> </Menu>

View File

@@ -23,8 +23,8 @@ import type {DataTableColumn} from './DataTable';
import {Typography} from 'antd'; import {Typography} from 'antd';
import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons'; import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
import {Sorting, OnColumnResize, SortDirection} from './useDataTableManager'; import {Sorting, SortDirection, DataTableDispatch} from './DataTableManager';
import {ColumnFilterHandlers, FilterButton, FilterIcon} from './ColumnFilter'; import {FilterButton, FilterIcon} from './ColumnFilter';
const {Text} = Typography; const {Text} = Typography;
@@ -120,18 +120,14 @@ const RIGHT_RESIZABLE = {right: true};
function TableHeadColumn({ function TableHeadColumn({
column, column,
isResizable, isResizable,
onColumnResize,
onSort,
sorted, sorted,
...filterHandlers dispatch,
}: { }: {
column: DataTableColumn<any>; column: DataTableColumn<any>;
sorted: SortDirection; sorted: SortDirection;
isResizable: boolean; isResizable: boolean;
onSort: (id: string, direction: SortDirection) => void; dispatch: DataTableDispatch;
sortOrder: undefined | Sorting; }) {
onColumnResize: OnColumnResize;
} & ColumnFilterHandlers) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const onResize = (newWidth: number) => { const onResize = (newWidth: number) => {
@@ -158,7 +154,11 @@ function TableHeadColumn({
} }
} }
onColumnResize(column.key, normalizedWidth); dispatch({
type: 'resizeColumn',
column: column.key,
width: normalizedWidth,
});
}; };
let children = ( let children = (
@@ -172,7 +172,11 @@ function TableHeadColumn({
: sorted === 'asc' : sorted === 'asc'
? 'desc' ? 'desc'
: undefined; : undefined;
onSort(column.key, newDirection); dispatch({
type: 'sortColumn',
column: column.key,
direction: newDirection,
});
}} }}
role="button" role="button"
tabIndex={0}> tabIndex={0}>
@@ -180,11 +184,13 @@ function TableHeadColumn({
{column.title ?? <>&nbsp;</>} {column.title ?? <>&nbsp;</>}
<SortIcons <SortIcons
direction={sorted} direction={sorted}
onSort={(dir) => onSort(column.key, dir)} onSort={(dir) =>
dispatch({type: 'sortColumn', column: column.key, direction: dir})
}
/> />
</Text> </Text>
</div> </div>
<FilterIcon column={column} {...filterHandlers} /> <FilterIcon column={column} dispatch={dispatch} />
</Layout.Right> </Layout.Right>
); );
@@ -209,14 +215,13 @@ function TableHeadColumn({
export const TableHead = memo(function TableHead({ export const TableHead = memo(function TableHead({
visibleColumns, visibleColumns,
...props dispatch,
sorting,
}: { }: {
dispatch: DataTableDispatch<any>;
visibleColumns: DataTableColumn<any>[]; visibleColumns: DataTableColumn<any>[];
onColumnResize: OnColumnResize;
onReset: () => void;
sorting: Sorting | undefined; sorting: Sorting | undefined;
onColumnSort: (key: string, direction: SortDirection) => void; }) {
} & ColumnFilterHandlers) {
return ( return (
<TableHeadContainer> <TableHeadContainer>
{visibleColumns.map((column, i) => ( {visibleColumns.map((column, i) => (
@@ -224,18 +229,8 @@ export const TableHead = memo(function TableHead({
key={column.key} key={column.key}
column={column} column={column}
isResizable={i < visibleColumns.length - 1} isResizable={i < visibleColumns.length - 1}
sortOrder={props.sorting} dispatch={dispatch}
onSort={props.onColumnSort} sorted={sorting?.key === column.key ? sorting!.direction : undefined}
onColumnResize={props.onColumnResize}
onAddColumnFilter={props.onAddColumnFilter}
onRemoveColumnFilter={props.onRemoveColumnFilter}
onToggleColumnFilter={props.onToggleColumnFilter}
onSetColumnFilterFromSelection={props.onSetColumnFilterFromSelection}
sorted={
props.sorting?.key === column.key
? props.sorting!.direction
: undefined
}
/> />
))} ))}
</TableHeadContainer> </TableHeadContainer>

View File

@@ -9,39 +9,39 @@
import {MenuOutlined} from '@ant-design/icons'; import {MenuOutlined} from '@ant-design/icons';
import {Button, Dropdown, Input} from 'antd'; import {Button, Dropdown, Input} from 'antd';
import React, {memo, useState} from 'react'; import React, {memo, useCallback} from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
import {theme} from '../theme'; import {theme} from '../theme';
import {debounce} from 'lodash'; import type {DataTableDispatch} from './DataTableManager';
import {useAssertStableRef} from '../../utils/useAssertStableRef';
export const TableSearch = memo(function TableSearch({ export const TableSearch = memo(function TableSearch({
onSearch, searchValue,
dispatch,
extraActions, extraActions,
contextMenu, contextMenu,
}: { }: {
onSearch(value: string): void; searchValue: string;
dispatch: DataTableDispatch<any>;
extraActions?: React.ReactElement; extraActions?: React.ReactElement;
hasSelection?: boolean; contextMenu: undefined | (() => JSX.Element);
contextMenu?: React.ReactElement;
}) { }) {
useAssertStableRef(onSearch, 'onSearch'); const onSearch = useCallback(
const [search, setSearch] = useState(''); (value: string) => {
const [performSearch] = useState(() => dispatch({type: 'setSearchValue', value});
debounce(onSearch, 200, {leading: true}), },
[dispatch],
); );
return ( return (
<Searchbar gap> <Searchbar gap>
<Input.Search <Input.Search
allowClear allowClear
placeholder="Search..." placeholder="Search..."
onSearch={performSearch} onSearch={onSearch}
value={search} value={searchValue}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value); onSearch(e.target.value);
performSearch(e.target.value);
}} }}
/> />
{extraActions} {extraActions}

View File

@@ -11,7 +11,7 @@ import React, {createRef} from 'react';
import {DataTable, DataTableColumn} from '../DataTable'; import {DataTable, DataTableColumn} from '../DataTable';
import {render, act} from '@testing-library/react'; import {render, act} from '@testing-library/react';
import {createDataSource} from '../../../state/datasource/DataSource'; import {createDataSource} from '../../../state/datasource/DataSource';
import {computeDataTableFilter, DataTableManager} from '../useDataTableManager'; import {computeDataTableFilter, DataTableManager} from '../DataTableManager';
import {Button} from 'antd'; import {Button} from 'antd';
type Todo = { type Todo = {

View File

@@ -10,7 +10,7 @@
import { import {
computeAddRangeToSelection, computeAddRangeToSelection,
computeSetSelection, computeSetSelection,
} from '../useDataTableManager'; } from '../DataTableManager';
test('computeSetSelection', () => { test('computeSetSelection', () => {
const emptyBase = { const emptyBase = {

View File

@@ -1,441 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {DataTableColumn} from 'flipper-plugin/src/ui/datatable/DataTable';
import {Percentage} from '../../utils/widthUtils';
import produce from 'immer';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {DataSource} from '../../state/datasource/DataSource';
import {useMemoize} from '../../utils/useMemoize';
export type OnColumnResize = (id: string, size: number | Percentage) => void;
export type Sorting = {
key: string;
direction: Exclude<SortDirection, undefined>;
};
export type SortDirection = 'asc' | 'desc' | undefined;
export interface DataTableManager<T> {
/** The default columns, but normalized */
columns: DataTableColumn<T>[];
/** The effective columns to be rendererd */
visibleColumns: DataTableColumn<T>[];
/** The currently applicable sorting, if any */
sorting: Sorting | undefined;
/** Reset the current table preferences, including column widths an visibility, back to the default */
reset(): void;
/** Resizes the column with the given key to the given width */
resizeColumn(column: string, width: number | Percentage): void;
/** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */
sortColumn(column: string, direction: SortDirection): void;
/** Show / hide the given column */
toggleColumnVisibility(column: string): void;
/** Active search value */
setSearchValue(value: string): void;
/** current selection, describes the index index in the datasources's current output (not window) */
selection: Selection;
selectItem(
nextIndex: number | ((currentIndex: number) => number),
addToSelection?: boolean,
): void;
addRangeToSelection(
start: number,
end: number,
allowUnselect?: boolean,
): void;
clearSelection(): void;
getSelectedItem(): T | undefined;
getSelectedItems(): readonly T[];
/** Changing column filters */
addColumnFilter(column: string, value: string, disableOthers?: boolean): void;
removeColumnFilter(column: string, index: number): void;
toggleColumnFilter(column: string, index: number): void;
setColumnFilterFromSelection(column: string): void;
}
type Selection = {items: ReadonlySet<number>; current: number};
const emptySelection: Selection = {
items: new Set(),
current: -1,
};
/**
* A hook that coordinates filtering, sorting etc for a DataSource
*/
export function useDataTableManager<T>(
dataSource: DataSource<T>,
defaultColumns: DataTableColumn<T>[],
onSelect?: (item: T | undefined, items: T[]) => void,
): DataTableManager<T> {
const [columns, setEffectiveColumns] = useState(
computeInitialColumns(defaultColumns),
);
// TODO: move selection with shifts with index < selection?
// TODO: clear selection if out of range
const [selection, setSelection] = useState<Selection>(emptySelection);
const [sorting, setSorting] = useState<Sorting | undefined>(undefined);
const [searchValue, setSearchValue] = useState('');
const visibleColumns = useMemo(
() => columns.filter((column) => column.visible),
[columns],
);
/**
* Select an individual item, used by mouse clicks and keyboard navigation
* Set addToSelection if the current selection should be expanded to the given position,
* rather than replacing the current selection.
*
* The nextIndex can be used to compute the new selection by basing relatively to the current selection
*/
const selectItem = useCallback(
(
nextIndex: number | ((currentIndex: number) => number),
addToSelection?: boolean,
) => {
setSelection((base) =>
computeSetSelection(base, nextIndex, addToSelection),
);
},
[],
);
/**
* Adds a range of items to the current seleciton (if any)
*/
const addRangeToSelection = useCallback(
(start: number, end: number, allowUnselect?: boolean) => {
setSelection((base) =>
computeAddRangeToSelection(base, start, end, allowUnselect),
);
},
[],
);
const clearSelection = useCallback(() => {
setSelection(emptySelection);
}, []);
const getSelectedItem = useCallback(() => {
return selection.current < 0
? undefined
: dataSource.getItem(selection.current);
}, [dataSource, selection]);
const getSelectedItems = useCallback(() => {
return [...selection.items]
.sort()
.map((i) => dataSource.getItem(i))
.filter(Boolean) as any[];
}, [dataSource, selection]);
useEffect(
function fireSelection() {
if (onSelect) {
const item = getSelectedItem();
const items = getSelectedItems();
onSelect(item, items);
}
},
// selection is intentionally a dep
[onSelect, selection, selection, getSelectedItem, getSelectedItems],
);
/**
* Filtering
*/
const addColumnFilter = useCallback(
(columnId: string, value: string, disableOthers = false) => {
// TODO: fix typings
setEffectiveColumns(
produce((draft: DataTableColumn<any>[]) => {
const column = draft.find((c) => c.key === columnId)!;
const filterValue = value.toLowerCase();
const existing = column.filters!.find((c) => c.value === filterValue);
if (existing) {
existing.enabled = true;
} else {
column.filters!.push({
label: value,
value: filterValue,
enabled: true,
});
}
if (disableOthers) {
column.filters!.forEach((c) => {
if (c.value !== filterValue) {
c.enabled = false;
}
});
}
}),
);
},
[],
);
const removeColumnFilter = useCallback((columnId: string, index: number) => {
// TODO: fix typings
setEffectiveColumns(
produce((draft: DataTableColumn<any>[]) => {
draft.find((c) => c.key === columnId)!.filters?.splice(index, 1);
}),
);
}, []);
const toggleColumnFilter = useCallback((columnId: string, index: number) => {
// TODO: fix typings
setEffectiveColumns(
produce((draft: DataTableColumn<any>[]) => {
const f = draft.find((c) => c.key === columnId)!.filters![index];
f.enabled = !f.enabled;
}),
);
}, []);
const setColumnFilterFromSelection = useCallback(
(columnId: string) => {
const items = getSelectedItems();
if (items.length) {
items.forEach((item, index) => {
addColumnFilter(
columnId,
item[columnId],
index === 0, // remove existing filters before adding the first
);
});
}
},
[getSelectedItems, addColumnFilter],
);
// filter is computed by useMemo to support adding column filters etc here in the future
const currentFilter = useMemoize(
computeDataTableFilter,
[searchValue, columns], // possible optimization: we only need the column filters
);
const reset = useCallback(() => {
setEffectiveColumns(computeInitialColumns(defaultColumns));
setSorting(undefined);
setSearchValue('');
setSelection(emptySelection);
dataSource.reset();
}, [dataSource, defaultColumns]);
const resizeColumn = useCallback((id: string, width: number | Percentage) => {
setEffectiveColumns(
// TODO: fix typing of produce
produce((columns: DataTableColumn<any>[]) => {
const col = columns.find((c) => c.key === id)!;
col.width = width;
}),
);
}, []);
const sortColumn = useCallback(
(key: string, direction: SortDirection) => {
if (direction === undefined) {
// remove sorting
setSorting(undefined);
dataSource.setSortBy(undefined);
dataSource.setReversed(false);
} else {
// update sorting
// TODO: make sure that setting both doesn't rebuild output twice!
if (!sorting || sorting.key !== key) {
dataSource.setSortBy(key as any);
}
if (!sorting || sorting.direction !== direction) {
dataSource.setReversed(direction === 'desc');
}
setSorting({key, direction});
}
},
[dataSource, sorting],
);
const toggleColumnVisibility = useCallback((id: string) => {
setEffectiveColumns(
// TODO: fix typing of produce
produce((columns: DataTableColumn<any>[]) => {
const col = columns.find((c) => c.key === id)!;
col.visible = !col.visible;
}),
);
}, []);
useEffect(
function applyFilter() {
dataSource.setFilter(currentFilter);
},
[currentFilter, dataSource],
);
// if the component unmounts, we reset the SFRW pipeline to
// avoid wasting resources in the background
useEffect(() => () => dataSource.reset(), [dataSource]);
return {
/** The default columns, but normalized */
columns,
/** The effective columns to be rendererd */
visibleColumns,
/** The currently applicable sorting, if any */
sorting,
/** Reset the current table preferences, including column widths an visibility, back to the default */
reset,
/** Resizes the column with the given key to the given width */
resizeColumn,
/** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */
sortColumn,
/** Show / hide the given column */
toggleColumnVisibility,
/** Active search value */
setSearchValue,
/** current selection, describes the index index in the datasources's current output (not window) */
selection,
selectItem,
addRangeToSelection,
clearSelection,
getSelectedItem,
getSelectedItems,
/** Changing column filters */
addColumnFilter,
removeColumnFilter,
toggleColumnFilter,
setColumnFilterFromSelection,
};
}
function computeInitialColumns(
columns: DataTableColumn<any>[],
): DataTableColumn<any>[] {
return columns.map((c) => ({
...c,
filters:
c.filters?.map((f) => ({
...f,
predefined: true,
})) ?? [],
visible: c.visible !== false,
}));
}
export function computeDataTableFilter(
searchValue: string,
columns: DataTableColumn[],
) {
const searchString = searchValue.toLowerCase();
// the columns with an active filter are those that have filters defined,
// with at least one enabled
const filteringColumns = columns.filter((c) =>
c.filters?.some((f) => f.enabled),
);
if (searchValue === '' && !filteringColumns.length) {
// unset
return undefined;
}
return function dataTableFilter(item: any) {
for (const column of filteringColumns) {
if (
!column.filters!.some(
(f) =>
f.enabled &&
String(item[column.key]).toLowerCase().includes(f.value),
)
) {
return false; // there are filters, but none matches
}
}
return Object.values(item).some((v) =>
String(v).toLowerCase().includes(searchString),
);
};
}
export function computeSetSelection(
base: Selection,
nextIndex: number | ((currentIndex: number) => number),
addToSelection?: boolean,
): Selection {
const newIndex =
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
// special case: toggle existing selection off
if (!addToSelection && base.items.size === 1 && base.current === newIndex) {
return emptySelection;
}
if (newIndex < 0) {
return emptySelection;
}
if (base.current < 0 || !addToSelection) {
return {
current: newIndex,
items: new Set([newIndex]),
};
} else {
const lowest = Math.min(base.current, newIndex);
const highest = Math.max(base.current, newIndex);
return {
current: newIndex,
items: addIndicesToMultiSelection(base.items, lowest, highest),
};
}
}
export function computeAddRangeToSelection(
base: Selection,
start: number,
end: number,
allowUnselect?: boolean,
): Selection {
// special case: unselectiong a single item with the selection
if (start === end && allowUnselect) {
if (base?.items.has(start)) {
const copy = new Set(base.items);
copy.delete(start);
const current = [...copy];
if (current.length === 0) {
return emptySelection;
}
return {
items: copy,
current: current[current.length - 1], // back to the last selected one
};
}
// intentional fall-through
}
// N.B. start and end can be reverted if selecting backwards
const lowest = Math.min(start, end);
const highest = Math.max(start, end);
const current = end;
return {
items: addIndicesToMultiSelection(base.items, lowest, highest),
current,
};
}
function addIndicesToMultiSelection(
base: ReadonlySet<number>,
lowest: number,
highest: number,
): ReadonlySet<number> {
const copy = new Set(base);
for (let i = lowest; i <= highest; i++) {
copy.add(i);
}
return copy;
}