From f897ab94877b7e9a4050a3172a22ebd0f151e713 Mon Sep 17 00:00:00 2001 From: Andrey Goncharov Date: Thu, 14 Sep 2023 04:48:12 -0700 Subject: [PATCH] Add DataTable wirh PowerSearch integrated Summary: Doc: https://docs.google.com/document/d/1miofxds9DJgWScj0zFyBbdpRH5Rj0T9FqiCapof5-vU/edit#heading=h.pg8svtdjlx7 Reviewed By: lblasa Differential Revision: D49225985 fbshipit-source-id: ea121c88f4f2275bb15b116858951a8bd2f43cc3 --- .../DataTableDefaultPowerSearchOperators.tsx | 19 + .../data-table/DataTableWithPowerSearch.tsx | 63 +- .../DataTableWithPowerSearchManager.tsx | 599 ++++++++++++++++++ .../PowerSearchTableContextMenu.tsx | 222 +++++++ 4 files changed, 850 insertions(+), 53 deletions(-) create mode 100644 desktop/flipper-plugin/src/ui/data-table/DataTableDefaultPowerSearchOperators.tsx create mode 100644 desktop/flipper-plugin/src/ui/data-table/DataTableWithPowerSearchManager.tsx create mode 100644 desktop/flipper-plugin/src/ui/data-table/PowerSearchTableContextMenu.tsx diff --git a/desktop/flipper-plugin/src/ui/data-table/DataTableDefaultPowerSearchOperators.tsx b/desktop/flipper-plugin/src/ui/data-table/DataTableDefaultPowerSearchOperators.tsx new file mode 100644 index 000000000..f1fb27f9c --- /dev/null +++ b/desktop/flipper-plugin/src/ui/data-table/DataTableDefaultPowerSearchOperators.tsx @@ -0,0 +1,19 @@ +/** + * 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 {OperatorConfig} from '../PowerSearch'; + +export type PowerSearchOperatorProcessor = ( + powerSearchOperatorConfig: OperatorConfig, + value: any, +) => boolean; + +export type PowerSearchOperatorProcessorConfig = { + [key: string]: PowerSearchOperatorProcessor; +}; diff --git a/desktop/flipper-plugin/src/ui/data-table/DataTableWithPowerSearch.tsx b/desktop/flipper-plugin/src/ui/data-table/DataTableWithPowerSearch.tsx index e332c9675..05675e6d9 100644 --- a/desktop/flipper-plugin/src/ui/data-table/DataTableWithPowerSearch.tsx +++ b/desktop/flipper-plugin/src/ui/data-table/DataTableWithPowerSearch.tsx @@ -39,10 +39,10 @@ import { getSelectedItem, getSelectedItems, savePreferences, -} from './DataTableManager'; +} from './DataTableWithPowerSearchManager'; import styled from '@emotion/styled'; import {theme} from '../theme'; -import {tableContextMenuFactory} from './TableContextMenu'; +import {tableContextMenuFactory} from './PowerSearchTableContextMenu'; import {Menu, Switch, InputRef, Typography} from 'antd'; import {CoffeeOutlined, SearchOutlined, PushpinFilled} from '@ant-design/icons'; import {useAssertStableRef} from '../../utils/useAssertStableRef'; @@ -55,7 +55,6 @@ import { DataSource, _DataSourceView, } from 'flipper-plugin-core'; -import {HighlightProvider} from '../Highlight'; import {useLatestRef} from '../../utils/useLatestRef'; import {PowerSearch, OperatorConfig} from '../PowerSearch'; import {powerSearchExampleConfig} from '../PowerSearch/PowerSearchExampleConfig'; @@ -193,7 +192,7 @@ export function DataTable( (props.tableManagerRef as MutableRefObject).current = tableManager; } - const {columns, selection, sorting} = tableState; + const {columns, selection, searchExpression, sorting} = tableState; const latestSelectionRef = useLatestRef(selection); const latestOnSelectRef = useLatestRef(onSelect); @@ -349,19 +348,9 @@ export function DataTable( case 'Escape': tableManager.clearSelection(); break; - case 't': - if (controlPressed) { - tableManager.toggleSearchValue(); - } - break; - case 'H': - tableManager.toggleHighlightSearch(); - break; case 'f': if (controlPressed && searchInputRef?.current) { searchInputRef?.current.focus(); - tableManager.showSearchDropdown(true); - tableManager.setShowNumberedHistory(true); } break; default: @@ -380,13 +369,7 @@ export function DataTable( tableState.selection.current >= 0 ? dataView.getEntry(tableState.selection.current) : null; - dataView.setFilter( - computeDataTableFilter( - tableState.searchValue, - tableState.useRegex, - tableState.columns, - ), - ); + dataView.setFilter(computeDataTableFilter(tableState.searchExpression, {})); dataView.setFilterExpections( tableState.filterExceptions as T[keyof T][] | undefined, ); @@ -431,8 +414,7 @@ export function DataTable( // 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 [ - tableState.searchValue, - tableState.useRegex, + tableState.searchExpression, // eslint-disable-next-line react-hooks/exhaustive-deps ...tableState.columns.map((c) => c.filters), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -549,10 +531,8 @@ export function DataTable( () => tableContextMenuFactory( dataView, - dispatch, + dispatch as any, selection, - tableState.highlightSearchSetting, - tableState.filterSearchHistory, tableState.columns, visibleColumns, onCopyRows, @@ -562,8 +542,6 @@ export function DataTable( [ dataView, selection, - tableState.highlightSearchSetting, - tableState.filterSearchHistory, tableState.columns, visibleColumns, onCopyRows, @@ -600,21 +578,12 @@ export function DataTable( const header = ( {props.enableSearchbar && ( - // {}} + initialSearchExpression={searchExpression} + onSearchExpressionChange={(newSearchExpression) => { + tableManager.setSearchExpression(newSearchExpression); + }} /> )} @@ -698,18 +667,6 @@ export function DataTable( } const mainPanel = ( - - {mainSection} - {props.enableAutoScroll && ( 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, +}; + +type PersistedState = { + /** Active search value */ + searchExpression?: SearchExpressionTerm[]; + /** 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; +}; + +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<'setSearchExpression', {searchExpression?: SearchExpressionTerm[]}> + | 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', {}> + | Action<'setFilterExceptions', {exceptions: string[] | undefined}> + | Action<'appliedInitialScroll'> + | Action<'toggleAutoScroll'> + | Action<'setAutoScroll', {autoScroll: boolean}> + | Action<'toggleSideBySide'> + | Action<'showSearchDropdown', {show: boolean}> + | Action<'setShowNumberedHistory', {showNumberedHistory: boolean}>; + +type DataManagerConfig = { + dataSource: DataSource; + dataView: _DataSourceView; + 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; + autoScroll: boolean; + searchExpression?: SearchExpressionTerm[]; + filterExceptions: string[] | undefined; + sideBySide: boolean; +}; + +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.searchExpression = undefined; + draft.selection = castDraft(emptySelection); + draft.filterExceptions = undefined; + break; + } + case 'resetFilters': { + draft.columns.forEach((c) => + c.filters?.forEach((f) => (f.enabled = false)), + ); + draft.searchExpression = undefined; + draft.filterExceptions = undefined; + 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 'setSearchExpression': { + draft.searchExpression = action.searchExpression; + draft.filterExceptions = undefined; + 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 'appliedInitialScroll': { + draft.initialOffset = 0; + break; + } + case 'toggleAutoScroll': { + draft.autoScroll = !draft.autoScroll; + break; + } + case 'setAutoScroll': { + draft.autoScroll = action.autoScroll; + break; + } + case 'toggleSideBySide': { + draft.sideBySide = !draft.sideBySide; + break; + } + case 'setFilterExceptions': { + draft.filterExceptions = action.exceptions; + 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; + setSearchExpression(searchExpression: SearchExpressionTerm[]): void; + dataView: _DataSourceView; + stateRef: RefObject>>; + toggleSideBySide(): void; + setFilterExceptions(exceptions: string[] | undefined): void; +}; + +export function createDataTableManager( + dataView: _DataSourceView, + 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(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}); + }, + setSearchExpression(searchExpression) { + getFlipperLib().logger.track('usage', 'data-table:power-search:search'); + dispatch({type: 'setSearchExpression', searchExpression}); + }, + toggleSideBySide() { + dispatch({type: 'toggleSideBySide'}); + }, + setFilterExceptions(exceptions: string[] | undefined) { + dispatch({type: 'setFilterExceptions', exceptions}); + }, + dataView, + stateRef, + }; +} + +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, + searchExpression: prefs?.searchExpression, + filterExceptions: undefined, + autoScroll: prefs?.autoScroll ?? config.autoScroll ?? false, + sideBySide: false, + }; + // @ts-ignore + res.config[immerable] = false; // optimization: never proxy anything in config + Object.freeze(res.config); + return res; +} + +export function getSelectedItem( + dataView: _DataSourceView, + selection: Selection, +): T | undefined { + return selection.current < 0 ? undefined : dataView.get(selection.current); +} + +export function getSelectedItems( + dataView: _DataSourceView, + 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, + scrollOffset: number, +) { + if (!state.config.scope || !state.config.enablePersistSettings) { + return; + } + const prefs: PersistedState = { + searchExpression: state.searchExpression, + 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, + visible: c.visible, + })), + scrollOffset, + autoScroll: state.autoScroll, + }; + 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( + searchExpression: SearchExpressionTerm[] | undefined, + powerSearchProcessors: PowerSearchOperatorProcessorConfig, +) { + return function dataTableFilter(item: any) { + if (!searchExpression) { + return true; + } + return searchExpression.some((searchTerm) => { + const value = getValueAtPath(item, searchTerm.field.key); + if (!value) { + console.warn( + 'computeDataTableFilter -> value at searchTerm.field.key is not recognized', + searchTerm, + item, + ); + return true; + } + + const processor = powerSearchProcessors[searchTerm.operator.key]; + if (!processor) { + console.warn( + 'computeDataTableFilter -> processor at searchTerm.operator.key is not recognized', + searchTerm, + powerSearchProcessors, + ); + return true; + } + + return processor(searchTerm.operator, value); + }); + }; +} + +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; +} diff --git a/desktop/flipper-plugin/src/ui/data-table/PowerSearchTableContextMenu.tsx b/desktop/flipper-plugin/src/ui/data-table/PowerSearchTableContextMenu.tsx new file mode 100644 index 000000000..e31dd454d --- /dev/null +++ b/desktop/flipper-plugin/src/ui/data-table/PowerSearchTableContextMenu.tsx @@ -0,0 +1,222 @@ +/** + * 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 {CopyOutlined, FilterOutlined, TableOutlined} from '@ant-design/icons'; +import {Checkbox, Menu} from 'antd'; +import { + DataTableDispatch, + getSelectedItem, + getSelectedItems, + getValueAtPath, + Selection, +} from './DataTableManager'; +import React from 'react'; +import { + _tryGetFlipperLibImplementation, + _DataSourceView, +} from 'flipper-plugin-core'; +import {DataTableColumn} from './DataTable'; +import {toFirstUpper} from '../../utils/toFirstUpper'; +import {renderColumnValue} from './TableRow'; +import {textContent} from '../../utils/textContent'; + +const {Item, SubMenu} = Menu; + +export function tableContextMenuFactory( + dataView: _DataSourceView, + dispatch: DataTableDispatch, + selection: Selection, + columns: DataTableColumn[], + visibleColumns: DataTableColumn[], + onCopyRows: ( + rows: T[], + visibleColumns: DataTableColumn[], + ) => string = defaultOnCopyRows, + onContextMenu?: (selection: undefined | T) => React.ReactElement, + sideBySideOption?: React.ReactElement, +) { + const lib = _tryGetFlipperLibImplementation(); + if (!lib) { + return ( + + Menu not ready + + ); + } + const hasSelection = selection.items.size > 0 ?? false; + return ( + + {onContextMenu + ? onContextMenu(getSelectedItem(dataView, selection)) + : null} + } + disabled={!hasSelection}> + {visibleColumns.map((column, idx) => ( + { + dispatch({ + type: 'setColumnFilterFromSelection', + column: column.key, + }); + }}> + {friendlyColumnTitle(column)} + + ))} + + } + disabled={!hasSelection}> + { + const items = getSelectedItems(dataView, selection); + if (items.length) { + lib.writeTextToClipboard(onCopyRows(items, visibleColumns)); + } + }}> + Copy row(s) + + {lib.isFB && ( + { + const items = getSelectedItems(dataView, selection); + if (items.length) { + lib.createPaste(onCopyRows(items, visibleColumns)); + } + }}> + Create paste + + )} + { + const items = getSelectedItems(dataView, selection); + if (items.length) { + lib.writeTextToClipboard(rowsToJson(items)); + } + }}> + Copy row(s) (JSON) + + {lib.isFB && ( + { + const items = getSelectedItems(dataView, selection); + if (items.length) { + lib.createPaste(rowsToJson(items)); + } + }}> + Create paste (JSON) + + )} + + + } + disabled={!hasSelection}> + {visibleColumns.map((column, idx) => ( + { + const items = getSelectedItems(dataView, selection); + if (items.length) { + lib.writeTextToClipboard( + items + .map((item) => '' + getValueAtPath(item, column.key)) + .join('\n'), + ); + } + }}> + {friendlyColumnTitle(column)} + + ))} + + + + {columns.map((column, idx) => ( + + { + e.stopPropagation(); + e.preventDefault(); + dispatch({type: 'toggleColumnVisibility', column: column.key}); + }}> + {friendlyColumnTitle(column)} + + + ))} + + { + dispatch({type: 'resetFilters'}); + }}> + Reset filters + + { + dispatch({type: 'reset'}); + }}> + Reset view + + + + { + dispatch({type: 'clearSearchHistory'}); + }}> + Clear search history + + + {sideBySideOption} + + ); +} + +function friendlyColumnTitle(column: DataTableColumn): string { + const name = column.title || column.key; + return toFirstUpper(name); +} + +function defaultOnCopyRows( + items: T[], + visibleColumns: DataTableColumn[], +) { + return ( + visibleColumns.map(friendlyColumnTitle).join('\t') + + '\n' + + items + .map((row, idx) => + visibleColumns + .map((col) => textContent(renderColumnValue(col, row, true, idx))) + .join('\t'), + ) + .join('\n') + ); +} + +function rowsToJson(items: T[]) { + return JSON.stringify(items.length > 1 ? items : items[0], null, 2); +}