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 {DataTable, DataTableColumn} from './ui/datatable/DataTable';
export {DataTableManager} from './ui/datatable/useDataTableManager';
export {DataTableManager} from './ui/datatable/DataTableManager';
export {
Interactive as _Interactive,

View File

@@ -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();
}
}

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -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<any>} & ColumnFilterHandlers) {
dispatch,
}: {
column: DataTableColumn<any>;
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}
/>
<Button onClick={onAddFilter}>Add</Button>
<Button
onClick={onAddFilter}
title="Add filter"
type="ghost"
style={{padding: '4px 8px'}}>
<PlusCircleOutlined />
</Button>
</Layout.Right>
</Menu.Item>
<Menu.Divider />
@@ -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}
</Checkbox>
@@ -81,7 +96,11 @@ export function FilterIcon({
<MinusCircleOutlined
onClick={(e) => {
e.stopPropagation();
props.onRemoveColumnFilter(column.key, index);
dispatch({
type: 'removeColumnFilter',
column: column.key,
index,
});
}}
/>
)}
@@ -89,17 +108,23 @@ export function FilterIcon({
</Menu.Item>
))
) : (
<Menu.Item disabled>
<Text type="secondary" style={{margin: 12}}>
No active filters
</Text>
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item disabled>
<div style={{textAlign: 'right'}}>
<Button
type="link"
style={{fontWeight: 'unset'}}
onClick={() => {
props.onSetColumnFilterFromSelection(column.key);
dispatch({
type: 'setColumnFilterFromSelection',
column: column.key,
});
}}>
From selection
</Button>
@@ -108,7 +133,13 @@ export function FilterIcon({
style={{fontWeight: 'unset'}}
onClick={() => {
filters?.forEach((f, index) => {
if (!f.enabled) props.onToggleColumnFilter(column.key, index);
if (!f.enabled) {
dispatch({
type: 'toggleColumnFilter',
column: column.key,
index,
});
}
});
}}>
All
@@ -118,23 +149,27 @@ export function FilterIcon({
style={{fontWeight: 'unset'}}
onClick={() => {
filters?.forEach((f, index) => {
if (f.enabled) props.onToggleColumnFilter(column.key, index);
if (f.enabled)
dispatch({
type: 'toggleColumnFilter',
column: column.key,
index,
});
});
}}>
None
</Button>
</div>
</Menu.Item>
</Menu>
);
return (
<div>
<Dropdown overlay={menu} trigger={['click']}>
<FilterButton isActive={isActive}>
<FilterFilled />
</FilterButton>
</Dropdown>
</div>
);
}

View File

@@ -55,7 +55,12 @@ type DataSourceProps<T extends object, C> = {
defaultRowHeight: number;
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
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;
_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 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: <T extends object, C>(
} else {
followOutput.current = true;
}
}, [autoScroll]);
}, [autoScroll, parentRef]);
useLayoutEffect(function scrollToEnd() {
if (followOutput.current) {

View File

@@ -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<T = any> {
columns: DataTableColumn<T>[];
@@ -79,30 +92,44 @@ export interface RenderContext<T = any> {
export function DataTable<T extends object>(
props: DataTableProps<T>,
): 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<DataSourceVirtualizer | undefined>();
const tableManager = useDataTableManager(
const [state, dispatch] = useReducer(
dataTableManagerReducer as DataTableReducer<T>,
undefined,
() =>
createInitialState({
dataSource,
props.columns,
props.onSelect,
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>>(() => {
let dragging = false;
@@ -112,17 +139,17 @@ export function DataTable<T extends object>(
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<T extends object>(
}
},
};
}, [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<T extends object>(
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<T extends object>(
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<T extends object>(
const hideRange = useRef<NodeJS.Timeout>();
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<T extends object>(
);
/** 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<T>) => {
return <EmptyTable dataSource={dataSource} />;
}, []);
useEffect(
function cleanup() {
return () => {
useEffect(function initialSetup() {
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<undefined>).current = undefined;
(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;
}
};
},
[props.tableManagerRef],
);
// one-time setup and cleanup effect, everything in here is asserted to be stable:
// dataSource, tableManager, tableManagerRef
// eslint-disable-next-line
}, []);
return (
<Layout.Container grow>
<Layout.Top>
<Layout.Container>
<TableSearch
onSearch={tableManager.setSearchValue}
extraActions={props.extraActions}
searchValue={searchValue}
dispatch={dispatch as any}
contextMenu={contexMenu}
extraActions={props.extraActions}
/>
<TableHead
visibleColumns={tableManager.visibleColumns}
onColumnResize={tableManager.resizeColumn}
onReset={tableManager.reset}
sorting={tableManager.sorting}
onColumnSort={tableManager.sortColumn}
onAddColumnFilter={tableManager.addColumnFilter}
onRemoveColumnFilter={tableManager.removeColumnFilter}
onToggleColumnFilter={tableManager.toggleColumnFilter}
onSetColumnFilterFromSelection={
tableManager.setColumnFilterFromSelection
}
visibleColumns={visibleColumns}
dispatch={dispatch as any}
sorting={sorting}
/>
</Layout.Container>
<DataSourceRenderer<T, RenderContext<T>>
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<T extends object>(
);
}
function emptyRenderer(dataSource: DataSource<any>) {
return <EmptyTable dataSource={dataSource} />;
}
function EmptyTable({dataSource}: {dataSource: DataSource<any>}) {
return (
<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 {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<any>) {
export function tableContextMenuFactory<T>(
datasource: DataSource<T>,
dispatch: DataTableDispatch<T>,
selection: Selection,
columns: DataTableColumn<T>[],
visibleColumns: DataTableColumn<T>[],
) {
const lib = tryGetFlipperLibImplementation();
if (!lib) {
return (
@@ -26,18 +37,22 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
</Menu>
);
}
const hasSelection = tableManager.selection?.items.size > 0 ?? false;
const hasSelection = selection.items.size > 0 ?? false;
return (
<Menu>
<SubMenu
title="Filter on same"
icon={<FilterOutlined />}
disabled={!hasSelection}>
{tableManager.visibleColumns.map((column) => (
{visibleColumns.map((column) => (
<Item
key={column.key}
onClick={() => {
tableManager.setColumnFilterFromSelection(column.key);
dispatch({
type: 'setColumnFilterFromSelection',
column: column.key,
});
}}>
{friendlyColumnTitle(column)}
</Item>
@@ -47,11 +62,11 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
title="Copy cell(s)"
icon={<CopyOutlined />}
disabled={!hasSelection}>
{tableManager.visibleColumns.map((column) => (
{visibleColumns.map((column) => (
<Item
key={column.key}
onClick={() => {
const items = tableManager.getSelectedItems();
const items = getSelectedItems(datasource, selection);
if (items.length) {
lib.writeTextToClipboard(
items
@@ -67,7 +82,7 @@ export function tableContextMenuFactory(tableManager: DataTableManager<any>) {
<Item
disabled={!hasSelection}
onClick={() => {
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<any>) {
<Item
disabled={!hasSelection}
onClick={() => {
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<any>) {
)}
<Menu.Divider />
<SubMenu title="Visible columns">
{tableManager.columns.map((column) => (
{columns.map((column) => (
<Menu.Item key={column.key}>
<Checkbox
checked={column.visible}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
tableManager.toggleColumnVisibility(column.key);
dispatch({type: 'toggleColumnVisibility', column: column.key});
}}>
{friendlyColumnTitle(column)}
</Checkbox>
</Menu.Item>
))}
</SubMenu>
<Menu.Item key="reset" onClick={tableManager.reset}>
<Menu.Item
key="reset"
onClick={() => {
dispatch({type: 'reset'});
}}>
Reset view
</Menu.Item>
</Menu>

View File

@@ -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<any>;
sorted: SortDirection;
isResizable: boolean;
onSort: (id: string, direction: SortDirection) => void;
sortOrder: undefined | Sorting;
onColumnResize: OnColumnResize;
} & ColumnFilterHandlers) {
dispatch: DataTableDispatch;
}) {
const ref = useRef<HTMLDivElement | null>(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 ?? <>&nbsp;</>}
<SortIcons
direction={sorted}
onSort={(dir) => onSort(column.key, dir)}
onSort={(dir) =>
dispatch({type: 'sortColumn', column: column.key, direction: dir})
}
/>
</Text>
</div>
<FilterIcon column={column} {...filterHandlers} />
<FilterIcon column={column} dispatch={dispatch} />
</Layout.Right>
);
@@ -209,14 +215,13 @@ function TableHeadColumn({
export const TableHead = memo(function TableHead({
visibleColumns,
...props
dispatch,
sorting,
}: {
dispatch: DataTableDispatch<any>;
visibleColumns: DataTableColumn<any>[];
onColumnResize: OnColumnResize;
onReset: () => void;
sorting: Sorting | undefined;
onColumnSort: (key: string, direction: SortDirection) => void;
} & ColumnFilterHandlers) {
}) {
return (
<TableHeadContainer>
{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}
/>
))}
</TableHeadContainer>

View File

@@ -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<any>;
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 (
<Searchbar gap>
<Input.Search
allowClear
placeholder="Search..."
onSearch={performSearch}
value={search}
onSearch={onSearch}
value={searchValue}
onChange={(e) => {
setSearch(e.target.value);
performSearch(e.target.value);
onSearch(e.target.value);
}}
/>
{extraActions}

View File

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

View File

@@ -10,7 +10,7 @@
import {
computeAddRangeToSelection,
computeSetSelection,
} from '../useDataTableManager';
} from '../DataTableManager';
test('computeSetSelection', () => {
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;
}