From bbe99df2b625053f166a8865ecfd70a3e9db39f6 Mon Sep 17 00:00:00 2001 From: Justin Tulk Date: Mon, 16 Dec 2024 13:43:38 -0800 Subject: [PATCH] chore(app): allowing optional regex searching in a dropdown (#3255) --- .../elements/ModifiedDropdown.test.tsx | 151 ++++++++++++++++++ .../components/elements/ModifiedDropdown.tsx | 46 ++++-- 2 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 weave-js/src/common/components/elements/ModifiedDropdown.test.tsx diff --git a/weave-js/src/common/components/elements/ModifiedDropdown.test.tsx b/weave-js/src/common/components/elements/ModifiedDropdown.test.tsx new file mode 100644 index 000000000000..3557537cbe8a --- /dev/null +++ b/weave-js/src/common/components/elements/ModifiedDropdown.test.tsx @@ -0,0 +1,151 @@ +import {getAsValidRegex, simpleSearch} from './ModifiedDropdown'; + +describe('testing regex validity', () => { + it('should handle basic character classes', () => { + expect(getAsValidRegex('[a-z]')).toBeInstanceOf(RegExp); + }); + + it('should handle alternation', () => { + expect(getAsValidRegex('cat|dog')).toBeInstanceOf(RegExp); + }); + + it('should handle quantifiers', () => { + expect(getAsValidRegex('a{1,3}')).toBeInstanceOf(RegExp); + }); + + it('should handle escaped special characters', () => { + expect(getAsValidRegex('\\[test\\]')).toBeInstanceOf(RegExp); + }); + + it('should handle complex patterns', () => { + expect( + getAsValidRegex('^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$') + ).toBeInstanceOf(RegExp); + }); + + it('should reject unmatched parentheses', () => { + expect(getAsValidRegex('(abc')).toBeNull(); + }); + + it('should reject unmatched brackets', () => { + expect(getAsValidRegex('[abc')).toBeNull(); + }); + + it('should reject invalid quantifiers', () => { + expect(getAsValidRegex('a{2,1}')).toBeNull(); + }); + + it('should reject unescaped special characters', () => { + expect(getAsValidRegex('[')).toBeNull(); + }); + + it('should reject invalid character ranges', () => { + expect(getAsValidRegex('[z-a]')).toBeNull(); + }); +}); + +describe('testing simple search', () => { + const options = [ + { + icon: 'wbic-ic-up-arrow', + text: 'Step', + value: '_step', + key: '_step', + }, + { + icon: 'calendar', + text: 'Relative Time (Wall)', + value: '_absolute_runtime', + key: '_absolute_runtime', + }, + { + icon: 'calendar', + text: 'Relative Time (Process)', + value: '_runtime', + key: '_runtime', + }, + { + icon: 'calendar', + text: 'Wall Time', + value: '_timestamp', + key: '_timestamp', + }, + { + icon: 'wbic-ic-up-arrow', + text: 'custom_x', + value: 'custom_x', + key: 'custom_x', + }, + { + icon: 'wbic-ic-up-arrow', + text: 'loss', + value: 'loss', + key: 'loss', + }, + { + icon: 'chart bar', + text: 'eval/run/1/loss_1024', + value: 'eval/run/1/loss_1024', + key: 'eval/run/1/loss_1024', + }, + { + icon: 'chart bar', + text: 'eval/run/1/loss_256', + value: 'eval/run/1/loss_256', + key: 'eval/run/1/loss_256', + }, + { + icon: 'chart bar', + text: 'eval/run/1/loss_512', + value: 'eval/run/1/loss_512', + key: 'eval/run/1/loss_512', + }, + { + icon: 'chart bar', + text: 'eval/run/2/loss_2048', + value: 'eval/run/2/loss_2048', + key: 'eval/run/2/loss_2048', + }, + { + icon: 'chart bar', + text: 'eval/run/2/loss_4096', + value: 'eval/run/2/loss_4096', + key: 'eval/run/2/loss_4096', + }, + { + icon: 'chart bar', + text: 'eval/run/2/loss_768', + value: 'eval/run/2/loss_768', + key: 'eval/run/2/loss_768', + }, + ]; + + it('simpleSearch matches exact non-regex strings', () => { + const results = simpleSearch(options, 'loss'); + expect(results.every(r => (r.value as string).includes('loss'))).toBe(true); + }); + + it('simpleSearch matches partial non-regex strings', () => { + const results = simpleSearch(options, 'loss_'); + expect(results.every(r => (r.value as string).includes('loss_'))).toBe( + true + ); + }); + + it('simpleSearch matches case-insensitive non-regex strings', () => { + const results = simpleSearch(options, 'LOSS'); + expect(results.every(r => (r.value as string).includes('loss'))).toBe(true); + }); + + it('simpleSearch can disallow matching regex patterns', () => { + const results = simpleSearch(options, '.*s.*s.*'); + expect(results.length).toBe(0); + }); + + it('simpleSearch can support matching regex patterns', () => { + const results = simpleSearch(options, '.*s.*s.*', { + allowRegexSearch: true, + }); + expect(results.length).toBeGreaterThan(0); + }); +}); diff --git a/weave-js/src/common/components/elements/ModifiedDropdown.tsx b/weave-js/src/common/components/elements/ModifiedDropdown.tsx index 814563cc14bc..301abe035f75 100644 --- a/weave-js/src/common/components/elements/ModifiedDropdown.tsx +++ b/weave-js/src/common/components/elements/ModifiedDropdown.tsx @@ -45,11 +45,28 @@ type LabelCoord = { const ITEM_LIMIT_VALUE = '__item_limit'; -const simpleSearch = (options: DropdownItemProps[], query: string) => { +export function getAsValidRegex(s: string): RegExp | null { + try { + return new RegExp(s); + } catch (e) { + return null; + } +} + +export const simpleSearch = ( + options: DropdownItemProps[], + query: string, + config: { + allowRegexSearch?: boolean; + } = {} +) => { + const regex = config.allowRegexSearch ? getAsValidRegex(query) : null; + return _.chain(options) - .filter(o => - _.includes(JSON.stringify(o.text).toLowerCase(), query.toLowerCase()) - ) + .filter(o => { + const text = JSON.stringify(o.text).toLowerCase(); + return regex ? regex.test(text) : _.includes(text, query.toLowerCase()); + }) .sortBy(o => { const valJSON = typeof o.text === 'string' ? `"${query}"` : query; return JSON.stringify(o.text).toLowerCase() === valJSON.toLowerCase() @@ -69,17 +86,17 @@ const getOptionProps = (opt: Option, hideText: boolean) => { }; export interface ModifiedDropdownExtraProps { + allowRegexSearch?: boolean; debounceTime?: number; enableReordering?: boolean; + hideText?: boolean; itemLimit?: number; options: Option[]; + optionTransform?(option: Option): Option; resultLimit?: number; resultLimitMessage?: string; style?: CSSProperties; - hideText?: boolean; useIcon?: boolean; - - optionTransform?(option: Option): Option; } type ModifiedDropdownProps = Omit & @@ -98,10 +115,11 @@ const ModifiedDropdown: FC = React.memo( } = props; const { + allowAdditions, + allowRegexSearch, + enableReordering, itemLimit, optionTransform, - enableReordering, - allowAdditions, resultLimit = 100, resultLimitMessage = `Limited to ${resultLimit} items. Refine search to see other options.`, ...passProps @@ -130,15 +148,13 @@ const ModifiedDropdown: FC = React.memo( _.concat(currentOptions, search(propsOptions, query) as Option[]) ); } else { - setOptions( - _.concat( - currentOptions, - simpleSearch(propsOptions, query) as Option[] - ) + const updatedOptions = currentOptions.concat( + simpleSearch(propsOptions, query, {allowRegexSearch}) as Option[] ); + setOptions(updatedOptions); } }, debounceTime || 400), - [multiple, propsOptions, search, value, debounceTime] + [allowRegexSearch, debounceTime, multiple, propsOptions, search, value] ); const firstRenderRef = useRef(true);