Files
flipper/desktop/flipper-plugin/src/ui/data-table/DataTable.tsx
Michel Weststrate f2bf48d4e4 DataTable delighter: Introduce search history
Summary:
Getting the behavior exactly right was tricky, now settled on the following:

* Don't automatically show the search history (the default) but introduce an explicit button to toggle it, as opening it by default was pretty obtrusive in testing
* Items are added to the history when using return / explicitly clicking search, to only get "clean" entries into the history, and not half complete searches. Needing to press enter might be to subtle since datatable will also search without that, but not searching on keypress felt as a regression as well.
* Introduced a menu item for clearing the search history
* Search history is persisted like search filters.

Yay to Antd's AutoComplete, which is really straightforward and cleanly composes with Input.Search.

Changelog: DataTable will now keep a history of search items when hitting <return> to search. Use the history button to bring up the history.

Reviewed By: aigoncharov

Differential Revision: D36736821

fbshipit-source-id: 8d18b85308a39bd1644057371040855d199545c7
2022-06-07 04:04:01 -07:00

732 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 React, {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
RefObject,
MutableRefObject,
CSSProperties,
useEffect,
useReducer,
} from 'react';
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
import {Layout} from '../Layout';
import {TableHead} from './TableHead';
import {Percentage} from '../../utils/widthUtils';
import {
DataSourceRendererVirtual,
DataSourceRendererStatic,
DataSource,
DataSourceVirtualizer,
} from '../../data-source/index';
import {
computeDataTableFilter,
createDataTableManager,
createInitialState,
DataManagerState,
DataTableManager,
dataTableManagerReducer,
DataTableReducer,
getSelectedItem,
getSelectedItems,
savePreferences,
} from './DataTableManager';
import {TableSearch} from './TableSearch';
import styled from '@emotion/styled';
import {theme} from '../theme';
import {tableContextMenuFactory} from './TableContextMenu';
import {Typography} from 'antd';
import {CoffeeOutlined, SearchOutlined, PushpinFilled} from '@ant-design/icons';
import {useAssertStableRef} from '../../utils/useAssertStableRef';
import {Formatter} from '../DataFormatter';
import {usePluginInstanceMaybe} from '../../plugin/PluginContext';
import {debounce} from 'lodash';
import {useInUnitTest} from '../../utils/useInUnitTest';
import {createDataSource} from '../../state/createDataSource';
type DataTableBaseProps<T = any> = {
columns: DataTableColumn<T>[];
enableSearchbar?: boolean;
enableAutoScroll?: boolean;
enableHorizontalScroll?: boolean;
enableColumnHeaders?: boolean;
enableMultiSelect?: boolean;
enableContextMenu?: boolean;
enablePersistSettings?: boolean;
// if set (the default) will grow and become scrollable. Otherwise will use natural size
scrollable?: boolean;
extraActions?: React.ReactElement;
onSelect?(record: T | undefined, records: T[]): void;
onRowStyle?(record: T): CSSProperties | undefined;
tableManagerRef?: RefObject<DataTableManager<T> | undefined>; // Actually we want a MutableRefObject, but that is not what React.createRef() returns, and we don't want to put the burden on the plugin dev to cast it...
onCopyRows?(records: T[]): string;
onContextMenu?: (selection: undefined | T) => React.ReactElement;
onRenderEmpty?:
| null
| ((dataSource?: DataSource<T, T[keyof T]>) => React.ReactElement);
};
export type ItemRenderer<T> = (
item: T,
selected: boolean,
index: number,
) => React.ReactNode;
type DataTableInput<T = any> =
| {
dataSource: DataSource<T, T[keyof T]>;
records?: undefined;
recordsKey?: undefined;
}
| {
records: readonly T[];
recordsKey?: keyof T;
dataSource?: undefined;
};
export type DataTableColumn<T = any> = {
//this can be a dotted path into a nest objects. e.g foo.bar
key: keyof T & string;
// possible future extension: getValue(row) (and free-form key) to support computed columns
onRender?: (row: T, selected: boolean, index: number) => React.ReactNode;
formatters?: Formatter[] | Formatter;
title?: string;
width?: number | Percentage | undefined; // undefined: use all remaining width
wrap?: boolean;
align?: 'left' | 'right' | 'center';
visible?: boolean;
filters?: {
label: string;
value: string;
enabled: boolean;
predefined?: boolean;
}[];
inversed?: boolean;
};
export interface TableRowRenderContext<T = any> {
columns: DataTableColumn<T>[];
onMouseEnter(
e: React.MouseEvent<HTMLDivElement>,
item: T,
itemId: number,
): void;
onMouseDown(
e: React.MouseEvent<HTMLDivElement>,
item: T,
itemId: number,
): void;
onRowStyle?(item: T): React.CSSProperties | undefined;
onContextMenu?(): React.ReactElement;
}
export type DataTableProps<T> = DataTableInput<T> & DataTableBaseProps<T>;
export function DataTable<T extends object>(
props: DataTableProps<T>,
): React.ReactElement {
const {onRowStyle, onSelect, onCopyRows, onContextMenu} = props;
const dataSource = normalizeDataSourceInput(props);
useAssertStableRef(dataSource, 'dataSource');
useAssertStableRef(onRowStyle, 'onRowStyle');
useAssertStableRef(props.onSelect, 'onRowSelect');
useAssertStableRef(props.columns, 'columns');
useAssertStableRef(onCopyRows, 'onCopyRows');
useAssertStableRef(onContextMenu, 'onContextMenu');
const isUnitTest = useInUnitTest();
// eslint-disable-next-line
const scope = isUnitTest ? '' : usePluginInstanceMaybe()?.definition.id ?? '';
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
const [tableState, dispatch] = useReducer(
dataTableManagerReducer as DataTableReducer<T>,
undefined,
() =>
createInitialState({
dataSource,
defaultColumns: props.columns,
onSelect,
scope,
virtualizerRef,
autoScroll: props.enableAutoScroll,
enablePersistSettings: props.enablePersistSettings,
}),
);
const stateRef = useRef(tableState);
stateRef.current = tableState;
const lastOffset = useRef(0);
const dragging = useRef(false);
const [tableManager] = useState(() =>
createDataTableManager(dataSource, dispatch, stateRef),
);
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<any>).current = tableManager;
}
const {columns, selection, searchValue, sorting} = tableState;
const visibleColumns = useMemo(
() => columns.filter((column) => column.visible),
[columns],
);
const renderingConfig = useMemo<TableRowRenderContext<T>>(() => {
let startIndex = 0;
return {
columns: visibleColumns,
onMouseEnter(e, _item, index) {
if (dragging.current && e.buttons === 1 && props.enableMultiSelect) {
// by computing range we make sure no intermediate items are missed when scrolling fast
tableManager.addRangeToSelection(startIndex, index);
}
},
onMouseDown(e, _item, index) {
if (!props.enableMultiSelect && e.buttons > 1) {
tableManager.selectItem(index, false, true);
return;
}
if (!dragging.current) {
if (e.buttons > 1) {
// for right click we only want to add if needed, not deselect
tableManager.addRangeToSelection(index, index, false);
} else if (e.ctrlKey || e.metaKey) {
tableManager.addRangeToSelection(index, index, true);
} else if (e.shiftKey) {
tableManager.selectItem(index, true, true);
} else {
tableManager.selectItem(index, false, true);
}
dragging.current = true;
startIndex = index;
function onStopDragSelecting() {
dragging.current = false;
document.removeEventListener('mouseup', onStopDragSelecting);
}
document.addEventListener('mouseup', onStopDragSelecting);
}
},
onRowStyle,
onContextMenu: props.enableContextMenu
? () => {
// using a ref keeps the config stable, so that a new context menu doesn't need
// all rows to be rerendered, but rather shows it conditionally
return contextMenuRef.current?.()!;
}
: undefined,
};
}, [
visibleColumns,
tableManager,
onRowStyle,
props.enableContextMenu,
props.enableMultiSelect,
]);
const itemRenderer = useCallback(
function itemRenderer(
record: T,
index: number,
renderContext: TableRowRenderContext<T>,
) {
return (
<TableRow
key={index}
config={renderContext}
record={record}
itemIndex={index}
highlighted={
index === selection.current || selection.items.has(index)
}
style={onRowStyle?.(record)}
/>
);
},
[selection, onRowStyle],
);
/**
* Keyboard / selection handling
*/
const onKeyDown = useCallback(
(e: React.KeyboardEvent<any>) => {
let handled = true;
const shiftPressed = e.shiftKey;
const outputSize = dataSource.view.size;
const windowSize = props.scrollable
? virtualizerRef.current?.virtualItems.length ?? 0
: dataSource.view.size;
if (!windowSize) {
return;
}
switch (e.key) {
case 'ArrowUp':
tableManager.selectItem(
(idx) => (idx > 0 ? idx - 1 : 0),
shiftPressed,
);
break;
case 'ArrowDown':
tableManager.selectItem(
(idx) => (idx < outputSize - 1 ? idx + 1 : idx),
shiftPressed,
);
break;
case 'Home':
tableManager.selectItem(0, shiftPressed);
break;
case 'End':
tableManager.selectItem(outputSize - 1, shiftPressed);
break;
case ' ': // yes, that is a space
case 'PageDown':
tableManager.selectItem(
(idx) => Math.min(outputSize - 1, idx + windowSize - 1),
shiftPressed,
);
break;
case 'PageUp':
tableManager.selectItem(
(idx) => Math.max(0, idx - windowSize + 1),
shiftPressed,
);
break;
case 'Escape':
tableManager.clearSelection();
break;
case 'Control':
tableManager.toggleSearchValue();
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
},
[dataSource, tableManager, props.scrollable],
);
const [setFilter] = useState(() => (tableState: DataManagerState<T>) => {
const selectedEntry =
tableState.selection.current >= 0
? dataSource.view.getEntry(tableState.selection.current)
: null;
dataSource.view.setFilter(
computeDataTableFilter(
tableState.searchValue,
tableState.useRegex,
tableState.columns,
),
);
// TODO: in the future setFilter effects could be async, at the moment it isn't,
// so we can safely assume the internal state of the dataSource.view is updated with the
// filter changes and try to find the same entry back again
if (selectedEntry) {
const selectionIndex = dataSource.view.getViewIndexOfEntry(selectedEntry);
tableManager.selectItem(selectionIndex, false, false);
// we disable autoScroll as is it can accidentally be annoying if it was never turned off and
// filter causes items to not fill the available space
dispatch({type: 'setAutoScroll', autoScroll: false});
virtualizerRef.current?.scrollToIndex(selectionIndex, {align: 'center'});
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(selectionIndex, {
align: 'center',
});
}, 0);
}
// TODO: could do the same for multiselections, doesn't seem to be requested so far
});
const [debouncedSetFilter] = useState(() => {
// we don't want to trigger filter changes too quickly, as they can be pretty expensive
// and would block the user from entering text in the search bar for example
// (and in the future would really benefit from concurrent mode here :))
// leading is set to true so that an initial filter is immediately applied and a flash of wrong content is prevented
// this also makes clear act faster
return isUnitTest ? setFilter : debounce(setFilter, 250);
});
useEffect(
function updateFilter() {
if (!dataSource.view.isFiltered) {
setFilter(tableState);
} else {
debouncedSetFilter(tableState);
}
},
// 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
[
tableState.searchValue,
tableState.useRegex,
// eslint-disable-next-line react-hooks/exhaustive-deps
...tableState.columns.map((c) => c.filters),
// eslint-disable-next-line react-hooks/exhaustive-deps
...tableState.columns.map((c) => c.inversed),
],
);
useEffect(
function updateSorting() {
if (tableState.sorting === undefined) {
dataSource.view.setSortBy(undefined);
dataSource.view.setReversed(false);
} else {
dataSource.view.setSortBy(tableState.sorting.key);
dataSource.view.setReversed(tableState.sorting.direction === 'desc');
}
},
[dataSource, tableState.sorting],
);
const isMounted = useRef(false);
useEffect(
function triggerSelection() {
if (isMounted.current) {
onSelect?.(
getSelectedItem(dataSource, tableState.selection),
getSelectedItems(dataSource, tableState.selection),
);
}
isMounted.current = true;
},
[onSelect, dataSource, tableState.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)
useLayoutEffect(
function scrollSelectionIntoView() {
if (tableState.initialOffset) {
virtualizerRef.current?.scrollToOffset(tableState.initialOffset);
dispatch({
type: 'appliedInitialScroll',
});
} else if (selection && selection.current >= 0) {
dispatch({type: 'setAutoScroll', autoScroll: false});
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],
);
/** Range finder */
const [range, setRange] = useState('');
const hideRange = useRef<any>();
const onRangeChange = useCallback(
(start: number, end: number, total: number, offset) => {
setRange(`${start} - ${end} / ${total}`);
lastOffset.current = offset;
clearTimeout(hideRange.current!);
hideRange.current = setTimeout(() => {
setRange('');
}, 1000);
},
[],
);
const onUpdateAutoScroll = useCallback(
(autoScroll: boolean) => {
if (props.enableAutoScroll) {
dispatch({type: 'setAutoScroll', autoScroll});
}
},
[props.enableAutoScroll],
);
/** Context menu */
const contexMenu = isUnitTest
? undefined
: // eslint-disable-next-line
useCallback(
() =>
tableContextMenuFactory(
dataSource,
dispatch,
selection,
tableState.columns,
visibleColumns,
onCopyRows,
onContextMenu,
),
[
dataSource,
dispatch,
selection,
tableState.columns,
visibleColumns,
onCopyRows,
onContextMenu,
],
);
const contextMenuRef = useRef(contexMenu);
contextMenuRef.current = contexMenu;
useEffect(function initialSetup() {
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.view.reset();
// clean ref
if (props.tableManagerRef) {
(props.tableManagerRef as MutableRefObject<any>).current = undefined;
}
};
// one-time setup and cleanup effect, everything in here is asserted to be stable:
// dataSource, tableManager, tableManagerRef
// eslint-disable-next-line
}, []);
const header = (
<Layout.Container>
{props.enableSearchbar && (
<TableSearch
searchValue={searchValue}
useRegex={tableState.useRegex}
dispatch={dispatch as any}
searchHistory={tableState.searchHistory}
contextMenu={props.enableContextMenu ? contexMenu : undefined}
extraActions={props.extraActions}
/>
)}
</Layout.Container>
);
const columnHeaders = (
<Layout.Container>
{props.enableColumnHeaders && (
<TableHead
visibleColumns={visibleColumns}
dispatch={dispatch as any}
sorting={sorting}
scrollbarSize={
props.scrollable
? 0
: 15 /* width on MacOS: TODO, determine dynamically */
}
/>
)}
</Layout.Container>
);
const emptyRenderer =
props.onRenderEmpty === undefined
? createDefaultEmptyRenderer(tableManager)
: props.onRenderEmpty;
let mainSection: JSX.Element;
if (props.scrollable) {
const dataSourceRenderer = (
<DataSourceRendererVirtual<T, TableRowRenderContext<T>>
dataSource={dataSource}
autoScroll={tableState.autoScroll && !dragging.current}
useFixedRowHeight={!tableState.usesWrapping}
defaultRowHeight={DEFAULT_ROW_HEIGHT}
context={renderingConfig}
itemRenderer={itemRenderer}
onKeyDown={onKeyDown}
virtualizerRef={virtualizerRef}
onRangeChange={onRangeChange}
onUpdateAutoScroll={onUpdateAutoScroll}
emptyRenderer={emptyRenderer}
/>
);
mainSection = props.enableHorizontalScroll ? (
<Layout.Top>
{header}
<Layout.ScrollContainer horizontal vertical={false}>
<Layout.Top>
{columnHeaders}
{dataSourceRenderer}
</Layout.Top>
</Layout.ScrollContainer>
</Layout.Top>
) : (
<Layout.Top>
<div>
{header}
{columnHeaders}
</div>
{dataSourceRenderer}
</Layout.Top>
);
} else {
mainSection = (
<Layout.Container>
{header}
{columnHeaders}
<DataSourceRendererStatic<T, TableRowRenderContext<T>>
dataSource={dataSource}
useFixedRowHeight={!tableState.usesWrapping}
defaultRowHeight={DEFAULT_ROW_HEIGHT}
context={renderingConfig}
itemRenderer={itemRenderer}
onKeyDown={onKeyDown}
emptyRenderer={emptyRenderer}
/>
</Layout.Container>
);
}
return (
<Layout.Container grow={props.scrollable}>
{mainSection}
{props.enableAutoScroll && (
<AutoScroller>
<PushpinFilled
style={{
color: tableState.autoScroll ? theme.successColor : undefined,
}}
onClick={() => {
dispatch({type: 'toggleAutoScroll'});
}}
/>
</AutoScroller>
)}
{range && !isUnitTest && <RangeFinder>{range}</RangeFinder>}
</Layout.Container>
);
}
DataTable.defaultProps = {
scrollable: true,
enableSearchbar: true,
enableAutoScroll: false,
enableHorizontalScroll: true,
enableColumnHeaders: true,
enableMultiSelect: true,
enableContextMenu: true,
enablePersistSettings: true,
onRenderEmpty: undefined,
} as Partial<DataTableProps<any>>;
/* eslint-disable react-hooks/rules-of-hooks */
function normalizeDataSourceInput<T>(
props: DataTableInput<T>,
): DataSource<T, T[keyof T] | never> {
if (props.dataSource) {
return props.dataSource;
}
if (props.records) {
const [dataSource] = useState(() =>
createDataSource(props.records, {key: props.recordsKey}),
);
useEffect(() => {
syncRecordsToDataSource(dataSource, props.records);
}, [dataSource, props.records]);
return dataSource;
}
throw new Error(
`Either the 'dataSource' or 'records' prop should be provided to DataTable`,
);
}
/* eslint-enable */
function syncRecordsToDataSource<T>(
ds: DataSource<T, T[keyof T] | never>,
records: readonly T[],
) {
const startTime = Date.now();
ds.clear();
// TODO: optimize in the case we're only dealing with appends or replacements
records.forEach((r) => ds.append(r));
const duration = Math.abs(Date.now() - startTime);
if (duration > 50 || records.length > 500) {
console.warn(
"The 'records' props is only intended to be used on small datasets. Please use a 'dataSource' instead. See createDataSource for details: https://fbflipper.com/docs/extending/flipper-plugin#createdatasource",
);
}
}
function createDefaultEmptyRenderer<T>(dataTableManager?: DataTableManager<T>) {
return (dataSource?: DataSource<T, T[keyof T]>) => (
<EmptyTable dataSource={dataSource} dataManager={dataTableManager} />
);
}
function EmptyTable<T>({
dataSource,
dataManager,
}: {
dataSource?: DataSource<T, T[keyof T]>;
dataManager?: DataTableManager<T>;
}) {
const resetFilters = useCallback(() => {
dataManager?.resetFilters();
}, [dataManager]);
return (
<Layout.Container
center
style={{width: '100%', padding: 40, color: theme.textColorSecondary}}>
{dataSource?.size === 0 ? (
<>
<CoffeeOutlined style={{fontSize: '2em', margin: 8}} />
<Typography.Text type="secondary">No records yet</Typography.Text>
</>
) : (
<>
<SearchOutlined style={{fontSize: '2em', margin: 8}} />
<Typography.Text type="secondary">
No records match the current search / filter criteria.
</Typography.Text>
<Typography.Text>
<Typography.Link onClick={resetFilters}>
Reset filters
</Typography.Link>
</Typography.Text>
</>
)}
</Layout.Container>
);
}
const RangeFinder = styled.div({
backgroundColor: theme.backgroundWash,
position: 'absolute',
right: 64,
bottom: 20,
padding: '4px 8px',
color: theme.textColorSecondary,
fontSize: '0.8em',
});
const AutoScroller = styled.div({
backgroundColor: theme.backgroundWash,
position: 'absolute',
right: 40,
bottom: 20,
width: 24,
padding: '4px 8px',
color: theme.textColorSecondary,
fontSize: '0.8em',
});