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