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
This commit is contained in:
Michel Weststrate
2022-06-07 04:04:01 -07:00
committed by Facebook GitHub Bot
parent 36b78131b7
commit f2bf48d4e4
4 changed files with 113 additions and 42 deletions

View File

@@ -512,6 +512,7 @@ export function DataTable<T extends object>(
searchValue={searchValue} searchValue={searchValue}
useRegex={tableState.useRegex} useRegex={tableState.useRegex}
dispatch={dispatch as any} dispatch={dispatch as any}
searchHistory={tableState.searchHistory}
contextMenu={props.enableContextMenu ? contexMenu : undefined} contextMenu={props.enableContextMenu ? contexMenu : undefined}
extraActions={props.extraActions} extraActions={props.extraActions}
/> />

View File

@@ -28,6 +28,8 @@ const emptySelection: Selection = {
current: -1, current: -1,
}; };
const MAX_HISTORY = 1000;
type PersistedState = { type PersistedState = {
/** Active search value */ /** Active search value */
search: string; search: string;
@@ -43,6 +45,7 @@ type PersistedState = {
>[]; >[];
scrollOffset: number; scrollOffset: number;
autoScroll: boolean; autoScroll: boolean;
searchHistory: string[];
}; };
type Action<Name extends string, Args = {}> = {type: Name} & Args; type Action<Name extends string, Args = {}> = {type: Name} & Args;
@@ -58,7 +61,7 @@ type DataManagerActions<T> =
| Action<'sortColumn', {column: keyof T; direction: SortDirection}> | Action<'sortColumn', {column: keyof T; direction: SortDirection}>
/** Show / hide the given column */ /** Show / hide the given column */
| Action<'toggleColumnVisibility', {column: keyof T}> | Action<'toggleColumnVisibility', {column: keyof T}>
| Action<'setSearchValue', {value: string}> | Action<'setSearchValue', {value: string; addToHistory: boolean}>
| Action< | Action<
'selectItem', 'selectItem',
{ {
@@ -96,7 +99,8 @@ type DataManagerActions<T> =
| Action<'toggleUseRegex'> | Action<'toggleUseRegex'>
| Action<'toggleAutoScroll'> | Action<'toggleAutoScroll'>
| Action<'setAutoScroll', {autoScroll: boolean}> | Action<'setAutoScroll', {autoScroll: boolean}>
| Action<'toggleSearchValue'>; | Action<'toggleSearchValue'>
| Action<'clearSearchHistory'>;
type DataManagerConfig<T> = { type DataManagerConfig<T> = {
dataSource: DataSource<T, T[keyof T]>; dataSource: DataSource<T, T[keyof T]>;
@@ -116,11 +120,12 @@ export type DataManagerState<T> = {
columns: DataTableColumn[]; columns: DataTableColumn[];
sorting: Sorting<T> | undefined; sorting: Sorting<T> | undefined;
selection: Selection; selection: Selection;
searchValue: string;
useRegex: boolean; useRegex: boolean;
autoScroll: boolean; autoScroll: boolean;
searchValue: string;
/** Used to remember the record entry to lookup when user presses ctrl */ /** Used to remember the record entry to lookup when user presses ctrl */
previousSearchValue: string; previousSearchValue: string;
searchHistory: string[];
}; };
export type DataTableReducer<T> = Reducer< export type DataTableReducer<T> = Reducer<
@@ -173,6 +178,17 @@ export const dataTableManagerReducer = produce<
case 'setSearchValue': { case 'setSearchValue': {
draft.searchValue = action.value; draft.searchValue = action.value;
draft.previousSearchValue = ''; 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; break;
} }
case 'toggleSearchValue': { case 'toggleSearchValue': {
@@ -185,6 +201,10 @@ export const dataTableManagerReducer = produce<
} }
break; break;
} }
case 'clearSearchHistory': {
draft.searchHistory = [];
break;
}
case 'toggleUseRegex': { case 'toggleUseRegex': {
draft.useRegex = !draft.useRegex; draft.useRegex = !draft.useRegex;
break; break;
@@ -305,7 +325,7 @@ export type DataTableManager<T> = {
getSelectedItems(): readonly T[]; getSelectedItems(): readonly T[];
toggleColumnVisibility(column: keyof T): void; toggleColumnVisibility(column: keyof T): void;
sortColumn(column: keyof T, direction?: SortDirection): void; sortColumn(column: keyof T, direction?: SortDirection): void;
setSearchValue(value: string): void; setSearchValue(value: string, addToHistory?: boolean): void;
dataSource: DataSource<T, T[keyof T]>; dataSource: DataSource<T, T[keyof T]>;
toggleSearchValue(): void; toggleSearchValue(): void;
}; };
@@ -351,8 +371,8 @@ export function createDataTableManager<T>(
sortColumn(column, direction) { sortColumn(column, direction) {
dispatch({type: 'sortColumn', column, direction}); dispatch({type: 'sortColumn', column, direction});
}, },
setSearchValue(value) { setSearchValue(value, addToHistory = false) {
dispatch({type: 'setSearchValue', value}); dispatch({type: 'setSearchValue', value, addToHistory});
}, },
toggleSearchValue() { toggleSearchValue() {
dispatch({type: 'toggleSearchValue'}); dispatch({type: 'toggleSearchValue'});
@@ -399,6 +419,7 @@ export function createInitialState<T>(
: emptySelection, : emptySelection,
searchValue: prefs?.search ?? '', searchValue: prefs?.search ?? '',
previousSearchValue: '', previousSearchValue: '',
searchHistory: prefs?.searchHistory ?? [],
useRegex: prefs?.useRegex ?? false, useRegex: prefs?.useRegex ?? false,
autoScroll: prefs?.autoScroll ?? config.autoScroll ?? false, autoScroll: prefs?.autoScroll ?? config.autoScroll ?? false,
}; };
@@ -478,6 +499,7 @@ export function savePreferences(
})), })),
scrollOffset, scrollOffset,
autoScroll: state.autoScroll, autoScroll: state.autoScroll,
searchHistory: state.searchHistory,
}; };
localStorage.setItem(state.storageKey, JSON.stringify(prefs)); localStorage.setItem(state.storageKey, JSON.stringify(prefs));
} }

