From a610c821d3b83288590234ed3562cf5ad7994049 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Tue, 16 Mar 2021 14:54:53 -0700 Subject: [PATCH] Store preferences and scrolling, refactor to useReducer Reviewed By: priteshrnandgaonkar Differential Revision: D26848266 fbshipit-source-id: 738d52556b9fb65ec5b5de7c727467227167b9b9 --- desktop/flipper-plugin/src/index.ts | 2 +- .../src/state/datasource/DataSource.tsx | 1 + desktop/flipper-plugin/src/ui/Layout.tsx | 4 +- desktop/flipper-plugin/src/ui/Tracked.tsx | 2 +- .../src/ui/datatable/ColumnFilter.tsx | 143 +++-- .../src/ui/datatable/DataSourceRenderer.tsx | 16 +- .../src/ui/datatable/DataTable.tsx | 220 +++++--- .../src/ui/datatable/DataTableManager.tsx | 520 ++++++++++++++++++ .../src/ui/datatable/TableContextMenu.tsx | 43 +- .../src/ui/datatable/TableHead.tsx | 55 +- .../src/ui/datatable/TableSearch.tsx | 30 +- .../ui/datatable/__tests__/DataTable.node.tsx | 2 +- .../__tests__/DataTableManager.node.tsx | 2 +- .../src/ui/datatable/useDataTableManager.tsx | 441 --------------- 14 files changed, 854 insertions(+), 627 deletions(-) create mode 100644 desktop/flipper-plugin/src/ui/datatable/DataTableManager.tsx delete mode 100644 desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 555f7a345..3f10397c9 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -78,7 +78,7 @@ export {Idler} from './utils/Idler'; export {createDataSource, DataSource} from './state/datasource/DataSource'; export {DataTable, DataTableColumn} from './ui/datatable/DataTable'; -export {DataTableManager} from './ui/datatable/useDataTableManager'; +export {DataTableManager} from './ui/datatable/DataTableManager'; export { Interactive as _Interactive, diff --git a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx index 906296827..7349d2bed 100644 --- a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx +++ b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx @@ -297,6 +297,7 @@ export class DataSource< setFilter(filter: undefined | ((value: T) => boolean)) { if (this.filter !== filter) { this.filter = filter; + // TODO: this needs debouncing! this.rebuildOutput(); } } diff --git a/desktop/flipper-plugin/src/ui/Layout.tsx b/desktop/flipper-plugin/src/ui/Layout.tsx index 796f4d413..c88bde083 100644 --- a/desktop/flipper-plugin/src/ui/Layout.tsx +++ b/desktop/flipper-plugin/src/ui/Layout.tsx @@ -203,11 +203,11 @@ const SandySplitContainer = styled.div<{ alignItems: props.center ? 'center' : 'stretch', gap: normalizeSpace(props.gap, theme.space.small), 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, minWidth: props.grow === 1 ? 0 : undefined, }, - '>:nth-of-type(2)': { + '>:nth-child(2)': { flex: props.grow === 2 ? splitGrowStyle : splitFixedStyle, minWidth: props.grow === 2 ? 0 : undefined, }, diff --git a/desktop/flipper-plugin/src/ui/Tracked.tsx b/desktop/flipper-plugin/src/ui/Tracked.tsx index b5f616e8a..80c3eb90a 100644 --- a/desktop/flipper-plugin/src/ui/Tracked.tsx +++ b/desktop/flipper-plugin/src/ui/Tracked.tsx @@ -39,7 +39,7 @@ export function resetGlobalInteractionReporter() { const DEFAULT_SCOPE = 'Flipper'; -const TrackingScopeContext = createContext(DEFAULT_SCOPE); +export const TrackingScopeContext = createContext(DEFAULT_SCOPE); export function TrackingScope({ scope, diff --git a/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx b/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx index fc4763fb5..aceb847d9 100644 --- a/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/ColumnFilter.tsx @@ -11,32 +11,37 @@ import {useMemo, useState} from 'react'; import styled from '@emotion/styled'; import React from 'react'; 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 type {DataTableColumn} from './DataTable'; import {Layout} from '../Layout'; +import type {DataTableDispatch} from './DataTableManager'; 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({ column, - ...props -}: {column: DataTableColumn} & ColumnFilterHandlers) { + dispatch, +}: { + column: DataTableColumn; + dispatch: DataTableDispatch; +}) { const [input, setInput] = useState(''); const {filters} = column; const isActive = useMemo(() => filters?.some((f) => f.enabled), [filters]); const onAddFilter = (e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation(); - props.onAddColumnFilter(column.key, input); + dispatch({ + type: 'addColumnFilter', + column: column.key, + value: input, + }); setInput(''); }; @@ -60,7 +65,13 @@ export function FilterIcon({ onPressEnter={onAddFilter} disabled={false} /> - + @@ -73,7 +84,11 @@ export function FilterIcon({ onClick={(e) => { e.stopPropagation(); e.preventDefault(); - props.onToggleColumnFilter(column.key, index); + dispatch({ + type: 'toggleColumnFilter', + column: column.key, + index, + }); }}> {filter.label} @@ -81,7 +96,11 @@ export function FilterIcon({ { e.stopPropagation(); - props.onRemoveColumnFilter(column.key, index); + dispatch({ + type: 'removeColumnFilter', + column: column.key, + index, + }); }} /> )} @@ -89,52 +108,68 @@ export function FilterIcon({ )) ) : ( - - No active filters - + + + No active filters + + )} -
- - - -
+ +
+ + + +
+
); return ( -
- - - - - -
+ + + + + ); } diff --git a/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx index 82e02ebf3..a0b5235ff 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx @@ -55,7 +55,12 @@ type DataSourceProps = { defaultRowHeight: number; onKeyDown?: React.KeyboardEventHandler; virtualizerRef?: MutableRefObject; - onRangeChange?(start: number, end: number, total: number): void; + onRangeChange?( + start: number, + end: number, + total: number, + offset: number, + ): void; emptyRenderer?(dataSource: DataSource): React.ReactElement; _testHeight?: number; // exposed for unit testing only }; @@ -181,7 +186,12 @@ export const DataSourceRenderer: ( const start = virtualizer.virtualItems[0]?.index ?? 0; const end = start + virtualizer.virtualItems.length; 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); }); @@ -208,7 +218,7 @@ export const DataSourceRenderer: ( } else { followOutput.current = true; } - }, [autoScroll]); + }, [autoScroll, parentRef]); useLayoutEffect(function scrollToEnd() { if (followOutput.current) { diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx index 6a4fb6235..c2348f079 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -17,6 +17,8 @@ import React, { MutableRefObject, CSSProperties, useEffect, + useContext, + useReducer, } from 'react'; import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow'; import {DataSource} from '../../state/datasource/DataSource'; @@ -24,7 +26,17 @@ import {Layout} from '../Layout'; import {TableHead} from './TableHead'; import {Percentage} from '../../utils/widthUtils'; 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 styled from '@emotion/styled'; import {theme} from '../theme'; @@ -32,6 +44,7 @@ import {tableContextMenuFactory} from './TableContextMenu'; import {Typography} from 'antd'; import {CoffeeOutlined, SearchOutlined} from '@ant-design/icons'; import {useAssertStableRef} from '../../utils/useAssertStableRef'; +import {TrackingScopeContext} from 'flipper-plugin/src/ui/Tracked'; interface DataTableProps { columns: DataTableColumn[]; @@ -79,30 +92,44 @@ export interface RenderContext { export function DataTable( props: DataTableProps, ): React.ReactElement { - const {dataSource, onRowStyle} = props; + const {dataSource, onRowStyle, onSelect} = props; useAssertStableRef(dataSource, 'dataSource'); useAssertStableRef(onRowStyle, 'onRowStyle'); useAssertStableRef(props.onSelect, 'onRowSelect'); useAssertStableRef(props.columns, 'columns'); 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(); - const tableManager = useDataTableManager( - dataSource, - props.columns, - props.onSelect, + const [state, dispatch] = useReducer( + dataTableManagerReducer as DataTableReducer, + undefined, + () => + 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 - >).current = tableManager; - } - const { - visibleColumns, - selectItem, - selection, - addRangeToSelection, - } = tableManager; const renderingConfig = useMemo>(() => { let dragging = false; @@ -112,17 +139,17 @@ export function DataTable( onMouseEnter(_e, _item, index) { if (dragging) { // 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) { if (!dragging) { if (e.ctrlKey || e.metaKey) { - addRangeToSelection(index, index, true); + tableManager.addRangeToSelection(index, index, true); } else if (e.shiftKey) { - selectItem(index, true); + tableManager.selectItem(index, true); } else { - selectItem(index); + tableManager.selectItem(index); } dragging = true; @@ -137,12 +164,7 @@ export function DataTable( } }, }; - }, [visibleColumns, selectItem, addRangeToSelection]); - - const usesWrapping = useMemo( - () => tableManager.columns.some((col) => col.wrap), - [tableManager.columns], - ); + }, [visibleColumns, tableManager]); const itemRenderer = useCallback( function itemRenderer( @@ -177,29 +199,35 @@ export function DataTable( const windowSize = virtualizerRef.current!.virtualItems.length; switch (e.key) { case 'ArrowUp': - selectItem((idx) => (idx > 0 ? idx - 1 : 0), shiftPressed); + tableManager.selectItem( + (idx) => (idx > 0 ? idx - 1 : 0), + shiftPressed, + ); break; case 'ArrowDown': - selectItem( + tableManager.selectItem( (idx) => (idx < outputSize - 1 ? idx + 1 : idx), shiftPressed, ); break; case 'Home': - selectItem(0, shiftPressed); + tableManager.selectItem(0, shiftPressed); break; case 'End': - selectItem(outputSize - 1, shiftPressed); + tableManager.selectItem(outputSize - 1, shiftPressed); break; case ' ': // yes, that is a space case 'PageDown': - selectItem( + tableManager.selectItem( (idx) => Math.min(outputSize - 1, idx + windowSize - 1), shiftPressed, ); break; case 'PageUp': - selectItem((idx) => Math.max(0, idx - windowSize + 1), shiftPressed); + tableManager.selectItem( + (idx) => Math.max(0, idx - windowSize + 1), + shiftPressed, + ); break; default: handled = false; @@ -209,17 +237,63 @@ export function DataTable( 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( 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, { align: 'auto', }); } }, + // initialOffset is relevant for the first run, + // but should not trigger the efffect in general + // eslint-disable-next-line [selection], ); @@ -228,9 +302,10 @@ export function DataTable( const hideRange = useRef(); 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 setRange(`${start} - ${end} / ${total}`); + lastOffset.current = offset; clearTimeout(hideRange.current!); hideRange.current = setTimeout(() => { setRange(''); @@ -240,53 +315,62 @@ export function DataTable( ); /** Context menu */ - // TODO: support customizing context menu const contexMenu = props._testHeight - ? undefined // don't render context menu in tests - : tableContextMenuFactory(tableManager); + ? undefined + : // eslint-disable-next-line + useCallback( + () => + tableContextMenuFactory( + dataSource, + dispatch, + selection, + state.columns, + visibleColumns, + ), + [dataSource, dispatch, selection, state.columns, visibleColumns], + ); - const emptyRenderer = useCallback((dataSource: DataSource) => { - return ; + useEffect(function initialSetup() { + if (props.tableManagerRef) { + (props.tableManagerRef as MutableRefObject).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).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).current = undefined; - } - }; - }, - [props.tableManagerRef], - ); - return ( > dataSource={dataSource} autoScroll={props.autoScroll} - useFixedRowHeight={!usesWrapping} + useFixedRowHeight={!state.usesWrapping} defaultRowHeight={DEFAULT_ROW_HEIGHT} context={renderingConfig} itemRenderer={itemRenderer} @@ -302,6 +386,10 @@ export function DataTable( ); } +function emptyRenderer(dataSource: DataSource) { + return ; +} + function EmptyTable({dataSource}: {dataSource: DataSource}) { return ( void; +export type Sorting = { + key: keyof T; + direction: Exclude; +}; + +export type SortDirection = 'asc' | 'desc' | undefined; + +export type Selection = {items: ReadonlySet; 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[]; + scrollOffset: number; +}; + +type Action = {type: Name} & Args; + +type DataManagerActions = + /** 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 = { + dataSource: DataSource; + defaultColumns: DataTableColumn[]; + scope: string; + onSelect: undefined | ((item: T | undefined, items: T[]) => void); + virtualizerRef: MutableRefObject; +}; + +type DataManagerState = { + config: DataManagerConfig; + usesWrapping: boolean; + storageKey: string; + initialOffset: number; + columns: DataTableColumn[]; + sorting: Sorting | undefined; + selection: Selection; + searchValue: string; +}; + +export type DataTableReducer = Reducer< + DataManagerState, + DataManagerActions +>; +export type DataTableDispatch = React.Dispatch>; + +// TODO: make argu inference correct +export const dataTableManagerReducer = produce(function ( + draft: DataManagerState, + action: DataManagerActions, +) { + 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 = { + 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( + dataSource: DataSource, + dispatch: DataTableDispatch, + stateRef: MutableRefObject>, +): DataTableManager { + 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( + config: DataManagerConfig, +): DataManagerState { + 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 = { + 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( + columns: DataTableColumn[], + 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( + dataSource: DataSource, + selection: Selection, +): T | undefined { + return selection.current < 0 + ? undefined + : dataSource.getItem(selection.current); +} + +export function getSelectedItems( + dataSource: DataSource, + selection: Selection, +): T[] { + return [...selection.items] + .sort() + .map((i) => dataSource.getItem(i)) + .filter(Boolean) as any[]; +} + +export function savePreferences( + state: DataManagerState, + 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[], +): DataTableColumn[] { + 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, + lowest: number, + highest: number, +): ReadonlySet { + const copy = new Set(base); + for (let i = lowest; i <= highest; i++) { + copy.add(i); + } + return copy; +} diff --git a/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx b/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx index f329a0be4..2b0a60c04 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableContextMenu.tsx @@ -9,15 +9,26 @@ import {CopyOutlined, FilterOutlined} from '@ant-design/icons'; import {Checkbox, Menu} from 'antd'; -import {DataTableManager} from './useDataTableManager'; +import { + DataTableDispatch, + getSelectedItems, + Selection, +} from './DataTableManager'; import React from 'react'; import {normalizeCellValue} from './TableRow'; import {tryGetFlipperLibImplementation} from '../../plugin/FlipperLib'; import {DataTableColumn} from './DataTable'; +import {DataSource} from '../../state/datasource/DataSource'; const {Item, SubMenu} = Menu; -export function tableContextMenuFactory(tableManager: DataTableManager) { +export function tableContextMenuFactory( + datasource: DataSource, + dispatch: DataTableDispatch, + selection: Selection, + columns: DataTableColumn[], + visibleColumns: DataTableColumn[], +) { const lib = tryGetFlipperLibImplementation(); if (!lib) { return ( @@ -26,18 +37,22 @@ export function tableContextMenuFactory(tableManager: DataTableManager) { ); } - const hasSelection = tableManager.selection?.items.size > 0 ?? false; + const hasSelection = selection.items.size > 0 ?? false; + return ( } disabled={!hasSelection}> - {tableManager.visibleColumns.map((column) => ( + {visibleColumns.map((column) => ( { - tableManager.setColumnFilterFromSelection(column.key); + dispatch({ + type: 'setColumnFilterFromSelection', + column: column.key, + }); }}> {friendlyColumnTitle(column)} @@ -47,11 +62,11 @@ export function tableContextMenuFactory(tableManager: DataTableManager) { title="Copy cell(s)" icon={} disabled={!hasSelection}> - {tableManager.visibleColumns.map((column) => ( + {visibleColumns.map((column) => ( { - const items = tableManager.getSelectedItems(); + const items = getSelectedItems(datasource, selection); if (items.length) { lib.writeTextToClipboard( items @@ -67,7 +82,7 @@ export function tableContextMenuFactory(tableManager: DataTableManager) { { - const items = tableManager.getSelectedItems(); + const items = getSelectedItems(datasource, selection); if (items.length) { lib.writeTextToClipboard( JSON.stringify(items.length > 1 ? items : items[0], null, 2), @@ -80,7 +95,7 @@ export function tableContextMenuFactory(tableManager: DataTableManager) { { - const items = tableManager.getSelectedItems(); + const items = getSelectedItems(datasource, selection); if (items.length) { lib.createPaste( JSON.stringify(items.length > 1 ? items : items[0], null, 2), @@ -92,21 +107,25 @@ export function tableContextMenuFactory(tableManager: DataTableManager) { )} - {tableManager.columns.map((column) => ( + {columns.map((column) => ( { e.stopPropagation(); e.preventDefault(); - tableManager.toggleColumnVisibility(column.key); + dispatch({type: 'toggleColumnVisibility', column: column.key}); }}> {friendlyColumnTitle(column)} ))} - + { + dispatch({type: 'reset'}); + }}> Reset view diff --git a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx index 0bf2292e9..dba43d18d 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableHead.tsx @@ -23,8 +23,8 @@ import type {DataTableColumn} from './DataTable'; import {Typography} from 'antd'; import {CaretDownFilled, CaretUpFilled} from '@ant-design/icons'; import {Layout} from '../Layout'; -import {Sorting, OnColumnResize, SortDirection} from './useDataTableManager'; -import {ColumnFilterHandlers, FilterButton, FilterIcon} from './ColumnFilter'; +import {Sorting, SortDirection, DataTableDispatch} from './DataTableManager'; +import {FilterButton, FilterIcon} from './ColumnFilter'; const {Text} = Typography; @@ -120,18 +120,14 @@ const RIGHT_RESIZABLE = {right: true}; function TableHeadColumn({ column, isResizable, - onColumnResize, - onSort, sorted, - ...filterHandlers + dispatch, }: { column: DataTableColumn; sorted: SortDirection; isResizable: boolean; - onSort: (id: string, direction: SortDirection) => void; - sortOrder: undefined | Sorting; - onColumnResize: OnColumnResize; -} & ColumnFilterHandlers) { + dispatch: DataTableDispatch; +}) { const ref = useRef(null); const onResize = (newWidth: number) => { @@ -158,7 +154,11 @@ function TableHeadColumn({ } } - onColumnResize(column.key, normalizedWidth); + dispatch({ + type: 'resizeColumn', + column: column.key, + width: normalizedWidth, + }); }; let children = ( @@ -172,7 +172,11 @@ function TableHeadColumn({ : sorted === 'asc' ? 'desc' : undefined; - onSort(column.key, newDirection); + dispatch({ + type: 'sortColumn', + column: column.key, + direction: newDirection, + }); }} role="button" tabIndex={0}> @@ -180,11 +184,13 @@ function TableHeadColumn({ {column.title ?? <> } onSort(column.key, dir)} + onSort={(dir) => + dispatch({type: 'sortColumn', column: column.key, direction: dir}) + } /> - + ); @@ -209,14 +215,13 @@ function TableHeadColumn({ export const TableHead = memo(function TableHead({ visibleColumns, - ...props + dispatch, + sorting, }: { + dispatch: DataTableDispatch; visibleColumns: DataTableColumn[]; - onColumnResize: OnColumnResize; - onReset: () => void; sorting: Sorting | undefined; - onColumnSort: (key: string, direction: SortDirection) => void; -} & ColumnFilterHandlers) { +}) { return ( {visibleColumns.map((column, i) => ( @@ -224,18 +229,8 @@ export const TableHead = memo(function TableHead({ key={column.key} column={column} isResizable={i < visibleColumns.length - 1} - sortOrder={props.sorting} - onSort={props.onColumnSort} - 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 - } + dispatch={dispatch} + sorted={sorting?.key === column.key ? sorting!.direction : undefined} /> ))} diff --git a/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx b/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx index ffe554dad..e9b223da0 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx @@ -9,39 +9,39 @@ import {MenuOutlined} from '@ant-design/icons'; import {Button, Dropdown, Input} from 'antd'; -import React, {memo, useState} from 'react'; +import React, {memo, useCallback} from 'react'; import styled from '@emotion/styled'; import {Layout} from '../Layout'; import {theme} from '../theme'; -import {debounce} from 'lodash'; -import {useAssertStableRef} from '../../utils/useAssertStableRef'; +import type {DataTableDispatch} from './DataTableManager'; export const TableSearch = memo(function TableSearch({ - onSearch, + searchValue, + dispatch, extraActions, contextMenu, }: { - onSearch(value: string): void; + searchValue: string; + dispatch: DataTableDispatch; extraActions?: React.ReactElement; - hasSelection?: boolean; - contextMenu?: React.ReactElement; + contextMenu: undefined | (() => JSX.Element); }) { - useAssertStableRef(onSearch, 'onSearch'); - const [search, setSearch] = useState(''); - const [performSearch] = useState(() => - debounce(onSearch, 200, {leading: true}), + const onSearch = useCallback( + (value: string) => { + dispatch({type: 'setSearchValue', value}); + }, + [dispatch], ); return ( { - setSearch(e.target.value); - performSearch(e.target.value); + onSearch(e.target.value); }} /> {extraActions} diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx index 4e9c9ba3f..be39d2232 100644 --- a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx @@ -11,7 +11,7 @@ import React, {createRef} from 'react'; import {DataTable, DataTableColumn} from '../DataTable'; import {render, act} from '@testing-library/react'; import {createDataSource} from '../../../state/datasource/DataSource'; -import {computeDataTableFilter, DataTableManager} from '../useDataTableManager'; +import {computeDataTableFilter, DataTableManager} from '../DataTableManager'; import {Button} from 'antd'; type Todo = { diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx index 792a10083..afe7f1247 100644 --- a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTableManager.node.tsx @@ -10,7 +10,7 @@ import { computeAddRangeToSelection, computeSetSelection, -} from '../useDataTableManager'; +} from '../DataTableManager'; test('computeSetSelection', () => { const emptyBase = { diff --git a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx b/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx deleted file mode 100644 index 8aac4cab8..000000000 --- a/desktop/flipper-plugin/src/ui/datatable/useDataTableManager.tsx +++ /dev/null @@ -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; -}; - -export type SortDirection = 'asc' | 'desc' | undefined; - -export interface DataTableManager { - /** The default columns, but normalized */ - columns: DataTableColumn[]; - /** The effective columns to be rendererd */ - visibleColumns: DataTableColumn[]; - /** 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; current: number}; - -const emptySelection: Selection = { - items: new Set(), - current: -1, -}; - -/** - * A hook that coordinates filtering, sorting etc for a DataSource - */ -export function useDataTableManager( - dataSource: DataSource, - defaultColumns: DataTableColumn[], - onSelect?: (item: T | undefined, items: T[]) => void, -): DataTableManager { - 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(emptySelection); - - const [sorting, setSorting] = useState(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[]) => { - 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[]) => { - draft.find((c) => c.key === columnId)!.filters?.splice(index, 1); - }), - ); - }, []); - - const toggleColumnFilter = useCallback((columnId: string, index: number) => { - // TODO: fix typings - setEffectiveColumns( - produce((draft: DataTableColumn[]) => { - 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[]) => { - 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[]) => { - 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[], -): DataTableColumn[] { - 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, - lowest: number, - highest: number, -): ReadonlySet { - const copy = new Set(base); - for (let i = lowest; i <= highest; i++) { - copy.add(i); - } - return copy; -}