diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index 30f24775be6..34645d76aca 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -14,7 +14,7 @@ import { Portal } from '../Portal/Portal'; import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; import { AsyncError, NotFoundError } from './MessageRows'; -import { itemFilter, itemToString } from './filter'; +import { fuzzyFind, itemToString } from './filter'; import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles'; import { useComboboxFloat } from './useComboboxFloat'; import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall'; @@ -158,6 +158,12 @@ export const Combobox = (props: ComboboxProps) => [createCustomValue, id, ariaLabelledBy] ); + // Memoize for using in fuzzy search + const stringifiedItems = useMemo( + () => (isAsync ? [] : options.map((item) => itemToString(item))), + [options, isAsync] + ); + const selectedItemIndex = useMemo(() => { if (isAsync) { return null; @@ -262,7 +268,7 @@ export const Combobox = (props: ComboboxProps) => } if (!isAsync) { - const filteredItems = options.filter(itemFilter(inputValue)); + const filteredItems = fuzzyFind(options, stringifiedItems, inputValue); setItems(filteredItems, inputValue); } else { if (inputValue && createCustomValue) { diff --git a/packages/grafana-ui/src/components/Combobox/filter.test.ts b/packages/grafana-ui/src/components/Combobox/filter.test.ts new file mode 100644 index 00000000000..f7a5eb534fd --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/filter.test.ts @@ -0,0 +1,61 @@ +import { fuzzyFind } from './filter'; + +describe('combobox filter', () => { + it('should properly rank by match quality', () => { + const needle = 'C'; + + const stringOptions = ['A', 'AA', 'AB', 'AC', 'BC', 'C', 'CD']; + const options = stringOptions.map((value) => ({ value })); + + const matches = fuzzyFind(options, stringOptions, needle); + + expect(matches.map((m) => m.value)).toEqual(['C', 'CD', 'AC', 'BC']); + }); + + it('orders by match quality and case sensitivty', () => { + const stringOptions = [ + 'client_service_namespace', + 'namespace', + 'alert_namespace', + 'container_namespace', + 'Namespace', + 'client_k8s_namespace_name', + 'foobar', + ]; + const options = stringOptions.map((value) => ({ value })); + + const matches = fuzzyFind(options, stringOptions, 'Names'); + + expect(matches.map((m) => m.value)).toEqual([ + 'Namespace', + 'namespace', + 'alert_namespace', + 'container_namespace', + 'client_k8s_namespace_name', + 'client_service_namespace', + ]); + }); + + describe('non-ascii', () => { + it('should do substring match when needle is non-latin', () => { + const needle = '水'; + + const stringOptions = ['A水', 'AA', 'AB', 'AC', 'BC', 'C', 'CD']; + const options = stringOptions.map((value) => ({ value })); + + const matches = fuzzyFind(options, stringOptions, needle); + + expect(matches.map((m) => m.value)).toEqual(['A水']); + }); + + it('second case for non-latin characters', () => { + const stringOptions = ['台灣省', '台中市', '台北市', '台南市', '南投縣', '高雄市', '台中第一高級中學']; + + const options = stringOptions.map((value) => ({ value })); + + const matches = fuzzyFind(options, stringOptions, '南'); + + expect(matches.map((m) => m.value)).toEqual(['台南市', '南投縣']); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Combobox/filter.ts b/packages/grafana-ui/src/components/Combobox/filter.ts index dbf86c062d6..94419efe9ca 100644 --- a/packages/grafana-ui/src/components/Combobox/filter.ts +++ b/packages/grafana-ui/src/components/Combobox/filter.ts @@ -1,16 +1,33 @@ +import uFuzzy from '@leeoniya/ufuzzy'; + import { ComboboxOption } from './Combobox'; import { ALL_OPTION_VALUE } from './MultiCombobox'; +// https://catonmat.net/my-favorite-regex :) +const REGEXP_NON_ASCII = /[^ -~]/m; +// limit max terms in needle that qualify for re-ordering +const outOfOrderLimit = 5; +// beyond 25 chars fall back to substring search +const maxNeedleLength = 25; +// beyond 5 terms fall back to substring match +const maxFuzzyTerms = 5; +// when number of matches <= 1e4, do ranking + sorting by quality +const rankThreshold = 1e4; + +// typo tolerance mode +const uf = new uFuzzy({ intraMode: 1 }); + export function itemToString(item?: ComboboxOption | null) { - if (!item) { + if (item == null) { return ''; } - if (item.label?.includes('Custom value: ')) { + if (item.label?.startsWith('Custom value: ')) { return item.value.toString(); } return item.label ?? item.value.toString(); } +//TODO: Remove when MutliCombobox async has been merged export function itemFilter(inputValue: string) { const lowerCasedInputValue = inputValue.toLowerCase(); @@ -23,3 +40,45 @@ export function itemFilter(inputValue: string) { ); }; } + +export function fuzzyFind( + options: Array>, + haystack: string[], + needle: string +) { + let matches: Array> = []; + + if (needle === '') { + matches = options; + } + // fallback to substring matches to avoid badness + else if ( + // contains non-ascii + REGEXP_NON_ASCII.test(needle) || + // too long (often copy-paste from somewhere) + needle.length > maxNeedleLength || + uf.split(needle).length > maxFuzzyTerms + ) { + for (let i = 0; i < haystack.length; i++) { + let item = haystack[i]; + + if (item.includes(needle)) { + matches.push(options[i]); + } + } + } + // fuzzy search + else { + const [idxs, info, order] = uf.search(haystack, needle, outOfOrderLimit, rankThreshold); + + if (idxs?.length) { + if (info && order) { + matches = order.map((idx) => options[info.idx[idx]]); + } else { + matches = idxs.map((idx) => options[idx]); + } + } + } + + return matches; +}