View File

@@ -177,6 +177,13 @@ export function tableContextMenuFactory<T>(
}}> }}>
Reset view Reset view
</Menu.Item> </Menu.Item>
<Menu.Item
key="clear history"
onClick={() => {
dispatch({type: 'clearSearchHistory'});
}}>
Clear search history
</Menu.Item>
</Menu> </Menu>
); );
} }

View File

@@ -7,9 +7,9 @@
* @format * @format
*/ */
import {MenuOutlined} from '@ant-design/icons'; import {HistoryOutlined, MenuOutlined} from '@ant-design/icons';
import {Button, Dropdown, Input} from 'antd'; import {Button, Dropdown, Input, AutoComplete} from 'antd';
import React, {memo, useCallback, useMemo} from 'react'; import React, {memo, useCallback, useMemo, useState} from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
@@ -20,18 +20,21 @@ export const TableSearch = memo(function TableSearch({
searchValue, searchValue,
useRegex, useRegex,
dispatch, dispatch,
searchHistory,
extraActions, extraActions,
contextMenu, contextMenu,
}: { }: {
searchValue: string; searchValue: string;
useRegex: boolean; useRegex: boolean;
dispatch: DataTableDispatch<any>; dispatch: DataTableDispatch<any>;
searchHistory: string[];
extraActions?: React.ReactElement; extraActions?: React.ReactElement;
contextMenu: undefined | (() => JSX.Element); contextMenu: undefined | (() => JSX.Element);
}) { }) {
const [showHistory, setShowHistory] = useState(false);
const onSearch = useCallback( const onSearch = useCallback(
(value: string) => { (value: string, addToHistory: boolean) => {
dispatch({type: 'setSearchValue', value}); dispatch({type: 'setSearchValue', value, addToHistory});
}, },
[dispatch], [dispatch],
); );
@@ -54,38 +57,72 @@ export const TableSearch = memo(function TableSearch({
} }
}, [useRegex, searchValue]); }, [useRegex, searchValue]);
const options = useMemo(
() => searchHistory.map((value) => ({label: value, value})),
[searchHistory],
);
return ( return (
<Searchbar gap> <Searchbar gap>
<Input.Search <AutoComplete
allowClear defaultOpen={false}
placeholder="Search..." open={showHistory}
onSearch={onSearch} options={options}
value={searchValue} filterOption={(inputValue, option) =>
suffix={ option!.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1
<RegexButton
size="small"
onClick={onToggleRegex}
style={
useRegex
? {
background: regexError
? theme.errorColor
: theme.successColor,
color: theme.white,
}
: {
color: theme.disabledColor,
}
}
type="default"
title={regexError || 'Search using Regex'}>
.*
</RegexButton>
} }
onChange={(e) => { onSelect={(value: string) => {
onSearch(e.target.value); setShowHistory(false);
onSearch(value, false);
}} }}
/> onDropdownVisibleChange={(open) => {
if (!open) {
setShowHistory(false);
}
}}>
<Input.Search
allowClear
placeholder="Search..."
value={searchValue}
suffix={
<>
{options.length ? (
<RegexButton
onClick={() => {
setShowHistory((v) => !v);
}}>
<HistoryOutlined />
</RegexButton>
) : null}
<RegexButton
size="small"
onClick={onToggleRegex}
style={
useRegex
? {
background: regexError
? theme.errorColor
: theme.successColor,
color: theme.white,
}
: {
color: theme.disabledColor,
}
}
type="default"
title={regexError || 'Search using Regex'}>
.*
</RegexButton>
</>
}
onChange={(e) => {
onSearch(e.target.value, false);
}}
onSearch={(value) => {
onSearch(value, true);
}}
/>
</AutoComplete>
{extraActions} {extraActions}
{contextMenu && ( {contextMenu && (
<Dropdown overlay={contextMenu} placement="bottomRight"> <Dropdown overlay={contextMenu} placement="bottomRight">
@@ -108,17 +145,21 @@ const Searchbar = styled(Layout.Horizontal)({
padding: `${theme.space.tiny}px ${theme.space.small}px`, padding: `${theme.space.tiny}px ${theme.space.small}px`,
background: 'transparent', background: 'transparent',
}, },
'> .ant-select': {
flex: 1,
},
}); });
const RegexButton = styled(Button)({ const RegexButton = styled(Button)({
padding: '0px !important', padding: '0px !important',
borderRadius: 4, borderRadius: 4,
marginRight: -6, // marginRight: -6,
marginLeft: 4, // marginLeft: 4,
lineHeight: '20px', lineHeight: '20px',
width: 20, width: 16,
height: 20, height: 20,
border: 'none', border: 'none',
color: theme.disabledColor,
'& :hover': { '& :hover': {
color: theme.primaryColor, color: theme.primaryColor,
}, },