Support RegEx search

Summary:
Changelog: Restored the possibility to use Regex in logs search

Fixes:
https://github.com/facebook/flipper/issues/2076
https://fb.workplace.com/groups/flipperfyi/permalink/912753022824327/

Reviewed By: passy

Differential Revision: D27188241

fbshipit-source-id: 38ae2972c7dd3dd5cf24df87535d5ad74598cd88
This commit is contained in:
Michel Weststrate
2021-03-19 08:57:24 -07:00
committed by Facebook GitHub Bot
parent 2ae7d13a64
commit c648c58825
4 changed files with 133 additions and 22 deletions

View File

@@ -249,19 +249,29 @@ export function DataTable<T extends object>(
// we don't want to trigger filter changes too quickly, as they can be pretty expensive // 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 would block the user from entering text in the search bar for example
// (and in the future would really benefit from concurrent mode here :)) // (and in the future would really benefit from concurrent mode here :))
const setFilter = (search: string, columns: DataTableColumn<T>[]) => { const setFilter = (
dataSource.view.setFilter(computeDataTableFilter(search, columns)); search: string,
useRegex: boolean,
columns: DataTableColumn<T>[],
) => {
dataSource.view.setFilter(
computeDataTableFilter(search, useRegex, columns),
);
}; };
return props._testHeight ? setFilter : debounce(setFilter, 250); return props._testHeight ? setFilter : debounce(setFilter, 250);
}); });
useEffect( useEffect(
function updateFilter() { function updateFilter() {
debouncedSetFilter(tableState.searchValue, tableState.columns); debouncedSetFilter(
tableState.searchValue,
tableState.useRegex,
tableState.columns,
);
}, },
// Important dep optimization: we don't want to recalc filters if just the width or visibility changes! // 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 // 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 // eslint-disable-next-line
[tableState.searchValue, ...tableState.columns.map((c) => c.filters)], [tableState.searchValue, tableState.useRegex, ...tableState.columns.map((c) => c.filters)],
); );
useEffect( useEffect(
@@ -367,6 +377,7 @@ export function DataTable<T extends object>(
<Layout.Container> <Layout.Container>
<TableSearch <TableSearch
searchValue={searchValue} searchValue={searchValue}
useRegex={tableState.useRegex}
dispatch={dispatch as any} dispatch={dispatch as any}
contextMenu={contexMenu} contextMenu={contexMenu}
extraActions={props.extraActions} extraActions={props.extraActions}

View File

@@ -32,6 +32,7 @@ const emptySelection: Selection = {
type PersistedState = { type PersistedState = {
/** Active search value */ /** Active search value */
search: string; search: string;
useRegex: boolean;
/** current selection, describes the index index in the datasources's current output (not window!) */ /** current selection, describes the index index in the datasources's current output (not window!) */
selection: {current: number; items: number[]}; selection: {current: number; items: number[]};
/** The currently applicable sorting, if any */ /** The currently applicable sorting, if any */
@@ -77,7 +78,8 @@ type DataManagerActions<T> =
| Action<'removeColumnFilter', {column: keyof T; index: number}> | Action<'removeColumnFilter', {column: keyof T; index: number}>
| Action<'toggleColumnFilter', {column: keyof T; index: number}> | Action<'toggleColumnFilter', {column: keyof T; index: number}>
| Action<'setColumnFilterFromSelection', {column: keyof T}> | Action<'setColumnFilterFromSelection', {column: keyof T}>
| Action<'appliedInitialScroll'>; | Action<'appliedInitialScroll'>
| Action<'toggleUseRegex'>;
type DataManagerConfig<T> = { type DataManagerConfig<T> = {
dataSource: DataSource<T>; dataSource: DataSource<T>;
@@ -96,6 +98,7 @@ type DataManagerState<T> = {
sorting: Sorting<T> | undefined; sorting: Sorting<T> | undefined;
selection: Selection; selection: Selection;
searchValue: string; searchValue: string;
useRegex: boolean;
}; };
export type DataTableReducer<T> = Reducer< export type DataTableReducer<T> = Reducer<
@@ -142,6 +145,10 @@ export const dataTableManagerReducer = produce(function <T>(
draft.searchValue = action.value; draft.searchValue = action.value;
break; break;
} }
case 'toggleUseRegex': {
draft.useRegex = !draft.useRegex;
break;
}
case 'selectItem': { case 'selectItem': {
const {nextIndex, addToSelection} = action; const {nextIndex, addToSelection} = action;
draft.selection = computeSetSelection( draft.selection = computeSetSelection(
@@ -301,6 +308,7 @@ export function createInitialState<T>(
} }
: emptySelection, : emptySelection,
searchValue: prefs?.search ?? '', searchValue: prefs?.search ?? '',
useRegex: prefs?.useRegex ?? false,
}; };
// @ts-ignore // @ts-ignore
res.config[immerable] = false; // optimization: never proxy anything in config res.config[immerable] = false; // optimization: never proxy anything in config
@@ -363,6 +371,7 @@ export function savePreferences(
} }
const prefs: PersistedState = { const prefs: PersistedState = {
search: state.searchValue, search: state.searchValue,
useRegex: state.useRegex,
selection: { selection: {
current: state.selection.current, current: state.selection.current,
items: Array.from(state.selection.items), items: Array.from(state.selection.items),
@@ -411,9 +420,11 @@ function computeInitialColumns(
export function computeDataTableFilter( export function computeDataTableFilter(
searchValue: string, searchValue: string,
useRegex: boolean,
columns: DataTableColumn[], columns: DataTableColumn[],
) { ) {
const searchString = searchValue.toLowerCase(); const searchString = searchValue.toLowerCase();
const searchRegex = useRegex ? safeCreateRegExp(searchValue) : undefined;
// the columns with an active filter are those that have filters defined, // the columns with an active filter are those that have filters defined,
// with at least one enabled // with at least one enabled
const filteringColumns = columns.filter((c) => const filteringColumns = columns.filter((c) =>
@@ -438,11 +449,21 @@ export function computeDataTableFilter(
} }
} }
return Object.values(item).some((v) => return Object.values(item).some((v) =>
String(v).toLowerCase().includes(searchString), 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( export function computeSetSelection(
base: Selection, base: Selection,
nextIndex: number | ((currentIndex: number) => number), nextIndex: number | ((currentIndex: number) => number),

View File

@@ -9,7 +9,7 @@
import {MenuOutlined} from '@ant-design/icons'; import {MenuOutlined} from '@ant-design/icons';
import {Button, Dropdown, Input} from 'antd'; import {Button, Dropdown, Input} from 'antd';
import React, {memo, useCallback} from 'react'; import React, {memo, useCallback, useMemo} from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import {Layout} from '../Layout'; import {Layout} from '../Layout';
@@ -18,11 +18,13 @@ import type {DataTableDispatch} from './DataTableManager';
export const TableSearch = memo(function TableSearch({ export const TableSearch = memo(function TableSearch({
searchValue, searchValue,
useRegex,
dispatch, dispatch,
extraActions, extraActions,
contextMenu, contextMenu,
}: { }: {
searchValue: string; searchValue: string;
useRegex: boolean;
dispatch: DataTableDispatch<any>; dispatch: DataTableDispatch<any>;
extraActions?: React.ReactElement; extraActions?: React.ReactElement;
contextMenu: undefined | (() => JSX.Element); contextMenu: undefined | (() => JSX.Element);
@@ -33,6 +35,25 @@ export const TableSearch = memo(function TableSearch({
}, },
[dispatch], [dispatch],
); );
const onToggleRegex = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
dispatch({type: 'toggleUseRegex'});
},
[dispatch],
);
const regexError = useMemo(() => {
if (!useRegex || !searchValue) {
return;
}
try {
new RegExp(searchValue);
} catch (e) {
return '' + e;
}
}, [useRegex, searchValue]);
return ( return (
<Searchbar gap> <Searchbar gap>
<Input.Search <Input.Search
@@ -40,6 +61,27 @@ export const TableSearch = memo(function TableSearch({
placeholder="Search..." placeholder="Search..."
onSearch={onSearch} onSearch={onSearch}
value={searchValue} value={searchValue}
suffix={
<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) => { onChange={(e) => {
onSearch(e.target.value); onSearch(e.target.value);
}} }}
@@ -59,8 +101,25 @@ export const TableSearch = memo(function TableSearch({
const Searchbar = styled(Layout.Horizontal)({ const Searchbar = styled(Layout.Horizontal)({
backgroundColor: theme.backgroundWash, backgroundColor: theme.backgroundWash,
padding: theme.space.small, padding: theme.space.small,
'.ant-input-affix-wrapper': {
height: 32,
},
'.ant-btn': { '.ant-btn': {
padding: `${theme.space.tiny}px ${theme.space.small}px`, padding: `${theme.space.tiny}px ${theme.space.small}px`,
background: 'transparent', background: 'transparent',
}, },
}); });
const RegexButton = styled(Button)({
padding: '0px !important',
borderRadius: 4,
marginRight: -6,
marginLeft: 4,
lineHeight: '20px',
width: 20,
height: 20,
border: 'none',
'& :hover': {
color: theme.primaryColor,
},
});

View File

@@ -307,9 +307,9 @@ test('compute filters', () => {
const data = [coffee, espresso, meet]; const data = [coffee, espresso, meet];
// results in empty filter // results in empty filter
expect(computeDataTableFilter('', [])).toBeUndefined(); expect(computeDataTableFilter('', false, [])).toBeUndefined();
expect( expect(
computeDataTableFilter('', [ computeDataTableFilter('', false, [
{ {
key: 'title', key: 'title',
filters: [ filters: [
@@ -324,32 +324,52 @@ test('compute filters', () => {
).toBeUndefined(); ).toBeUndefined();
{ {
const filter = computeDataTableFilter('tEsT', [])!; const filter = computeDataTableFilter('tEsT', false, [])!;
expect(data.filter(filter)).toEqual([]); expect(data.filter(filter)).toEqual([]);
} }
{ {
const filter = computeDataTableFilter('EE', [])!; const filter = computeDataTableFilter('EE', false, [])!;
expect(data.filter(filter)).toEqual([coffee, meet]); expect(data.filter(filter)).toEqual([coffee, meet]);
} }
{ {
const filter = computeDataTableFilter('D', [])!; const filter = computeDataTableFilter('D', false, [])!;
expect(data.filter(filter)).toEqual([coffee]); expect(data.filter(filter)).toEqual([coffee]);
} }
{ {
const filter = computeDataTableFilter('true', [])!; // regex, positive (mind the double escaping of \\b)
const filter = computeDataTableFilter('..t', true, [])!;
expect(data.filter(filter)).toEqual([meet]);
}
{
// regex, words with 6 chars
const filter = computeDataTableFilter('\\w{6}', true, [])!;
expect(data.filter(filter)).toEqual([coffee, espresso]);
}
{
// no match
const filter = computeDataTableFilter('\\w{18}', true, [])!;
expect(data.filter(filter)).toEqual([]);
}
{
// invalid regex
const filter = computeDataTableFilter('bla/[', true, [])!;
expect(data.filter(filter)).toEqual([]);
}
{
const filter = computeDataTableFilter('true', false, [])!;
expect(data.filter(filter)).toEqual([coffee]); expect(data.filter(filter)).toEqual([coffee]);
} }
{ {
const filter = computeDataTableFilter('false', [])!; const filter = computeDataTableFilter('false', false, [])!;
expect(data.filter(filter)).toEqual([espresso, meet]); expect(data.filter(filter)).toEqual([espresso, meet]);
} }
{ {
const filter = computeDataTableFilter('EE', [ const filter = computeDataTableFilter('EE', false, [
{ {
key: 'level', key: 'level',
filters: [ filters: [
@@ -364,7 +384,7 @@ test('compute filters', () => {
expect(data.filter(filter)).toEqual([meet]); expect(data.filter(filter)).toEqual([meet]);
} }
{ {
const filter = computeDataTableFilter('EE', [ const filter = computeDataTableFilter('EE', false, [
{ {
key: 'level', key: 'level',
filters: [ filters: [
@@ -384,7 +404,7 @@ test('compute filters', () => {
expect(data.filter(filter)).toEqual([coffee, meet]); expect(data.filter(filter)).toEqual([coffee, meet]);
} }
{ {
const filter = computeDataTableFilter('', [ const filter = computeDataTableFilter('', false, [
{ {
key: 'level', key: 'level',
filters: [ filters: [
@@ -404,7 +424,7 @@ test('compute filters', () => {
expect(data.filter(filter)).toEqual([coffee, espresso]); expect(data.filter(filter)).toEqual([coffee, espresso]);
} }
{ {
const filter = computeDataTableFilter('', [ const filter = computeDataTableFilter('', false, [
{ {
key: 'done', key: 'done',
filters: [ filters: [
@@ -420,7 +440,7 @@ test('compute filters', () => {
} }
{ {
// nothing selected anything will not filter anything out for that column // nothing selected anything will not filter anything out for that column
const filter = computeDataTableFilter('', [ const filter = computeDataTableFilter('', false, [
{ {
key: 'level', key: 'level',
filters: [ filters: [
@@ -440,7 +460,7 @@ test('compute filters', () => {
expect(filter).toBeUndefined(); expect(filter).toBeUndefined();
} }
{ {
const filter = computeDataTableFilter('', [ const filter = computeDataTableFilter('', false, [
{ {
key: 'level', key: 'level',
filters: [ filters: [
@@ -460,7 +480,7 @@ test('compute filters', () => {
expect(data.filter(filter)).toEqual([coffee, espresso, meet]); expect(data.filter(filter)).toEqual([coffee, espresso, meet]);
} }
{ {
const filter = computeDataTableFilter('', [ const filter = computeDataTableFilter('', false, [
{ {
key: 'level', key: 'level',
filters: [ filters: [
@@ -485,7 +505,7 @@ test('compute filters', () => {
expect(data.filter(filter)).toEqual([espresso]); expect(data.filter(filter)).toEqual([espresso]);
} }
{ {
const filter = computeDataTableFilter('nonsense', [ const filter = computeDataTableFilter('nonsense', false, [
{ {
key: 'level', key: 'level',
filters: [ filters: [