diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx index 00e951733..66611dd62 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -249,19 +249,29 @@ export function DataTable( // 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 :)) - const setFilter = (search: string, columns: DataTableColumn[]) => { - dataSource.view.setFilter(computeDataTableFilter(search, columns)); + const setFilter = ( + search: string, + useRegex: boolean, + columns: DataTableColumn[], + ) => { + dataSource.view.setFilter( + computeDataTableFilter(search, useRegex, columns), + ); }; return props._testHeight ? setFilter : debounce(setFilter, 250); }); useEffect( 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! // 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.columns.map((c) => c.filters)], + [tableState.searchValue, tableState.useRegex, ...tableState.columns.map((c) => c.filters)], ); useEffect( @@ -367,6 +377,7 @@ export function DataTable( = | Action<'removeColumnFilter', {column: keyof T; index: number}> | Action<'toggleColumnFilter', {column: keyof T; index: number}> | Action<'setColumnFilterFromSelection', {column: keyof T}> - | Action<'appliedInitialScroll'>; + | Action<'appliedInitialScroll'> + | Action<'toggleUseRegex'>; type DataManagerConfig = { dataSource: DataSource; @@ -96,6 +98,7 @@ type DataManagerState = { sorting: Sorting | undefined; selection: Selection; searchValue: string; + useRegex: boolean; }; export type DataTableReducer = Reducer< @@ -142,6 +145,10 @@ export const dataTableManagerReducer = produce(function ( draft.searchValue = action.value; break; } + case 'toggleUseRegex': { + draft.useRegex = !draft.useRegex; + break; + } case 'selectItem': { const {nextIndex, addToSelection} = action; draft.selection = computeSetSelection( @@ -301,6 +308,7 @@ export function createInitialState( } : emptySelection, searchValue: prefs?.search ?? '', + useRegex: prefs?.useRegex ?? false, }; // @ts-ignore res.config[immerable] = false; // optimization: never proxy anything in config @@ -363,6 +371,7 @@ export function savePreferences( } const prefs: PersistedState = { search: state.searchValue, + useRegex: state.useRegex, selection: { current: state.selection.current, items: Array.from(state.selection.items), @@ -411,9 +420,11 @@ function computeInitialColumns( 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) => @@ -438,11 +449,21 @@ export function computeDataTableFilter( } } 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( base: Selection, nextIndex: number | ((currentIndex: number) => number), diff --git a/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx b/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx index e9b223da0..c4ceb35f0 100644 --- a/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/TableSearch.tsx @@ -9,7 +9,7 @@ import {MenuOutlined} from '@ant-design/icons'; 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 {Layout} from '../Layout'; @@ -18,11 +18,13 @@ import type {DataTableDispatch} from './DataTableManager'; export const TableSearch = memo(function TableSearch({ searchValue, + useRegex, dispatch, extraActions, contextMenu, }: { searchValue: string; + useRegex: boolean; dispatch: DataTableDispatch; extraActions?: React.ReactElement; contextMenu: undefined | (() => JSX.Element); @@ -33,6 +35,25 @@ export const TableSearch = memo(function TableSearch({ }, [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 ( + .* + + } onChange={(e) => { onSearch(e.target.value); }} @@ -59,8 +101,25 @@ export const TableSearch = memo(function TableSearch({ const Searchbar = styled(Layout.Horizontal)({ backgroundColor: theme.backgroundWash, padding: theme.space.small, + '.ant-input-affix-wrapper': { + height: 32, + }, '.ant-btn': { padding: `${theme.space.tiny}px ${theme.space.small}px`, 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, + }, +}); diff --git a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx index 89ea5bef8..381244b7e 100644 --- a/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/__tests__/DataTable.node.tsx @@ -307,9 +307,9 @@ test('compute filters', () => { const data = [coffee, espresso, meet]; // results in empty filter - expect(computeDataTableFilter('', [])).toBeUndefined(); + expect(computeDataTableFilter('', false, [])).toBeUndefined(); expect( - computeDataTableFilter('', [ + computeDataTableFilter('', false, [ { key: 'title', filters: [ @@ -324,32 +324,52 @@ test('compute filters', () => { ).toBeUndefined(); { - const filter = computeDataTableFilter('tEsT', [])!; + const filter = computeDataTableFilter('tEsT', false, [])!; expect(data.filter(filter)).toEqual([]); } { - const filter = computeDataTableFilter('EE', [])!; + const filter = computeDataTableFilter('EE', false, [])!; expect(data.filter(filter)).toEqual([coffee, meet]); } { - const filter = computeDataTableFilter('D', [])!; + const filter = computeDataTableFilter('D', false, [])!; 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]); } { - const filter = computeDataTableFilter('false', [])!; + const filter = computeDataTableFilter('false', false, [])!; expect(data.filter(filter)).toEqual([espresso, meet]); } { - const filter = computeDataTableFilter('EE', [ + const filter = computeDataTableFilter('EE', false, [ { key: 'level', filters: [ @@ -364,7 +384,7 @@ test('compute filters', () => { expect(data.filter(filter)).toEqual([meet]); } { - const filter = computeDataTableFilter('EE', [ + const filter = computeDataTableFilter('EE', false, [ { key: 'level', filters: [ @@ -384,7 +404,7 @@ test('compute filters', () => { expect(data.filter(filter)).toEqual([coffee, meet]); } { - const filter = computeDataTableFilter('', [ + const filter = computeDataTableFilter('', false, [ { key: 'level', filters: [ @@ -404,7 +424,7 @@ test('compute filters', () => { expect(data.filter(filter)).toEqual([coffee, espresso]); } { - const filter = computeDataTableFilter('', [ + const filter = computeDataTableFilter('', false, [ { key: 'done', filters: [ @@ -420,7 +440,7 @@ test('compute filters', () => { } { // nothing selected anything will not filter anything out for that column - const filter = computeDataTableFilter('', [ + const filter = computeDataTableFilter('', false, [ { key: 'level', filters: [ @@ -440,7 +460,7 @@ test('compute filters', () => { expect(filter).toBeUndefined(); } { - const filter = computeDataTableFilter('', [ + const filter = computeDataTableFilter('', false, [ { key: 'level', filters: [ @@ -460,7 +480,7 @@ test('compute filters', () => { expect(data.filter(filter)).toEqual([coffee, espresso, meet]); } { - const filter = computeDataTableFilter('', [ + const filter = computeDataTableFilter('', false, [ { key: 'level', filters: [ @@ -485,7 +505,7 @@ test('compute filters', () => { expect(data.filter(filter)).toEqual([espresso]); } { - const filter = computeDataTableFilter('nonsense', [ + const filter = computeDataTableFilter('nonsense', false, [ { key: 'level', filters: [