mirror of https://github.com/grafana/grafana.git
				
				
				
			Combobox: Add fuzzy search (#99359)
* Add initial fuzzy match * Remove unused import * Fuzzy search for Multi * Remove old filter function * Restore changes to Multi while waiting for async * Add non ascii support and memoize stringified version * updates * Add tests * Add tests for real this time --------- Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
		
							parent
							
								
									2dfb796f21
								
							
						
					
					
						commit
						a9f62953df
					
				| 
						 | 
					@ -14,7 +14,7 @@ import { Portal } from '../Portal/Portal';
 | 
				
			||||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
 | 
					import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AsyncError, NotFoundError } from './MessageRows';
 | 
					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 { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
 | 
				
			||||||
import { useComboboxFloat } from './useComboboxFloat';
 | 
					import { useComboboxFloat } from './useComboboxFloat';
 | 
				
			||||||
import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall';
 | 
					import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall';
 | 
				
			||||||
| 
						 | 
					@ -158,6 +158,12 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
 | 
				
			||||||
    [createCustomValue, id, ariaLabelledBy]
 | 
					    [createCustomValue, id, ariaLabelledBy]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Memoize for using in fuzzy search
 | 
				
			||||||
 | 
					  const stringifiedItems = useMemo(
 | 
				
			||||||
 | 
					    () => (isAsync ? [] : options.map((item) => itemToString(item))),
 | 
				
			||||||
 | 
					    [options, isAsync]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const selectedItemIndex = useMemo(() => {
 | 
					  const selectedItemIndex = useMemo(() => {
 | 
				
			||||||
    if (isAsync) {
 | 
					    if (isAsync) {
 | 
				
			||||||
      return null;
 | 
					      return null;
 | 
				
			||||||
| 
						 | 
					@ -262,7 +268,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (!isAsync) {
 | 
					      if (!isAsync) {
 | 
				
			||||||
        const filteredItems = options.filter(itemFilter(inputValue));
 | 
					        const filteredItems = fuzzyFind(options, stringifiedItems, inputValue);
 | 
				
			||||||
        setItems(filteredItems, inputValue);
 | 
					        setItems(filteredItems, inputValue);
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        if (inputValue && createCustomValue) {
 | 
					        if (inputValue && createCustomValue) {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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(['台南市', '南投縣']);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
| 
						 | 
					@ -1,16 +1,33 @@
 | 
				
			||||||
 | 
					import uFuzzy from '@leeoniya/ufuzzy';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { ComboboxOption } from './Combobox';
 | 
					import { ComboboxOption } from './Combobox';
 | 
				
			||||||
import { ALL_OPTION_VALUE } from './MultiCombobox';
 | 
					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<T extends string | number>(item?: ComboboxOption<T> | null) {
 | 
					export function itemToString<T extends string | number>(item?: ComboboxOption<T> | null) {
 | 
				
			||||||
  if (!item) {
 | 
					  if (item == null) {
 | 
				
			||||||
    return '';
 | 
					    return '';
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  if (item.label?.includes('Custom value: ')) {
 | 
					  if (item.label?.startsWith('Custom value: ')) {
 | 
				
			||||||
    return item.value.toString();
 | 
					    return item.value.toString();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  return item.label ?? item.value.toString();
 | 
					  return item.label ?? item.value.toString();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//TODO: Remove when MutliCombobox async has been merged
 | 
				
			||||||
export function itemFilter<T extends string | number>(inputValue: string) {
 | 
					export function itemFilter<T extends string | number>(inputValue: string) {
 | 
				
			||||||
  const lowerCasedInputValue = inputValue.toLowerCase();
 | 
					  const lowerCasedInputValue = inputValue.toLowerCase();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -23,3 +40,45 @@ export function itemFilter<T extends string | number>(inputValue: string) {
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function fuzzyFind<T extends string | number>(
 | 
				
			||||||
 | 
					  options: Array<ComboboxOption<T>>,
 | 
				
			||||||
 | 
					  haystack: string[],
 | 
				
			||||||
 | 
					  needle: string
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  let matches: Array<ComboboxOption<T>> = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue