/** * 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, 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 = { key: keyof T; direction: Exclude; }; export type SearchHighlightSetting = { highlightEnabled: boolean; color: string; }; export type SortDirection = 'asc' | 'desc' | undefined; export type Selection = {items: ReadonlySet; current: number}; const emptySelection: Selection = { items: new Set(), current: -1, }; const MAX_HISTORY = 1000; type PersistedState = { /** Active search value */ search: string; useRegex: 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 = {type: Name} & Args; type DataManagerActions = /** 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}>; type DataManagerConfig = { dataSource: DataSource; defaultColumns: DataTableColumn[]; scope: string; onSelect: undefined | ((item: T | undefined, items: T[]) => void); virtualizerRef: MutableRefObject; autoScroll?: boolean; enablePersistSettings?: boolean; }; export type DataManagerState = { config: DataManagerConfig; usesWrapping: boolean; storageKey: string; initialOffset: number; columns: DataTableColumn[]; sorting: Sorting | undefined; selection: Selection; useRegex: boolean; autoScroll: boolean; searchValue: string; /** Used to remember the record entry to lookup when user presses ctrl */ previousSearchValue: string; searchHistory: string[]; highlightSearchSetting: SearchHighlightSetting; }; export type DataTableReducer = Reducer< DataManagerState, DataManagerActions >; export type DataTableDispatch = React.Dispatch>; export const dataTableManagerReducer = produce< DataManagerState, [DataManagerActions] >(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 '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.dataSource as DataSource, 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; } default: { throw new Error('Unknown action ' + (action as any).type); } } }); /** * Public only imperative convienience API for DataTable */ export type DataTableManager = { 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; dataSource: DataSource; toggleSearchValue(): void; toggleHighlightSearch(): void; setSearchHighlightColor(color: string): void; }; export function createDataTableManager( dataSource: DataSource, dispatch: DataTableDispatch, stateRef: MutableRefObject>, ): DataTableManager { 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(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, addToHistory = false) { dispatch({type: 'setSearchValue', value, addToHistory}); }, toggleSearchValue() { dispatch({type: 'toggleSearchValue'}); }, toggleHighlightSearch() { dispatch({type: 'toggleHighlightSearch'}); }, setSearchHighlightColor(color) { dispatch({type: 'setSearchHighlightColor', color}); }, dataSource, }; } export function createInitialState( config: DataManagerConfig, ): DataManagerState { // 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 = { 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, autoScroll: prefs?.autoScroll ?? config.autoScroll ?? false, highlightSearchSetting: prefs?.highlightSearchSetting ?? { highlightEnabled: false, color: theme.searchHighlightBackground.yellow, }, }; // @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 = 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( dataSource: DataSource, selection: Selection, ): T | undefined { return selection.current < 0 ? undefined : dataSource.view.get(selection.current); } export function getSelectedItems( dataSource: DataSource, selection: Selection, ): T[] { return [...selection.items] .sort((a, b) => a - b) // https://stackoverflow.com/a/15765283/1983583 .map((i) => dataSource.view.get(i)) .filter(Boolean) as any[]; } export function savePreferences( state: DataManagerState, scrollOffset: number, ) { if (!state.config.scope || !state.config.enablePersistSettings) { return; } const prefs: PersistedState = { search: state.searchValue, useRegex: state.useRegex, 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[], ): DataTableColumn[] { 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, 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, lowest: number, highest: number, ): ReadonlySet { const copy = new Set(base); for (let i = lowest; i <= highest; i++) { copy.add(i); } return copy; }