mirror of https://github.com/grafana/grafana
Prometheus: Use fuzzy string matching to autocomplete metric names and label (#32207)
* Fuzzy search prototype * Aggregate filter and sorting functions for auto-complete suggestions * Add a test for fuzzy search * Simplify setting fuzzy search information * Rename SimpleHighlighter * Test PartialHighlighter * Add PartialHighlighter snapshot * Simplify PartialHighlighter * Revert env change * Clean up the code * Add fuzzy search for labels * Bring back backwards compatiblity * Expose search function type only * Update docs * Covert snapshot test to assertions * Fix docs * Fix language provider test * Add a test for autocomplete logic * Clean up * Mock Editor functions * Add fuzzy search to Prometheus labels * Add docs about backwards compatibility * Simplify main fuzzy search looppull/33078/head
parent
2c862678ab
commit
dd095642e2
@ -0,0 +1,48 @@ |
||||
import React from 'react'; |
||||
import { mount, ReactWrapper } from 'enzyme'; |
||||
import { PartialHighlighter } from './PartialHighlighter'; |
||||
|
||||
function assertPart(component: ReactWrapper, isHighlighted: boolean, text: string): void { |
||||
expect(component.type()).toEqual(isHighlighted ? 'mark' : 'span'); |
||||
expect(component.hasClass('highlight')).toEqual(isHighlighted); |
||||
expect(component.text()).toEqual(text); |
||||
} |
||||
|
||||
describe('PartialHighlighter component', () => { |
||||
it('should highlight inner parts correctly', () => { |
||||
const component = mount( |
||||
<PartialHighlighter |
||||
text="Lorem ipsum dolor sit amet" |
||||
highlightClassName="highlight" |
||||
highlightParts={[ |
||||
{ start: 6, end: 10 }, |
||||
{ start: 18, end: 20 }, |
||||
]} |
||||
/> |
||||
); |
||||
const main = component.find('div'); |
||||
|
||||
assertPart(main.childAt(0), false, 'Lorem '); |
||||
assertPart(main.childAt(1), true, 'ipsum'); |
||||
assertPart(main.childAt(2), false, ' dolor '); |
||||
assertPart(main.childAt(3), true, 'sit'); |
||||
assertPart(main.childAt(4), false, ' amet'); |
||||
}); |
||||
|
||||
it('should highlight outer parts correctly', () => { |
||||
const component = mount( |
||||
<PartialHighlighter |
||||
text="Lorem ipsum dolor sit amet" |
||||
highlightClassName="highlight" |
||||
highlightParts={[ |
||||
{ start: 0, end: 4 }, |
||||
{ start: 22, end: 25 }, |
||||
]} |
||||
/> |
||||
); |
||||
const main = component.find('div'); |
||||
assertPart(main.childAt(0), true, 'Lorem'); |
||||
assertPart(main.childAt(1), false, ' ipsum dolor sit '); |
||||
assertPart(main.childAt(2), true, 'amet'); |
||||
}); |
||||
}); |
@ -0,0 +1,55 @@ |
||||
import React, { createElement } from 'react'; |
||||
import { HighlightPart } from '../../types'; |
||||
|
||||
interface Props { |
||||
text: string; |
||||
highlightParts: HighlightPart[]; |
||||
highlightClassName: string; |
||||
} |
||||
|
||||
/** |
||||
* Flattens parts into a list of indices pointing to the index where a part |
||||
* (highlighted or not highlighted) starts. Adds extra indices if needed |
||||
* at the beginning or the end to ensure the entire text is covered. |
||||
*/ |
||||
function getStartIndices(parts: HighlightPart[], length: number): number[] { |
||||
const indices: number[] = []; |
||||
parts.forEach((part) => { |
||||
indices.push(part.start, part.end + 1); |
||||
}); |
||||
if (indices[0] !== 0) { |
||||
indices.unshift(0); |
||||
} |
||||
if (indices[indices.length - 1] !== length) { |
||||
indices.push(length); |
||||
} |
||||
return indices; |
||||
} |
||||
|
||||
export const PartialHighlighter: React.FC<Props> = (props: Props) => { |
||||
let { highlightParts, text, highlightClassName } = props; |
||||
|
||||
if (!highlightParts) { |
||||
return null; |
||||
} |
||||
|
||||
let children = []; |
||||
let indices = getStartIndices(highlightParts, text.length); |
||||
let highlighted = highlightParts[0].start === 0; |
||||
|
||||
for (let i = 1; i < indices.length; i++) { |
||||
let start = indices[i - 1]; |
||||
let end = indices[i]; |
||||
|
||||
children.push( |
||||
createElement(highlighted ? 'mark' : 'span', { |
||||
key: i - 1, |
||||
children: text.substring(start, end), |
||||
className: highlighted ? highlightClassName : undefined, |
||||
}) |
||||
); |
||||
highlighted = !highlighted; |
||||
} |
||||
|
||||
return <div>{children}</div>; |
||||
}; |
@ -0,0 +1,79 @@ |
||||
import { fuzzyMatch } from './fuzzy'; |
||||
|
||||
describe('Fuzzy search', () => { |
||||
it('finds only matching elements', () => { |
||||
expect(fuzzyMatch('foo', 'foo')).toEqual({ |
||||
distance: 0, |
||||
ranges: [{ start: 0, end: 2 }], |
||||
found: true, |
||||
}); |
||||
|
||||
expect(fuzzyMatch('foo_bar', 'foo')).toEqual({ |
||||
distance: 0, |
||||
ranges: [{ start: 0, end: 2 }], |
||||
found: true, |
||||
}); |
||||
|
||||
expect(fuzzyMatch('bar', 'foo')).toEqual({ |
||||
distance: Infinity, |
||||
ranges: [], |
||||
found: false, |
||||
}); |
||||
}); |
||||
|
||||
it('is case sensitive', () => { |
||||
expect(fuzzyMatch('foo_bar', 'BAR')).toEqual({ |
||||
distance: Infinity, |
||||
ranges: [], |
||||
found: false, |
||||
}); |
||||
expect(fuzzyMatch('Foo_Bar', 'bar')).toEqual({ |
||||
distance: Infinity, |
||||
ranges: [], |
||||
found: false, |
||||
}); |
||||
}); |
||||
|
||||
it('finds highlight ranges with single letters', () => { |
||||
expect(fuzzyMatch('foo_xyzzy_bar', 'fxb')).toEqual({ |
||||
ranges: [ |
||||
{ start: 0, end: 0 }, |
||||
{ start: 4, end: 4 }, |
||||
{ start: 10, end: 10 }, |
||||
], |
||||
distance: 8, |
||||
found: true, |
||||
}); |
||||
}); |
||||
|
||||
it('finds highlight ranges for multiple outer words', () => { |
||||
expect(fuzzyMatch('foo_xyzzy_bar', 'foobar')).toEqual({ |
||||
ranges: [ |
||||
{ start: 0, end: 2 }, |
||||
{ start: 10, end: 12 }, |
||||
], |
||||
distance: 7, |
||||
found: true, |
||||
}); |
||||
}); |
||||
|
||||
it('finds highlight ranges for multiple inner words', () => { |
||||
expect(fuzzyMatch('foo_xyzzy_bar', 'oozzyba')).toEqual({ |
||||
ranges: [ |
||||
{ start: 1, end: 2 }, |
||||
{ start: 6, end: 8 }, |
||||
{ start: 10, end: 11 }, |
||||
], |
||||
distance: 4, |
||||
found: true, |
||||
}); |
||||
}); |
||||
|
||||
it('promotes exact matches', () => { |
||||
expect(fuzzyMatch('bbaarr_bar_bbaarr', 'bar')).toEqual({ |
||||
ranges: [{ start: 7, end: 9 }], |
||||
distance: 0, |
||||
found: true, |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,67 @@ |
||||
import { HighlightPart } from '../types'; |
||||
import { last } from 'lodash'; |
||||
|
||||
type FuzzyMatch = { |
||||
/** |
||||
* Total number of unmatched letters between matched letters |
||||
*/ |
||||
distance: number; |
||||
ranges: HighlightPart[]; |
||||
found: boolean; |
||||
}; |
||||
|
||||
/** |
||||
* Attempts to do a partial input search, e.g. allowing to search for a text (needle) |
||||
* in another text (stack) by skipping some letters in-between. All letters from |
||||
* the needle must exist in the stack in the same order to find a match. |
||||
* |
||||
* The search is case sensitive. Convert stack and needle to lower case |
||||
* to make it case insensitive. |
||||
* |
||||
* @param stack - main text to be searched |
||||
* @param needle - partial text to find in the stack |
||||
*/ |
||||
export function fuzzyMatch(stack: string, needle: string): FuzzyMatch { |
||||
let distance = 0, |
||||
searchIndex = stack.indexOf(needle); |
||||
|
||||
const ranges: HighlightPart[] = []; |
||||
|
||||
if (searchIndex !== -1) { |
||||
return { |
||||
distance: 0, |
||||
found: true, |
||||
ranges: [{ start: searchIndex, end: searchIndex + needle.length - 1 }], |
||||
}; |
||||
} |
||||
|
||||
for (const letter of needle) { |
||||
const letterIndex = stack.indexOf(letter, searchIndex); |
||||
|
||||
if (letterIndex === -1) { |
||||
return { distance: Infinity, ranges: [], found: false }; |
||||
} |
||||
// do not cumulate the distance if it's the first letter
|
||||
if (searchIndex !== -1) { |
||||
distance += letterIndex - searchIndex; |
||||
} |
||||
searchIndex = letterIndex + 1; |
||||
|
||||
if (ranges.length === 0) { |
||||
ranges.push({ start: letterIndex, end: letterIndex }); |
||||
} else { |
||||
const lastRange = last(ranges)!; |
||||
if (letterIndex === lastRange.end + 1) { |
||||
lastRange.end++; |
||||
} else { |
||||
ranges.push({ start: letterIndex, end: letterIndex }); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return { |
||||
distance: distance, |
||||
ranges, |
||||
found: true, |
||||
}; |
||||
} |
@ -0,0 +1,156 @@ |
||||
import { SearchFunctionMap } from '../utils/searchFunctions'; |
||||
import { render } from 'enzyme'; |
||||
import { SuggestionsPlugin } from './suggestions'; |
||||
import { Plugin as SlatePlugin } from '@grafana/slate-react'; |
||||
import { SearchFunctionType } from '../utils'; |
||||
import { CompletionItemGroup, SuggestionsState } from '../types'; |
||||
|
||||
jest.mock('../utils/searchFunctions', () => ({ |
||||
// @ts-ignore
|
||||
...jest.requireActual('../utils/searchFunctions'), |
||||
SearchFunctionMap: { |
||||
Prefix: jest.fn((items) => items), |
||||
Word: jest.fn((items) => items), |
||||
Fuzzy: jest.fn((items) => items), |
||||
}, |
||||
})); |
||||
|
||||
const TypeaheadMock = jest.fn(() => ''); |
||||
jest.mock('../components/Typeahead/Typeahead', () => { |
||||
return { |
||||
Typeahead: (state: Partial<SuggestionsState>) => { |
||||
// @ts-ignore
|
||||
TypeaheadMock(state); |
||||
return ''; |
||||
}, |
||||
}; |
||||
}); |
||||
|
||||
jest.mock('lodash/debounce', () => { |
||||
return (func: () => any) => func; |
||||
}); |
||||
|
||||
describe('SuggestionsPlugin', () => { |
||||
let plugin: SlatePlugin, nextMock: any, suggestions: CompletionItemGroup[], editorMock: any, eventMock: any; |
||||
|
||||
beforeEach(() => { |
||||
let onTypeahead = async () => { |
||||
return { |
||||
suggestions: suggestions, |
||||
}; |
||||
}; |
||||
|
||||
(SearchFunctionMap.Prefix as jest.Mock).mockClear(); |
||||
(SearchFunctionMap.Word as jest.Mock).mockClear(); |
||||
(SearchFunctionMap.Fuzzy as jest.Mock).mockClear(); |
||||
|
||||
plugin = SuggestionsPlugin({ portalOrigin: '', onTypeahead }); |
||||
nextMock = () => {}; |
||||
editorMock = createEditorMock('foo'); |
||||
eventMock = new window.KeyboardEvent('keydown', { key: 'a' }); |
||||
}); |
||||
|
||||
async function triggerAutocomplete() { |
||||
await plugin.onKeyDown!(eventMock, editorMock, nextMock); |
||||
render(plugin.renderEditor!({} as any, editorMock, nextMock)); |
||||
} |
||||
|
||||
it('is backward compatible with prefixMatch and sortText', async () => { |
||||
suggestions = [ |
||||
{ |
||||
label: 'group', |
||||
prefixMatch: true, |
||||
items: [ |
||||
{ label: 'foobar', sortText: '3' }, |
||||
{ label: 'foobar', sortText: '1' }, |
||||
{ label: 'foobar', sortText: '2' }, |
||||
], |
||||
}, |
||||
]; |
||||
|
||||
await triggerAutocomplete(); |
||||
|
||||
expect(SearchFunctionMap.Word).not.toBeCalled(); |
||||
expect(SearchFunctionMap.Fuzzy).not.toBeCalled(); |
||||
expect(SearchFunctionMap.Prefix).toBeCalled(); |
||||
|
||||
expect(TypeaheadMock).toBeCalledWith( |
||||
expect.objectContaining({ |
||||
groupedItems: [ |
||||
{ |
||||
label: 'group', |
||||
prefixMatch: true, |
||||
items: [ |
||||
{ label: 'foobar', sortText: '1' }, |
||||
{ label: 'foobar', sortText: '2' }, |
||||
{ label: 'foobar', sortText: '3' }, |
||||
], |
||||
}, |
||||
], |
||||
}) |
||||
); |
||||
}); |
||||
|
||||
it('uses searchFunction to create autocomplete list and sortValue if defined', async () => { |
||||
suggestions = [ |
||||
{ |
||||
label: 'group', |
||||
searchFunctionType: SearchFunctionType.Fuzzy, |
||||
items: [ |
||||
{ label: 'foobar', sortValue: 3 }, |
||||
{ label: 'foobar', sortValue: 1 }, |
||||
{ label: 'foobar', sortValue: 2 }, |
||||
], |
||||
}, |
||||
]; |
||||
|
||||
await triggerAutocomplete(); |
||||
|
||||
expect(SearchFunctionMap.Word).not.toBeCalled(); |
||||
expect(SearchFunctionMap.Prefix).not.toBeCalled(); |
||||
expect(SearchFunctionMap.Fuzzy).toBeCalled(); |
||||
|
||||
expect(TypeaheadMock).toBeCalledWith( |
||||
expect.objectContaining({ |
||||
groupedItems: [ |
||||
{ |
||||
label: 'group', |
||||
searchFunctionType: SearchFunctionType.Fuzzy, |
||||
items: [ |
||||
{ label: 'foobar', sortValue: 1 }, |
||||
{ label: 'foobar', sortValue: 2 }, |
||||
{ label: 'foobar', sortValue: 3 }, |
||||
], |
||||
}, |
||||
], |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
function createEditorMock(currentText: string) { |
||||
return { |
||||
blur: jest.fn().mockReturnThis(), |
||||
focus: jest.fn().mockReturnThis(), |
||||
value: { |
||||
selection: { |
||||
start: { |
||||
offset: 0, |
||||
}, |
||||
end: { |
||||
offset: 0, |
||||
}, |
||||
focus: { |
||||
offset: currentText.length, |
||||
}, |
||||
}, |
||||
document: { |
||||
getClosestBlock: () => {}, |
||||
}, |
||||
focusText: { |
||||
text: currentText, |
||||
}, |
||||
focusBlock: {}, |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,58 @@ |
||||
import { CompletionItem, SearchFunction } from '../types'; |
||||
import { fuzzyMatch } from '../slate-plugins/fuzzy'; |
||||
|
||||
/** |
||||
* List of auto-complete search function used by SuggestionsPlugin.handleTypeahead() |
||||
* @alpha |
||||
*/ |
||||
export enum SearchFunctionType { |
||||
Word = 'Word', |
||||
Prefix = 'Prefix', |
||||
Fuzzy = 'Fuzzy', |
||||
} |
||||
|
||||
/** |
||||
* Exact-word matching for auto-complete suggestions. |
||||
* - Returns items containing the searched text. |
||||
* @internal |
||||
*/ |
||||
const wordSearch: SearchFunction = (items: CompletionItem[], text: string): CompletionItem[] => { |
||||
return items.filter((c) => (c.filterText || c.label).includes(text)); |
||||
}; |
||||
|
||||
/** |
||||
* Prefix-based search for auto-complete suggestions. |
||||
* - Returns items starting with the searched text. |
||||
* @internal |
||||
*/ |
||||
const prefixSearch: SearchFunction = (items: CompletionItem[], text: string): CompletionItem[] => { |
||||
return items.filter((c) => (c.filterText || c.label).startsWith(text)); |
||||
}; |
||||
|
||||
/** |
||||
* Fuzzy search for auto-complete suggestions. |
||||
* - Returns items containing all letters from the search text occurring in the same order. |
||||
* - Stores highlight parts with parts of the text phrase found by fuzzy search |
||||
* @internal |
||||
*/ |
||||
const fuzzySearch: SearchFunction = (items: CompletionItem[], text: string): CompletionItem[] => { |
||||
text = text.toLowerCase(); |
||||
return items.filter((item) => { |
||||
const { distance, ranges, found } = fuzzyMatch(item.label.toLowerCase(), text); |
||||
if (!found) { |
||||
return false; |
||||
} |
||||
item.sortValue = distance; |
||||
item.highlightParts = ranges; |
||||
return true; |
||||
}); |
||||
}; |
||||
|
||||
/** |
||||
* @internal |
||||
*/ |
||||
export const SearchFunctionMap = { |
||||
[SearchFunctionType.Word]: wordSearch, |
||||
[SearchFunctionType.Prefix]: prefixSearch, |
||||
[SearchFunctionType.Fuzzy]: fuzzySearch, |
||||
}; |
Loading…
Reference in new issue