OptionsPicker: Use fuzzy search and improve ranking of matches (#79286)

This commit is contained in:
Leon Sorokin 2023-12-12 16:41:50 -06:00 committed by GitHub
parent 19cda38f1b
commit 09cef892a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 82 additions and 22 deletions

View File

@ -260,7 +260,7 @@
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"@kusto/monaco-kusto": "^7.4.0",
"@leeoniya/ufuzzy": "1.0.8",
"@leeoniya/ufuzzy": "1.0.13",
"@lezer/common": "1.0.2",
"@lezer/highlight": "1.1.3",
"@lezer/lr": "1.3.3",

View File

@ -46,7 +46,7 @@
"@emotion/css": "11.11.2",
"@grafana/data": "10.3.0-pre",
"@grafana/ui": "10.3.0-pre",
"@leeoniya/ufuzzy": "1.0.8",
"@leeoniya/ufuzzy": "1.0.13",
"d3": "^7.8.5",
"lodash": "4.17.21",
"react": "18.2.0",

View File

@ -53,7 +53,7 @@
"@grafana/e2e-selectors": "10.3.0-pre",
"@grafana/faro-web-sdk": "1.2.1",
"@grafana/schema": "10.3.0-pre",
"@leeoniya/ufuzzy": "1.0.8",
"@leeoniya/ufuzzy": "1.0.13",
"@monaco-editor/react": "4.6.0",
"@popperjs/core": "2.11.8",
"@react-aria/button": "3.8.0",

View File

@ -210,9 +210,9 @@ function useInternalMatches(filtered: ActionImpl[], search: string): Match[] {
} else {
const termCount = ufuzzy.split(throttledSearch).length;
const infoThresh = Infinity;
const oooSearch = termCount < 5;
const oooLimit = termCount < 5 ? 4 : 0;
const [, info, order] = ufuzzy.search(haystack, throttledSearch, oooSearch, infoThresh);
const [, info, order] = ufuzzy.search(haystack, throttledSearch, oooLimit, infoThresh);
if (info && order) {
for (let orderIndex = 0; orderIndex < order.length; orderIndex++) {

View File

@ -10,7 +10,7 @@ const uf = new uFuzzy({
});
export function fuzzySearch(haystack: string[], query: string, dispatcher: (data: string[][]) => void) {
const [idxs, info, order] = uf.search(haystack, query, false, 1e5);
const [idxs, info, order] = uf.search(haystack, query, 0, 1e5);
let haystackOrder: string[] = [];
let matchesSet: Set<string> = new Set();

View File

@ -116,7 +116,7 @@ class FullResultCache {
// eslint-disable-next-line
const values = allFields.map((v) => [] as any[]); // empty value for each field
let [idxs, info, order] = this.ufuzzy.search(haystack, query, true);
let [idxs, info, order] = this.ufuzzy.search(haystack, query, 5);
for (let c = 0; c < allFields.length; c++) {
let src = allFields[c].values;

View File

@ -774,6 +774,39 @@ describe('optionsPickerReducer', () => {
});
});
describe('when similar data for updateOptionsAndFilter', () => {
it('should properly rank by match quality', () => {
const searchQuery = 'C';
const options: VariableOption[] = 'A AA AB AC BC C CD'.split(' ').map((v) => ({
selected: false,
text: v,
value: v,
}));
const expect: VariableOption[] = 'C CD AC BC'.split(' ').map((v) => ({
selected: false,
text: v,
value: v,
}));
const { initialState } = getVariableTestContext({
queryValue: searchQuery,
});
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(updateOptionsAndFilter(options))
.thenStateShouldEqual({
...cloneDeep(initialState),
options: expect,
selectedValues: [],
queryValue: 'C',
highlightIndex: 0,
});
});
});
describe('when large data for updateOptionsFromSearch is dispatched and variable has searchFilter', () => {
it('then state should be correct', () => {
const searchQuery = '__searchFilter';

View File

@ -1,5 +1,6 @@
import uFuzzy from '@leeoniya/ufuzzy';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { cloneDeep, isString, trimStart } from 'lodash';
import { cloneDeep, isString } from 'lodash';
import { containsSearchFilter } from '@grafana/data';
@ -34,6 +35,14 @@ export const initialOptionPickerState: OptionsPickerState = {
export const OPTIONS_LIMIT = 1000;
const ufuzzy = new uFuzzy({
intraMode: 1,
intraIns: 1,
intraSub: 1,
intraTrn: 1,
intraDel: 1,
});
const optionsToRecord = (options: VariableOption[]): Record<string, VariableOption> => {
if (!Array.isArray(options)) {
return {};
@ -237,14 +246,32 @@ const optionsPickerSlice = createSlice({
return state;
},
updateOptionsAndFilter: (state, action: PayloadAction<VariableOption[]>): OptionsPickerState => {
const searchQuery = trimStart((state.queryValue ?? '').toLowerCase());
const needle = state.queryValue.trim();
state.options = action.payload.filter((option) => {
const optionsText = option.text ?? '';
const text = Array.isArray(optionsText) ? optionsText.toString() : optionsText;
return text.toLowerCase().indexOf(searchQuery) !== -1;
});
let opts: VariableOption[] = [];
if (needle === '') {
opts = action.payload;
} else {
// with current API, not seeing a way to cache this on state using action.payload's uniqueness
// since it's recreated and includes selected state on each item :(
const haystack = action.payload.map(({ text }) => (Array.isArray(text) ? text.toString() : text));
const [idxs, info, order] = ufuzzy.search(haystack, needle, 5);
if (idxs?.length) {
if (info && order) {
opts = order.map((idx) => action.payload[info.idx[idx]]);
} else {
opts = idxs!.map((idx) => action.payload[idx]);
}
// always sort $__all to the top, even if exact match exists?
opts.sort((a, b) => (a.value === '$__all' ? -1 : 0) - (b.value === '$__all' ? -1 : 0));
}
}
state.options = opts;
state.highlightIndex = 0;
return applyStateChanges(state, updateDefaultSelection, updateOptions);

View File

@ -10,7 +10,7 @@ const uf = new uFuzzy({
});
export function fuzzySearch(haystack: string[], query: string, dispatcher: (data: string[][]) => void) {
const [idxs, info, order] = uf.search(haystack, query, false, 1e5);
const [idxs, info, order] = uf.search(haystack, query, 0, 1e5);
let haystackOrder: string[] = [];
let matchesSet: Set<string> = new Set();

View File

@ -3169,7 +3169,7 @@ __metadata:
"@grafana/data": "npm:10.3.0-pre"
"@grafana/tsconfig": "npm:^1.2.0-rc1"
"@grafana/ui": "npm:10.3.0-pre"
"@leeoniya/ufuzzy": "npm:1.0.8"
"@leeoniya/ufuzzy": "npm:1.0.13"
"@rollup/plugin-node-resolve": "npm:15.2.3"
"@testing-library/jest-dom": "npm:^6.1.2"
"@testing-library/react": "npm:14.0.0"
@ -3366,7 +3366,7 @@ __metadata:
"@grafana/faro-web-sdk": "npm:1.2.1"
"@grafana/schema": "npm:10.3.0-pre"
"@grafana/tsconfig": "npm:^1.2.0-rc1"
"@leeoniya/ufuzzy": "npm:1.0.8"
"@leeoniya/ufuzzy": "npm:1.0.13"
"@monaco-editor/react": "npm:4.6.0"
"@popperjs/core": "npm:2.11.8"
"@react-aria/button": "npm:3.8.0"
@ -4177,10 +4177,10 @@ __metadata:
languageName: node
linkType: hard
"@leeoniya/ufuzzy@npm:1.0.8":
version: 1.0.8
resolution: "@leeoniya/ufuzzy@npm:1.0.8"
checksum: b5068593022e322e395e7cb2c134b4b040375165d0953d3f40f8dcd4bb84fe56c39c08ee0dede056b4b8ed1060d39f5bf6c821dab4ae271e8e5befc81c33d7c6
"@leeoniya/ufuzzy@npm:1.0.13":
version: 1.0.13
resolution: "@leeoniya/ufuzzy@npm:1.0.13"
checksum: 2222357f136764674e84d1b85ae2b4436840a270f75598c902e528ccfbc3d91b638f9e3b6d16e115e98eaffa4bcd9c71db909b126cf4ac3102fa14339d16cf4b
languageName: node
linkType: hard
@ -17337,7 +17337,7 @@ __metadata:
"@grafana/tsconfig": "npm:^1.3.0-rc1"
"@grafana/ui": "workspace:*"
"@kusto/monaco-kusto": "npm:^7.4.0"
"@leeoniya/ufuzzy": "npm:1.0.8"
"@leeoniya/ufuzzy": "npm:1.0.13"
"@lezer/common": "npm:1.0.2"
"@lezer/highlight": "npm:1.1.3"
"@lezer/lr": "npm:1.3.3"