diff --git a/packages/grafana-ui/src/components/Typeahead/PartialHighlighter.test.tsx b/packages/grafana-ui/src/components/Typeahead/PartialHighlighter.test.tsx new file mode 100644 index 00000000000..1ee7c4a4281 --- /dev/null +++ b/packages/grafana-ui/src/components/Typeahead/PartialHighlighter.test.tsx @@ -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( + + ); + 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( + + ); + 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'); + }); +}); diff --git a/packages/grafana-ui/src/components/Typeahead/PartialHighlighter.tsx b/packages/grafana-ui/src/components/Typeahead/PartialHighlighter.tsx new file mode 100644 index 00000000000..c97b86b96ea --- /dev/null +++ b/packages/grafana-ui/src/components/Typeahead/PartialHighlighter.tsx @@ -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) => { + 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
{children}
; +}; diff --git a/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx b/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx index 2d6a16e62a1..fbe81f7dec8 100644 --- a/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx +++ b/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx @@ -6,6 +6,7 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme } from '@grafana/data'; import { CompletionItem, CompletionItemKind } from '../../types/completion'; import { ThemeContext } from '../../themes/ThemeContext'; +import { PartialHighlighter } from './PartialHighlighter'; interface Props { isSelected: boolean; @@ -83,7 +84,15 @@ export const TypeaheadItem: React.FC = (props: Props) => { onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} > - + {item.highlightParts !== undefined ? ( + + ) : ( + + )} ); }; diff --git a/packages/grafana-ui/src/slate-plugins/fuzzy.test.ts b/packages/grafana-ui/src/slate-plugins/fuzzy.test.ts new file mode 100644 index 00000000000..c32a848edd7 --- /dev/null +++ b/packages/grafana-ui/src/slate-plugins/fuzzy.test.ts @@ -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, + }); + }); +}); diff --git a/packages/grafana-ui/src/slate-plugins/fuzzy.ts b/packages/grafana-ui/src/slate-plugins/fuzzy.ts new file mode 100644 index 00000000000..030831defaa --- /dev/null +++ b/packages/grafana-ui/src/slate-plugins/fuzzy.ts @@ -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, + }; +} diff --git a/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx b/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx new file mode 100644 index 00000000000..a250a992032 --- /dev/null +++ b/packages/grafana-ui/src/slate-plugins/suggestions.test.tsx @@ -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) => { + // @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: {}, + }, + }; +} diff --git a/packages/grafana-ui/src/slate-plugins/suggestions.tsx b/packages/grafana-ui/src/slate-plugins/suggestions.tsx index bfd821ba0a8..49b13e0de85 100644 --- a/packages/grafana-ui/src/slate-plugins/suggestions.tsx +++ b/packages/grafana-ui/src/slate-plugins/suggestions.tsx @@ -7,8 +7,9 @@ import { Plugin as SlatePlugin } from '@grafana/slate-react'; import TOKEN_MARK from './slate-prism/TOKEN_MARK'; import { Typeahead } from '../components/Typeahead/Typeahead'; -import { CompletionItem, TypeaheadOutput, TypeaheadInput, SuggestionsState } from '../types/completion'; -import { makeFragment } from '../utils/slate'; +import { CompletionItem, SuggestionsState, TypeaheadInput, TypeaheadOutput } from '../types'; +import { makeFragment, SearchFunctionType } from '../utils'; +import { SearchFunctionMap } from '../utils/searchFunctions'; export const TYPEAHEAD_DEBOUNCE = 250; @@ -289,17 +290,16 @@ const handleTypeahead = async ( if (!group.items) { return group; } - + // Falling back to deprecated prefixMatch to support backwards compatibility with plugins using this property + const searchFunctionType = + group.searchFunctionType || (group.prefixMatch ? SearchFunctionType.Prefix : SearchFunctionType.Word); + const searchFunction = SearchFunctionMap[searchFunctionType]; let newGroup = { ...group }; if (prefix) { // Filter groups based on prefix if (!group.skipFilter) { newGroup.items = newGroup.items.filter((c) => (c.filterText || c.label).length >= prefix.length); - if (group.prefixMatch) { - newGroup.items = newGroup.items.filter((c) => (c.filterText || c.label).startsWith(prefix)); - } else { - newGroup.items = newGroup.items.filter((c) => (c.filterText || c.label).includes(prefix)); - } + newGroup.items = searchFunction(newGroup.items, prefix); } // Filter out the already typed value (prefix) unless it inserts custom text not matching the prefix @@ -309,7 +309,14 @@ const handleTypeahead = async ( } if (!group.skipSort) { - newGroup.items = sortBy(newGroup.items, (item: CompletionItem) => item.sortText || item.label); + newGroup.items = sortBy(newGroup.items, (item: CompletionItem) => { + if (item.sortText === undefined) { + return item.sortValue !== undefined ? item.sortValue : item.label; + } else { + // Falling back to deprecated sortText to support backwards compatibility with plugins using this property + return item.sortText || item.label; + } + }); } return newGroup; diff --git a/packages/grafana-ui/src/types/completion.ts b/packages/grafana-ui/src/types/completion.ts index b56c360faa5..1855889ff1e 100644 --- a/packages/grafana-ui/src/types/completion.ts +++ b/packages/grafana-ui/src/types/completion.ts @@ -1,4 +1,10 @@ import { Value, Editor as CoreEditor } from 'slate'; +import { SearchFunctionType } from '../utils'; + +/** + * @internal + */ +export type SearchFunction = (items: CompletionItem[], prefix: string) => CompletionItem[]; export interface CompletionItemGroup { /** @@ -13,9 +19,16 @@ export interface CompletionItemGroup { /** * If true, match only by prefix (and not mid-word). + * @deprecated use searchFunctionType instead */ prefixMatch?: boolean; + /** + * Function type used to create auto-complete list + * @alpha + */ + searchFunctionType?: SearchFunctionType; + /** * If true, do not filter items in this group based on the search. */ @@ -31,6 +44,14 @@ export enum CompletionItemKind { GroupTitle = 'GroupTitle', } +/** + * @internal + */ +export type HighlightPart = { + start: number; + end: number; +}; + export interface CompletionItem { /** * The label of this completion item. By default @@ -59,9 +80,23 @@ export interface CompletionItem { /** * A string that should be used when comparing this item * with other items. When `falsy` the `label` is used. + * @deprecated use sortValue instead */ sortText?: string; + /** + * A string or number that should be used when comparing this + * item with other items. When `undefined` then `label` is used. + * @alpha + */ + sortValue?: string | number; + + /** + * Parts of the label to be highlighted + * @internal + */ + highlightParts?: HighlightPart[]; + /** * A string that should be used when filtering a set of * completion items. When `falsy` the `label` is used. diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 63a19b03ddb..81f1fc7c533 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -6,6 +6,7 @@ export * from './tags'; export * from './scrollbar'; export * from './measureText'; export * from './useForceUpdate'; +export { SearchFunctionType } from './searchFunctions'; export { default as ansicolor } from './ansicolor'; import * as DOMUtil from './dom'; // includes Element.closest polyfill diff --git a/packages/grafana-ui/src/utils/searchFunctions.ts b/packages/grafana-ui/src/utils/searchFunctions.ts new file mode 100644 index 00000000000..9ceb1fd89be --- /dev/null +++ b/packages/grafana-ui/src/utils/searchFunctions.ts @@ -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, +}; diff --git a/public/app/plugins/datasource/cloudwatch/language_provider.ts b/public/app/plugins/datasource/cloudwatch/language_provider.ts index 004ee9e739b..601247f9612 100644 --- a/public/app/plugins/datasource/cloudwatch/language_provider.ts +++ b/public/app/plugins/datasource/cloudwatch/language_provider.ts @@ -18,7 +18,7 @@ import { CloudWatchQuery, TSDBResponse } from './types'; import { AbsoluteTimeRange, HistoryItem, LanguageProvider } from '@grafana/data'; import { CloudWatchDatasource } from './datasource'; -import { Token, TypeaheadInput, TypeaheadOutput } from '@grafana/ui'; +import { CompletionItemGroup, SearchFunctionType, Token, TypeaheadInput, TypeaheadOutput } from '@grafana/ui'; import Prism, { Grammar } from 'prismjs'; export type CloudWatchHistoryItem = HistoryItem; @@ -167,8 +167,12 @@ export class CloudWatchLanguageProvider extends LanguageProvider { private handleKeyword = async (context?: TypeaheadContext): Promise => { const suggs = await this.getFieldCompletionItems(context?.logGroupNames ?? []); - const functionSuggestions = [ - { prefixMatch: true, label: 'Functions', items: STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS) }, + const functionSuggestions: CompletionItemGroup[] = [ + { + searchFunctionType: SearchFunctionType.Prefix, + label: 'Functions', + items: STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS), + }, ]; suggs.suggestions.push(...functionSuggestions); @@ -244,7 +248,7 @@ export class CloudWatchLanguageProvider extends LanguageProvider { return { suggestions: [ { - prefixMatch: true, + searchFunctionType: SearchFunctionType.Prefix, label: 'Sort Order', items: [ { @@ -268,22 +272,32 @@ export class CloudWatchLanguageProvider extends LanguageProvider { }; private getCommandCompletionItems = (): TypeaheadOutput => { - return { suggestions: [{ prefixMatch: true, label: 'Commands', items: QUERY_COMMANDS }] }; + return { + suggestions: [{ searchFunctionType: SearchFunctionType.Prefix, label: 'Commands', items: QUERY_COMMANDS }], + }; }; private getFieldAndFilterFunctionCompletionItems = (): TypeaheadOutput => { - return { suggestions: [{ prefixMatch: true, label: 'Functions', items: FIELD_AND_FILTER_FUNCTIONS }] }; + return { + suggestions: [ + { searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: FIELD_AND_FILTER_FUNCTIONS }, + ], + }; }; private getStatsAggCompletionItems = (): TypeaheadOutput => { - return { suggestions: [{ prefixMatch: true, label: 'Functions', items: AGGREGATION_FUNCTIONS_STATS }] }; + return { + suggestions: [ + { searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: AGGREGATION_FUNCTIONS_STATS }, + ], + }; }; private getBoolFuncCompletionItems = (): TypeaheadOutput => { return { suggestions: [ { - prefixMatch: true, + searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: BOOLEAN_FUNCTIONS, }, @@ -295,7 +309,7 @@ export class CloudWatchLanguageProvider extends LanguageProvider { return { suggestions: [ { - prefixMatch: true, + searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: NUMERIC_OPERATORS.concat(BOOLEAN_FUNCTIONS), }, diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index fc2e720ccd6..e10cc3f2898 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -29,13 +29,13 @@ const NS_IN_MS = 1000000; // When changing RATE_RANGES, check if Prometheus/PromQL ranges should be changed too // @see public/app/plugins/datasource/prometheus/promql.ts const RATE_RANGES: CompletionItem[] = [ - { label: '$__interval', sortText: '$__interval' }, - { label: '1m', sortText: '00:01:00' }, - { label: '5m', sortText: '00:05:00' }, - { label: '10m', sortText: '00:10:00' }, - { label: '30m', sortText: '00:30:00' }, - { label: '1h', sortText: '01:00:00' }, - { label: '1d', sortText: '24:00:00' }, + { label: '$__interval', sortValue: '$__interval' }, + { label: '1m', sortValue: '00:01:00' }, + { label: '5m', sortValue: '00:05:00' }, + { label: '10m', sortValue: '00:10:00' }, + { label: '30m', sortValue: '00:30:00' }, + { label: '1h', sortValue: '01:00:00' }, + { label: '1d', sortValue: '24:00:00' }, ]; export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec diff --git a/public/app/plugins/datasource/prometheus/language_provider.test.ts b/public/app/plugins/datasource/prometheus/language_provider.test.ts index 6109e2ed231..2bbc9ddf89c 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.test.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.test.ts @@ -5,6 +5,7 @@ import { PrometheusDatasource } from './datasource'; import { HistoryItem } from '@grafana/data'; import { PromQuery } from './types'; import Mock = jest.Mock; +import { SearchFunctionType } from '@grafana/ui'; describe('Language completion provider', () => { const datasource: PrometheusDatasource = ({ @@ -123,14 +124,14 @@ describe('Language completion provider', () => { expect(result.suggestions).toMatchObject([ { items: [ - { label: '$__interval', sortText: '$__interval' }, // TODO: figure out why this row and sortText is needed - { label: '$__rate_interval', sortText: '$__rate_interval' }, - { label: '1m', sortText: '00:01:00' }, - { label: '5m', sortText: '00:05:00' }, - { label: '10m', sortText: '00:10:00' }, - { label: '30m', sortText: '00:30:00' }, - { label: '1h', sortText: '01:00:00' }, - { label: '1d', sortText: '24:00:00' }, + { label: '$__interval', sortValue: '$__interval' }, // TODO: figure out why this row and sortValue is needed + { label: '$__rate_interval', sortValue: '$__rate_interval' }, + { label: '1m', sortValue: '00:01:00' }, + { label: '5m', sortValue: '00:05:00' }, + { label: '10m', sortValue: '00:10:00' }, + { label: '30m', sortValue: '00:30:00' }, + { label: '1h', sortValue: '01:00:00' }, + { label: '1d', sortValue: '24:00:00' }, ], label: 'Range vector', }, @@ -236,7 +237,13 @@ describe('Language completion provider', () => { value: valueWithSelection, }); expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]); + expect(result.suggestions).toEqual([ + { + items: [{ label: 'job' }, { label: 'instance' }], + label: 'Labels', + searchFunctionType: SearchFunctionType.Fuzzy, + }, + ]); }); it('returns label suggestions on label context and metric', async () => { @@ -255,7 +262,9 @@ describe('Language completion provider', () => { value: valueWithSelection, }); expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + expect(result.suggestions).toEqual([ + { items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy }, + ]); }); it('returns label suggestions on label context but leaves out labels that already exist', async () => { @@ -286,7 +295,9 @@ describe('Language completion provider', () => { value: valueWithSelection, }); expect(result.context).toBe('context-labels'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + expect(result.suggestions).toEqual([ + { items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy }, + ]); }); it('returns label value suggestions inside a label value context after a negated matching operator', async () => { @@ -311,6 +322,7 @@ describe('Language completion provider', () => { { items: [{ label: 'value1' }, { label: 'value2' }], label: 'Label values for "job"', + searchFunctionType: SearchFunctionType.Fuzzy, }, ]); }); @@ -346,7 +358,9 @@ describe('Language completion provider', () => { value: valueWithSelection, }); expect(result.context).toBe('context-label-values'); - expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]); + expect(result.suggestions).toEqual([ + { items: [{ label: 'baz' }], label: 'Label values for "bar"', searchFunctionType: SearchFunctionType.Fuzzy }, + ]); }); it('returns label suggestions on aggregation context and metric w/ selector', async () => { @@ -364,7 +378,9 @@ describe('Language completion provider', () => { value: valueWithSelection, }); expect(result.context).toBe('context-aggregation'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + expect(result.suggestions).toEqual([ + { items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy }, + ]); }); it('returns label suggestions on aggregation context and metric w/o selector', async () => { @@ -382,7 +398,9 @@ describe('Language completion provider', () => { value: valueWithSelection, }); expect(result.context).toBe('context-aggregation'); - expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]); + expect(result.suggestions).toEqual([ + { items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy }, + ]); }); it('returns label suggestions inside a multi-line aggregation context', async () => { @@ -406,6 +424,7 @@ describe('Language completion provider', () => { { items: [{ label: 'bar' }], label: 'Labels', + searchFunctionType: SearchFunctionType.Fuzzy, }, ]); }); @@ -429,6 +448,7 @@ describe('Language completion provider', () => { { items: [{ label: 'bar' }], label: 'Labels', + searchFunctionType: SearchFunctionType.Fuzzy, }, ]); }); @@ -452,6 +472,7 @@ describe('Language completion provider', () => { { items: [{ label: 'bar' }], label: 'Labels', + searchFunctionType: SearchFunctionType.Fuzzy, }, ]); }); @@ -490,6 +511,7 @@ describe('Language completion provider', () => { { items: [{ label: 'bar' }], label: 'Labels', + searchFunctionType: SearchFunctionType.Fuzzy, }, ]); }); diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 95a23c79759..417b16d9b38 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -3,16 +3,16 @@ import LRU from 'lru-cache'; import { Value } from 'slate'; import { dateTime, HistoryItem, LanguageProvider } from '@grafana/data'; -import { CompletionItem, CompletionItemGroup, TypeaheadInput, TypeaheadOutput } from '@grafana/ui'; +import { CompletionItem, CompletionItemGroup, SearchFunctionType, TypeaheadInput, TypeaheadOutput } from '@grafana/ui'; import { + addLimitInfo, fixSummariesMetadata, + limitSuggestions, parseSelector, processHistogramLabels, processLabels, roundSecToMin, - addLimitInfo, - limitSuggestions, } from './language_utils'; import PromqlSyntax, { FUNCTIONS, RATE_RANGES } from './promql'; @@ -201,7 +201,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { getEmptyCompletionItems = (context: { history: Array> }): TypeaheadOutput => { const { history } = context; - const suggestions = []; + const suggestions: CompletionItemGroup[] = []; if (history && history.length) { const historyItems = _.chain(history) @@ -214,7 +214,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { .value(); suggestions.push({ - prefixMatch: true, + searchFunctionType: SearchFunctionType.Prefix, skipSort: true, label: 'History', items: historyItems, @@ -226,10 +226,10 @@ export default class PromQlLanguageProvider extends LanguageProvider { getTermCompletionItems = (): TypeaheadOutput => { const { metrics, metricsMetadata } = this; - const suggestions = []; + const suggestions: CompletionItemGroup[] = []; suggestions.push({ - prefixMatch: true, + searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: FUNCTIONS.map(setFunctionKind), }); @@ -239,6 +239,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { suggestions.push({ label: `Metrics${limitInfo}`, items: limitSuggestions(metrics).map((m) => addMetricsMetadata(m, metricsMetadata)), + searchFunctionType: SearchFunctionType.Fuzzy, }); } @@ -313,6 +314,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { suggestions.push({ label: `Labels${limitInfo}`, items: Object.keys(labelValues).map(wrapLabel), + searchFunctionType: SearchFunctionType.Fuzzy, }); } return result; @@ -379,6 +381,7 @@ export default class PromQlLanguageProvider extends LanguageProvider { suggestions.push({ label: `Label values for "${labelKey}"${limitInfo}`, items: labelValues[labelKey].map(wrapLabel), + searchFunctionType: SearchFunctionType.Fuzzy, }); } } else { @@ -391,7 +394,11 @@ export default class PromQlLanguageProvider extends LanguageProvider { context = 'context-labels'; const newItems = possibleKeys.map((key) => ({ label: key })); const limitInfo = addLimitInfo(newItems); - const newSuggestion: CompletionItemGroup = { label: `Labels${limitInfo}`, items: newItems }; + const newSuggestion: CompletionItemGroup = { + label: `Labels${limitInfo}`, + items: newItems, + searchFunctionType: SearchFunctionType.Fuzzy, + }; suggestions.push(newSuggestion); } } diff --git a/public/app/plugins/datasource/prometheus/promql.ts b/public/app/plugins/datasource/prometheus/promql.ts index f5b4f32100e..1400466551d 100644 --- a/public/app/plugins/datasource/prometheus/promql.ts +++ b/public/app/plugins/datasource/prometheus/promql.ts @@ -4,14 +4,14 @@ import { CompletionItem } from '@grafana/ui'; // When changing RATE_RANGES, check if Loki/LogQL ranges should be changed too // @see public/app/plugins/datasource/loki/language_provider.ts export const RATE_RANGES: CompletionItem[] = [ - { label: '$__interval', sortText: '$__interval' }, - { label: '$__rate_interval', sortText: '$__rate_interval' }, - { label: '1m', sortText: '00:01:00' }, - { label: '5m', sortText: '00:05:00' }, - { label: '10m', sortText: '00:10:00' }, - { label: '30m', sortText: '00:30:00' }, - { label: '1h', sortText: '01:00:00' }, - { label: '1d', sortText: '24:00:00' }, + { label: '$__interval', sortValue: '$__interval' }, + { label: '$__rate_interval', sortValue: '$__rate_interval' }, + { label: '1m', sortValue: '00:01:00' }, + { label: '5m', sortValue: '00:05:00' }, + { label: '10m', sortValue: '00:10:00' }, + { label: '30m', sortValue: '00:30:00' }, + { label: '1h', sortValue: '01:00:00' }, + { label: '1d', sortValue: '24:00:00' }, ]; export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];