From 09cef892a5ae7312dc8b775b93123401a13313c5 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Tue, 12 Dec 2023 16:41:50 -0600 Subject: [PATCH] OptionsPicker: Use fuzzy search and improve ranking of matches (#79286) --- package.json | 2 +- packages/grafana-flamegraph/package.json | 2 +- packages/grafana-ui/package.json | 2 +- .../app/features/commandPalette/useMatches.ts | 4 +- .../app/features/explore/Logs/utils/uFuzzy.ts | 2 +- .../app/features/search/service/frontend.ts | 2 +- .../pickers/OptionsPicker/reducer.test.ts | 33 +++++++++++++++ .../pickers/OptionsPicker/reducer.ts | 41 +++++++++++++++---- .../components/metrics-modal/uFuzzy.ts | 2 +- yarn.lock | 14 +++---- 10 files changed, 82 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 540492300a7..9f0808582a9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 0d9c21b9b1a..c41e2a11da3 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -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", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index acfb1f294e6..81db5806979 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -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", diff --git a/public/app/features/commandPalette/useMatches.ts b/public/app/features/commandPalette/useMatches.ts index 7c0456325c5..50926b10b80 100644 --- a/public/app/features/commandPalette/useMatches.ts +++ b/public/app/features/commandPalette/useMatches.ts @@ -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++) { diff --git a/public/app/features/explore/Logs/utils/uFuzzy.ts b/public/app/features/explore/Logs/utils/uFuzzy.ts index 8804d6d52b3..7c7e5eb44ea 100644 --- a/public/app/features/explore/Logs/utils/uFuzzy.ts +++ b/public/app/features/explore/Logs/utils/uFuzzy.ts @@ -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 = new Set(); diff --git a/public/app/features/search/service/frontend.ts b/public/app/features/search/service/frontend.ts index 35b79c795e3..4a78f750f5c 100644 --- a/public/app/features/search/service/frontend.ts +++ b/public/app/features/search/service/frontend.ts @@ -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; diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts index 2fba9f320e6..28579740f0f 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.test.ts @@ -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() + .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'; diff --git a/public/app/features/variables/pickers/OptionsPicker/reducer.ts b/public/app/features/variables/pickers/OptionsPicker/reducer.ts index 059ad9d579c..c9d53cf89bd 100644 --- a/public/app/features/variables/pickers/OptionsPicker/reducer.ts +++ b/public/app/features/variables/pickers/OptionsPicker/reducer.ts @@ -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 => { if (!Array.isArray(options)) { return {}; @@ -237,14 +246,32 @@ const optionsPickerSlice = createSlice({ return state; }, updateOptionsAndFilter: (state, action: PayloadAction): 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); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts index 8804d6d52b3..7c7e5eb44ea 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts @@ -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 = new Set(); diff --git a/yarn.lock b/yarn.lock index c2b01e0471a..88b88d0719e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"