Summary: Currently there's no way of quickly selecting recent searches other than manually opening the recent search history and then clicking one of the search terms. This diff seeks to add a new feature that would allow not only a keyboard short cut to open the recent search history drop down but also number the most recent 5 search terms so that the user could quickly select them with a number on their keyboard Additionally, fixed bug found in terms of the search bar not showing the current search value correctly `Changelog`: Introduced keyboard shortcut(ctrl + f) option to toggle the search history dropdown along with numbers attached to the options in order to quickly navigate to recent search terms. Have to first enable the option(search shortcut) in menu in order to use the feature. Also added a new button in the options menu that would trigger the search result toggle as triggered by the keyboard shortcut `ctrl` before(`ctrl` + `t` now) WARNING: The current behavior of "ctrl" toggling back and forth to focus the selected item has been migrated to "ctrl + t" key combo Reviewed By: mweststrate Differential Revision: D37685738 fbshipit-source-id: a7ac4dd3dceb846a98258de2d884ebc279ee5995
787 lines
22 KiB
TypeScript
787 lines
22 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and 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,
|
|
DataSourceView,
|
|
DataSourceVirtualizer,
|
|
} from '../../data-source/index';
|
|
import produce, {castDraft, immerable, original} from 'immer';
|
|
import {theme} from '../theme';
|
|
|
|
export type OnColumnResize = (id: string, size: number | Percentage) => void;
|
|
export type Sorting<T = any> = {
|
|
key: keyof T;
|
|
direction: Exclude<SortDirection, undefined>;
|
|
};
|
|
export type SearchHighlightSetting = {
|
|
highlightEnabled: boolean;
|
|
color: string;
|
|
};
|
|
|
|
export type SortDirection = 'asc' | 'desc' | undefined;
|
|
|
|
export type Selection = {items: ReadonlySet<number>; current: number};
|
|
|
|
const emptySelection: Selection = {
|
|
items: new Set(),
|
|
current: -1,
|
|
};
|
|
|
|
const MAX_HISTORY = 1000;
|
|
|
|
type PersistedState = {
|
|
/** Active search value */
|
|
search: string;
|
|
useRegex: boolean;
|
|
filterSearchHistory: boolean;
|
|
/** 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' | 'inversed'
|
|
>[];
|
|
scrollOffset: number;
|
|
autoScroll: boolean;
|
|
searchHistory: string[];
|
|
highlightSearchSetting: SearchHighlightSetting;
|
|
};
|
|
|
|
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'>
|
|
/** Disable the current column filters */
|
|
| Action<'resetFilters'>
|
|
/** 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; addToHistory: boolean}>
|
|
| Action<
|
|
'selectItem',
|
|
{
|
|
nextIndex: number | ((currentIndex: number) => number);
|
|
addToSelection?: boolean;
|
|
allowUnselect?: boolean;
|
|
}
|
|
>
|
|
| Action<
|
|
'selectItemById',
|
|
{
|
|
id: string;
|
|
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<'setColumnFilterInverse', {column: keyof T; inversed: boolean}>
|
|
| Action<'setColumnFilterFromSelection', {column: keyof T}>
|
|
| Action<'appliedInitialScroll'>
|
|
| Action<'toggleUseRegex'>
|
|
| Action<'toggleAutoScroll'>
|
|
| Action<'setAutoScroll', {autoScroll: boolean}>
|
|
| Action<'toggleSearchValue'>
|
|
| Action<'clearSearchHistory'>
|
|
| Action<'toggleHighlightSearch'>
|
|
| Action<'setSearchHighlightColor', {color: string}>
|
|
| Action<'toggleFilterSearchHistory'>
|
|
| Action<'toggleSideBySide'>
|
|
| Action<'showSearchDropdown', {show: boolean}>
|
|
| Action<'setShowNumberedHistory', {showNumberedHistory: boolean}>;
|
|
|
|
type DataManagerConfig<T> = {
|
|
dataSource: DataSource<T, T[keyof T]>;
|
|
dataView: DataSourceView<T, T[keyof T]>;
|
|
defaultColumns: DataTableColumn<T>[];
|
|
scope: string;
|
|
onSelect: undefined | ((item: T | undefined, items: T[]) => void);
|
|
virtualizerRef: MutableRefObject<DataSourceVirtualizer | undefined>;
|
|
autoScroll?: boolean;
|
|
enablePersistSettings?: boolean;
|
|
};
|
|
|
|
export type DataManagerState<T> = {
|
|
config: DataManagerConfig<T>;
|
|
usesWrapping: boolean;
|
|
storageKey: string;
|
|
initialOffset: number;
|
|
columns: DataTableColumn[];
|
|
sorting: Sorting<T> | undefined;
|
|
selection: Selection;
|
|
useRegex: boolean;
|
|
filterSearchHistory: boolean;
|
|
showSearchHistory: boolean;
|
|
showNumberedHistory: boolean;
|
|
autoScroll: boolean;
|
|
searchValue: string;
|
|
/** Used to remember the record entry to lookup when user presses ctrl */
|
|
previousSearchValue: string;
|
|
searchHistory: string[];
|
|
highlightSearchSetting: SearchHighlightSetting;
|
|
sideBySide: boolean;
|
|
};
|
|
|
|
export type DataTableReducer<T> = Reducer<
|
|
DataManagerState<T>,
|
|
DataManagerActions<T>
|
|
>;
|
|
export type DataTableDispatch<T = any> = React.Dispatch<DataManagerActions<T>>;
|
|
|
|
export const dataTableManagerReducer = produce<
|
|
DataManagerState<any>,
|
|
[DataManagerActions<any>]
|
|
>(function (draft, action) {
|
|
const config = original(draft.config)!;
|
|
switch (action.type) {
|
|
case 'reset': {
|
|
draft.columns = computeInitialColumns(config.defaultColumns);
|
|
draft.sorting = undefined;
|
|
draft.searchValue = '';
|
|
draft.selection = castDraft(emptySelection);
|
|
break;
|
|
}
|
|
case 'resetFilters': {
|
|
draft.columns.forEach((c) =>
|
|
c.filters?.forEach((f) => (f.enabled = false)),
|
|
);
|
|
draft.searchValue = '';
|
|
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;
|
|
draft.previousSearchValue = '';
|
|
if (
|
|
action.addToHistory &&
|
|
action.value &&
|
|
!draft.searchHistory.includes(action.value)
|
|
) {
|
|
draft.searchHistory.unshift(action.value);
|
|
// FIFO if history too large
|
|
if (draft.searchHistory.length > MAX_HISTORY) {
|
|
draft.searchHistory.length = MAX_HISTORY;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 'toggleSearchValue': {
|
|
if (draft.searchValue) {
|
|
draft.previousSearchValue = draft.searchValue;
|
|
draft.searchValue = '';
|
|
} else {
|
|
draft.searchValue = draft.previousSearchValue;
|
|
draft.previousSearchValue = '';
|
|
}
|
|
break;
|
|
}
|
|
case 'clearSearchHistory': {
|
|
draft.searchHistory = [];
|
|
break;
|
|
}
|
|
case 'toggleUseRegex': {
|
|
draft.useRegex = !draft.useRegex;
|
|
break;
|
|
}
|
|
case 'toggleFilterSearchHistory': {
|
|
draft.filterSearchHistory = !draft.filterSearchHistory;
|
|
break;
|
|
}
|
|
case 'selectItem': {
|
|
const {nextIndex, addToSelection, allowUnselect} = action;
|
|
draft.selection = castDraft(
|
|
computeSetSelection(
|
|
draft.selection,
|
|
nextIndex,
|
|
addToSelection,
|
|
allowUnselect,
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
case 'selectItemById': {
|
|
const {id, addToSelection} = action;
|
|
// TODO: fix that this doesn't jumpt selection if items are shifted! sorting is swapped etc
|
|
const idx = config.dataSource.getIndexOfKey(id);
|
|
if (idx !== -1) {
|
|
draft.selection = castDraft(
|
|
computeSetSelection(draft.selection, idx, addToSelection),
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case 'addRangeToSelection': {
|
|
const {start, end, allowUnselect} = action;
|
|
draft.selection = castDraft(
|
|
computeAddRangeToSelection(draft.selection, start, end, allowUnselect),
|
|
);
|
|
break;
|
|
}
|
|
case 'clearSelection': {
|
|
draft.selection = castDraft(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 'setColumnFilterInverse': {
|
|
draft.columns.find((c) => c.key === action.column)!.inversed =
|
|
action.inversed;
|
|
break;
|
|
}
|
|
case 'setColumnFilterFromSelection': {
|
|
const items = getSelectedItems(
|
|
config.dataView as DataSourceView<any, any>,
|
|
draft.selection,
|
|
);
|
|
items.forEach((item, index) => {
|
|
addColumnFilter(
|
|
draft.columns,
|
|
action.column,
|
|
getValueAtPath(item, String(action.column)),
|
|
index === 0, // remove existing filters before adding the first
|
|
);
|
|
});
|
|
break;
|
|
}
|
|
case 'appliedInitialScroll': {
|
|
draft.initialOffset = 0;
|
|
break;
|
|
}
|
|
case 'toggleAutoScroll': {
|
|
draft.autoScroll = !draft.autoScroll;
|
|
break;
|
|
}
|
|
case 'setAutoScroll': {
|
|
draft.autoScroll = action.autoScroll;
|
|
break;
|
|
}
|
|
case 'toggleHighlightSearch': {
|
|
draft.highlightSearchSetting.highlightEnabled =
|
|
!draft.highlightSearchSetting.highlightEnabled;
|
|
break;
|
|
}
|
|
case 'setSearchHighlightColor': {
|
|
if (draft.highlightSearchSetting.color !== action.color) {
|
|
draft.highlightSearchSetting.color = action.color;
|
|
}
|
|
break;
|
|
}
|
|
case 'toggleSideBySide': {
|
|
draft.sideBySide = !draft.sideBySide;
|
|
break;
|
|
}
|
|
case 'showSearchDropdown': {
|
|
draft.showSearchHistory = action.show;
|
|
break;
|
|
}
|
|
case 'setShowNumberedHistory': {
|
|
draft.showNumberedHistory = action.showNumberedHistory;
|
|
break;
|
|
}
|
|
default: {
|
|
throw new Error('Unknown action ' + (action as any).type);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Public only imperative convienience API for DataTable
|
|
*/
|
|
export type DataTableManager<T> = {
|
|
reset(): void;
|
|
resetFilters(): void;
|
|
selectItem(
|
|
index: number | ((currentSelection: number) => number),
|
|
addToSelection?: boolean,
|
|
allowUnselect?: boolean,
|
|
): void;
|
|
addRangeToSelection(
|
|
start: number,
|
|
end: number,
|
|
allowUnselect?: boolean,
|
|
): void;
|
|
selectItemById(id: string, addToSelection?: 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, addToHistory?: boolean): void;
|
|
dataView: DataSourceView<T, T[keyof T]>;
|
|
toggleSearchValue(): void;
|
|
toggleHighlightSearch(): void;
|
|
setSearchHighlightColor(color: string): void;
|
|
toggleSideBySide(): void;
|
|
showSearchDropdown(show: boolean): void;
|
|
setShowNumberedHistory(showNumberedHistory: boolean): void;
|
|
};
|
|
|
|
export function createDataTableManager<T>(
|
|
dataView: DataSourceView<T, T[keyof T]>,
|
|
dispatch: DataTableDispatch<T>,
|
|
stateRef: MutableRefObject<DataManagerState<T>>,
|
|
): DataTableManager<T> {
|
|
return {
|
|
reset() {
|
|
dispatch({type: 'reset'});
|
|
},
|
|
resetFilters() {
|
|
dispatch({type: 'resetFilters'});
|
|
},
|
|
selectItem(index: number, addToSelection = false, allowUnselect = false) {
|
|
dispatch({
|
|
type: 'selectItem',
|
|
nextIndex: index,
|
|
addToSelection,
|
|
allowUnselect,
|
|
});
|
|
},
|
|
selectItemById(id, addToSelection = false) {
|
|
dispatch({type: 'selectItemById', id, addToSelection});
|
|
},
|
|
addRangeToSelection(start, end, allowUnselect = false) {
|
|
dispatch({type: 'addRangeToSelection', start, end, allowUnselect});
|
|
},
|
|
clearSelection() {
|
|
dispatch({type: 'clearSelection'});
|
|
},
|
|
getSelectedItem() {
|
|
return getSelectedItem(dataView, stateRef.current.selection);
|
|
},
|
|
getSelectedItems() {
|
|
return getSelectedItems(dataView, stateRef.current.selection);
|
|
},
|
|
toggleColumnVisibility(column) {
|
|
dispatch({type: 'toggleColumnVisibility', column});
|
|
},
|
|
sortColumn(column, direction) {
|
|
dispatch({type: 'sortColumn', column, direction});
|
|
},
|
|
setSearchValue(value, addToHistory = false) {
|
|
dispatch({type: 'setSearchValue', value, addToHistory});
|
|
},
|
|
toggleSearchValue() {
|
|
dispatch({type: 'toggleSearchValue'});
|
|
},
|
|
toggleHighlightSearch() {
|
|
dispatch({type: 'toggleHighlightSearch'});
|
|
},
|
|
setSearchHighlightColor(color) {
|
|
dispatch({type: 'setSearchHighlightColor', color});
|
|
},
|
|
toggleSideBySide() {
|
|
dispatch({type: 'toggleSideBySide'});
|
|
},
|
|
showSearchDropdown(show) {
|
|
dispatch({type: 'showSearchDropdown', show});
|
|
},
|
|
setShowNumberedHistory(showNumberedHistory) {
|
|
dispatch({type: 'setShowNumberedHistory', showNumberedHistory});
|
|
},
|
|
dataView,
|
|
};
|
|
}
|
|
|
|
export function createInitialState<T>(
|
|
config: DataManagerConfig<T>,
|
|
): DataManagerState<T> {
|
|
// by default a table is considered to be identical if plugins, and default column names are the same
|
|
const storageKey = `${config.scope}:DataTable:${config.defaultColumns
|
|
.map((c) => c.key)
|
|
.join(',')}`;
|
|
const prefs = config.enablePersistSettings
|
|
? loadStateFromStorage(storageKey)
|
|
: undefined;
|
|
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 ?? '',
|
|
previousSearchValue: '',
|
|
searchHistory: prefs?.searchHistory ?? [],
|
|
useRegex: prefs?.useRegex ?? false,
|
|
filterSearchHistory: prefs?.filterSearchHistory ?? true,
|
|
autoScroll: prefs?.autoScroll ?? config.autoScroll ?? false,
|
|
highlightSearchSetting: prefs?.highlightSearchSetting ?? {
|
|
highlightEnabled: false,
|
|
color: theme.searchHighlightBackground.yellow,
|
|
},
|
|
sideBySide: false,
|
|
showSearchHistory: false,
|
|
showNumberedHistory: false,
|
|
};
|
|
// @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 = String(value).toLowerCase();
|
|
const existing = column.filters!.find((c) => c.value === filterValue);
|
|
if (existing) {
|
|
existing.enabled = true;
|
|
} else {
|
|
column.filters!.push({
|
|
label: String(value),
|
|
value: filterValue,
|
|
enabled: true,
|
|
});
|
|
}
|
|
if (disableOthers) {
|
|
column.filters!.forEach((c) => {
|
|
if (c.value !== filterValue) {
|
|
c.enabled = false;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export function getSelectedItem<T>(
|
|
dataView: DataSourceView<T, T[keyof T]>,
|
|
selection: Selection,
|
|
): T | undefined {
|
|
return selection.current < 0 ? undefined : dataView.get(selection.current);
|
|
}
|
|
|
|
export function getSelectedItems<T>(
|
|
dataView: DataSourceView<T, T[keyof T]>,
|
|
selection: Selection,
|
|
): T[] {
|
|
return [...selection.items]
|
|
.sort((a, b) => a - b) // https://stackoverflow.com/a/15765283/1983583
|
|
.map((i) => dataView.get(i))
|
|
.filter(Boolean) as any[];
|
|
}
|
|
|
|
export function savePreferences(
|
|
state: DataManagerState<any>,
|
|
scrollOffset: number,
|
|
) {
|
|
if (!state.config.scope || !state.config.enablePersistSettings) {
|
|
return;
|
|
}
|
|
const prefs: PersistedState = {
|
|
search: state.searchValue,
|
|
useRegex: state.useRegex,
|
|
filterSearchHistory: state.filterSearchHistory,
|
|
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,
|
|
inversed: c.inversed,
|
|
})),
|
|
scrollOffset,
|
|
autoScroll: state.autoScroll,
|
|
searchHistory: state.searchHistory,
|
|
highlightSearchSetting: state.highlightSearchSetting,
|
|
};
|
|
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>[] {
|
|
const visibleColumnCount = columns.filter((c) => c.visible !== false).length;
|
|
const columnsWithoutWidth = columns.filter(
|
|
(c) => c.visible !== false && c.width === undefined,
|
|
).length;
|
|
|
|
return columns.map((c) => ({
|
|
...c,
|
|
width:
|
|
c.width ??
|
|
// if the width is not set, and there are multiple columns with unset widths,
|
|
// there will be multiple columns ith the same flex weight (1), meaning that
|
|
// they will all resize a best fits in a specifc row.
|
|
// To address that we distribute space equally
|
|
// (this need further fine tuning in the future as with a subset of fixed columns width can become >100%)
|
|
(columnsWithoutWidth > 1
|
|
? `${Math.floor(100 / visibleColumnCount)}%`
|
|
: undefined),
|
|
filters:
|
|
c.filters?.map((f) => ({
|
|
...f,
|
|
predefined: true,
|
|
})) ?? [],
|
|
visible: c.visible !== false,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* A somewhat primitive and unsafe way to access nested fields an object.
|
|
* @param obj keys should only be strings
|
|
* @param keyPath dotted string path, e.g foo.bar
|
|
* @returns value at the key path
|
|
*/
|
|
|
|
export function getValueAtPath(obj: Record<string, any>, keyPath: string): any {
|
|
let res = obj;
|
|
for (const key of keyPath.split('.')) {
|
|
if (res == null) {
|
|
return null;
|
|
} else {
|
|
res = res[key];
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
export function computeDataTableFilter(
|
|
searchValue: string,
|
|
useRegex: boolean,
|
|
columns: DataTableColumn[],
|
|
) {
|
|
const searchString = searchValue.toLowerCase();
|
|
const searchRegex = useRegex ? safeCreateRegExp(searchValue) : undefined;
|
|
// 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) {
|
|
const rowMatchesFilter = column.filters!.some(
|
|
(f) =>
|
|
f.enabled &&
|
|
String(getValueAtPath(item, column.key))
|
|
.toLowerCase()
|
|
.includes(f.value),
|
|
);
|
|
if (column.inversed && rowMatchesFilter) {
|
|
return false;
|
|
}
|
|
if (!column.inversed && !rowMatchesFilter) {
|
|
return false;
|
|
}
|
|
}
|
|
//free search all top level keys as well as any (nested) columns in the table
|
|
const nestedColumns = columns
|
|
.map((col) => col.key)
|
|
.filter((path) => path.includes('.'));
|
|
return [...Object.keys(item), ...nestedColumns]
|
|
.map((key) => getValueAtPath(item, key))
|
|
.filter((val) => typeof val !== 'object')
|
|
.some((v) => {
|
|
return searchRegex
|
|
? searchRegex.test(String(v))
|
|
: String(v).toLowerCase().includes(searchString);
|
|
});
|
|
};
|
|
}
|
|
|
|
export function safeCreateRegExp(source: string): RegExp | undefined {
|
|
try {
|
|
return new RegExp(source);
|
|
} catch (_e) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function computeSetSelection(
|
|
base: Selection,
|
|
nextIndex: number | ((currentIndex: number) => number),
|
|
addToSelection?: boolean,
|
|
allowUnselect?: boolean,
|
|
): Selection {
|
|
const newIndex =
|
|
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
|
|
// special case: toggle existing selection off
|
|
if (
|
|
!addToSelection &&
|
|
allowUnselect &&
|
|
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;
|
|
}
|