diff --git a/src/plugins/data/common/es_query/kuery/types.ts b/src/plugins/data/common/es_query/kuery/types.ts index 86cb7e08a767c..63c52bb64dc65 100644 --- a/src/plugins/data/common/es_query/kuery/types.ts +++ b/src/plugins/data/common/es_query/kuery/types.ts @@ -33,6 +33,8 @@ export interface KueryParseOptions { startRule: string; allowLeadingWildcards: boolean; errorOnLuceneSyntax: boolean; + cursorSymbol?: string; + parseCursor?: boolean; } export { nodeTypes } from './node_types'; diff --git a/src/plugins/data/public/autocomplete/autocomplete_service.ts b/src/plugins/data/public/autocomplete/autocomplete_service.ts index 0527f833b0f8c..78bd2ec85f477 100644 --- a/src/plugins/data/public/autocomplete/autocomplete_service.ts +++ b/src/plugins/data/public/autocomplete/autocomplete_service.ts @@ -75,3 +75,9 @@ export class AutocompleteService { this.querySuggestionProviders.clear(); } } + +/** @public **/ +export type AutocompleteSetup = ReturnType; + +/** @public **/ +export type AutocompleteStart = ReturnType; diff --git a/src/plugins/data/public/autocomplete/index.ts b/src/plugins/data/public/autocomplete/index.ts index 5b8f3ae510bfd..c2b21e84b7a38 100644 --- a/src/plugins/data/public/autocomplete/index.ts +++ b/src/plugins/data/public/autocomplete/index.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import * as autocomplete from './static'; +export { AutocompleteService, AutocompleteSetup, AutocompleteStart } from './autocomplete_service'; -export { AutocompleteService } from './autocomplete_service'; -export { QuerySuggestion, QuerySuggestionType, QuerySuggestionsGetFn } from './types'; +export { autocomplete }; diff --git a/src/plugins/data/public/autocomplete/providers/query_suggestion_provider.ts b/src/plugins/data/public/autocomplete/providers/query_suggestion_provider.ts index 53abdd44c0c3f..22b684c69e188 100644 --- a/src/plugins/data/public/autocomplete/providers/query_suggestion_provider.ts +++ b/src/plugins/data/public/autocomplete/providers/query_suggestion_provider.ts @@ -19,13 +19,20 @@ import { IFieldType, IIndexPattern } from '../../../common/index_patterns'; -export type QuerySuggestionType = 'field' | 'value' | 'operator' | 'conjunction' | 'recentSearch'; +export enum QuerySuggestionsTypes { + field = 'field', + value = 'value', + operator = 'operator', + conjunction = 'conjunction', + recentSearch = 'recentSearch', +} export type QuerySuggestionsGetFn = ( args: QuerySuggestionsGetFnArgs ) => Promise | undefined; -interface QuerySuggestionsGetFnArgs { +/** @public **/ +export interface QuerySuggestionsGetFnArgs { language: string; indexPatterns: IIndexPattern[]; query: string; @@ -35,22 +42,21 @@ interface QuerySuggestionsGetFnArgs { boolFilter?: any; } -interface BasicQuerySuggestion { - type: QuerySuggestionType; - description?: string; +/** @public **/ +export interface BasicQuerySuggestion { + type: QuerySuggestionsTypes; + description?: string | JSX.Element; end: number; start: number; text: string; cursorIndex?: number; } -interface FieldQuerySuggestion extends BasicQuerySuggestion { - type: 'field'; +/** @public **/ +export interface FieldQuerySuggestion extends BasicQuerySuggestion { + type: QuerySuggestionsTypes.field; field: IFieldType; } -// A union type allows us to do easy type guards in the code. For example, if I want to ensure I'm -// working with a FieldAutocompleteSuggestion, I can just do `if ('field' in suggestion)` and the -// TypeScript compiler will narrow the type to the parts of the union that have a field prop. /** @public **/ export type QuerySuggestion = BasicQuerySuggestion | FieldQuerySuggestion; diff --git a/src/plugins/data/public/autocomplete/types.ts b/src/plugins/data/public/autocomplete/static.ts similarity index 76% rename from src/plugins/data/public/autocomplete/types.ts rename to src/plugins/data/public/autocomplete/static.ts index 759e2dd25a5bc..7d627486c6d65 100644 --- a/src/plugins/data/public/autocomplete/types.ts +++ b/src/plugins/data/public/autocomplete/static.ts @@ -17,17 +17,11 @@ * under the License. */ -import { AutocompleteService } from './autocomplete_service'; - -/** @public **/ -export type AutocompleteSetup = ReturnType; - -/** @public **/ -export type AutocompleteStart = ReturnType; - -/** @public **/ export { QuerySuggestion, + QuerySuggestionsTypes, QuerySuggestionsGetFn, - QuerySuggestionType, + QuerySuggestionsGetFnArgs, + BasicQuerySuggestion, + FieldQuerySuggestion, } from './providers/query_suggestion_provider'; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index bc25c64f0e96e..2fa6b8deae69d 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -18,7 +18,6 @@ */ import { PluginInitializerContext } from '../../../core/public'; -import * as autocomplete from './autocomplete'; export function plugin(initializerContext: PluginInitializerContext) { return new DataPublicPlugin(initializerContext); @@ -44,7 +43,7 @@ export { RefreshInterval, TimeRange, } from '../common'; - +export { autocomplete } from './autocomplete'; export * from './field_formats'; export * from './index_patterns'; export * from './search'; @@ -70,5 +69,3 @@ export { // Export plugin after all other imports import { DataPublicPlugin } from './plugin'; export { DataPublicPlugin as Plugin }; - -export { autocomplete }; diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index 6b6ff5e62e63f..e62aba5f2713d 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -20,7 +20,7 @@ import { CoreStart } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { IUiActionsSetup, IUiActionsStart } from 'src/plugins/ui_actions/public'; -import { AutocompleteSetup, AutocompleteStart } from './autocomplete/types'; +import { AutocompleteSetup, AutocompleteStart } from './autocomplete'; import { FieldFormatsSetup, FieldFormatsStart } from './field_formats'; import { ISearchSetup, ISearchStart } from './search'; import { QuerySetup, QueryStart } from './query'; diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index cf219c35bcced..ea2785d6675a3 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -89,8 +89,6 @@ const KEY_CODES = { END: 35, }; -const recentSearchType: autocomplete.QuerySuggestionType = 'recentSearch'; - export class QueryStringInputUI extends Component { public state: State = { isSuggestionsVisible: false, @@ -193,7 +191,7 @@ export class QueryStringInputUI extends Component { const text = toUser(recentSearch); const start = 0; const end = query.length; - return { type: recentSearchType, text, start, end }; + return { type: autocomplete.QuerySuggestionsTypes.recentSearch, text, start, end }; }); }; @@ -343,7 +341,7 @@ export class QueryStringInputUI extends Component { selectionEnd: start + (cursorIndex ? cursorIndex : text.length), }); - if (type === recentSearchType) { + if (type === autocomplete.QuerySuggestionsTypes.recentSearch) { this.setState({ isSuggestionsVisible: false, index: null }); this.onSubmit({ query: newQueryString, language: this.props.query.language }); } diff --git a/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx b/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx index 0c5c701642757..3469671636041 100644 --- a/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx @@ -31,7 +31,7 @@ const mockSuggestion: autocomplete.QuerySuggestion = { end: 0, start: 42, text: 'as promised, not helpful', - type: 'value', + type: autocomplete.QuerySuggestionsTypes.value, }; describe('SuggestionComponent', () => { diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx index b84f612b6d13a..f933f7d2c39ac 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx @@ -33,14 +33,14 @@ const mockSuggestions: autocomplete.QuerySuggestion[] = [ end: 0, start: 42, text: 'as promised, not helpful', - type: 'value', + type: autocomplete.QuerySuggestionsTypes.value, }, { description: 'This is another unhelpful suggestion', end: 0, start: 42, text: 'yep', - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, }, ]; diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/conjunction.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/conjunction.js deleted file mode 100644 index 94990edef5e82..0000000000000 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/conjunction.js +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getSuggestionsProvider } from '../conjunction'; - -describe('Kuery conjunction suggestions', function() { - const getSuggestions = getSuggestionsProvider(); - - it('should return a function', function() { - expect(typeof getSuggestions).to.be('function'); - }); - - it('should not suggest anything for phrases not ending in whitespace', function() { - const text = 'foo'; - const suggestions = getSuggestions({ text }); - expect(suggestions).to.eql([]); - }); - - it('should suggest and/or for phrases ending in whitespace', function() { - const text = 'foo '; - const suggestions = getSuggestions({ text }); - expect(suggestions.length).to.be(2); - expect(suggestions.map(suggestion => suggestion.text)).to.eql(['and ', 'or ']); - }); - - it('should suggest to insert the suggestion at the end of the string', function() { - const text = 'bar '; - const end = text.length; - const suggestions = getSuggestions({ text, end }); - expect(suggestions.length).to.be(2); - expect(suggestions.map(suggestion => suggestion.start)).to.eql([end, end]); - expect(suggestions.map(suggestion => suggestion.end)).to.eql([end, end]); - }); - it('should have descriptions', function() { - const text = ' '; - const suggestions = getSuggestions({ text }); - expect(typeof suggestions).to.be('object'); - expect(Object.keys(suggestions).length).to.be(2); - suggestions.forEach(suggestion => { - expect(typeof suggestion).to.be('object'); - expect(suggestion).to.have.property('description'); - }); - }); -}); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/field.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/field.js deleted file mode 100644 index 6dc07da68a5ea..0000000000000 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/field.js +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getSuggestionsProvider } from '../field'; -import indexPatternResponse from '../__fixtures__/index_pattern_response.json'; -import { isFilterable } from '../../../../../../../src/plugins/data/public'; - -describe('Kuery field suggestions', function() { - let indexPattern; - let indexPatterns; - let getSuggestions; - - beforeEach(() => { - indexPattern = indexPatternResponse; - indexPatterns = [indexPattern]; - getSuggestions = getSuggestionsProvider({ indexPatterns }); - }); - - it('should return a function', function() { - expect(typeof getSuggestions).to.be('function'); - }); - - it('should return filterable fields', function() { - const prefix = ''; - const suffix = ''; - const suggestions = getSuggestions({ prefix, suffix }); - const filterableFields = indexPattern.fields.filter(isFilterable); - expect(suggestions.length).to.be(filterableFields.length); - }); - - it('should filter suggestions based on the query', () => { - const prefix = 'machine'; - const suffix = ''; - const suggestions = getSuggestions({ prefix, suffix }); - expect(suggestions.find(({ text }) => text === 'machine.os ')).to.be.ok(); - }); - - it('should filter suggestions case insensitively', () => { - const prefix = 'MACHINE'; - const suffix = ''; - const suggestions = getSuggestions({ prefix, suffix }); - expect(suggestions.find(({ text }) => text === 'machine.os ')).to.be.ok(); - }); - - it('should return suggestions where the query matches somewhere in the middle', () => { - const prefix = '.'; - const suffix = ''; - const suggestions = getSuggestions({ prefix, suffix }); - expect(suggestions.find(({ text }) => text === 'machine.os ')).to.be.ok(); - }); - - it('should return field names that start with the query first', () => { - const prefix = 'e'; - const suffix = ''; - const suggestions = getSuggestions({ prefix, suffix }); - const extensionIndex = suggestions.findIndex(({ text }) => text === 'extension '); - const bytesIndex = suggestions.findIndex(({ text }) => text === 'bytes '); - expect(extensionIndex).to.be.lessThan(bytesIndex); - }); - - it('should sort keyword fields before analyzed versions', () => { - const prefix = ''; - const suffix = ''; - const suggestions = getSuggestions({ prefix, suffix }); - const analyzedIndex = suggestions.findIndex(({ text }) => text === 'machine.os '); - const keywordIndex = suggestions.findIndex(({ text }) => text === 'machine.os.raw '); - expect(keywordIndex).to.be.lessThan(analyzedIndex); - }); - - it('should have descriptions', function() { - const prefix = ''; - const suffix = ''; - const suggestions = getSuggestions({ prefix, suffix }); - expect(suggestions.length).to.be.greaterThan(0); - suggestions.forEach(suggestion => { - expect(suggestion).to.have.property('description'); - }); - }); - - describe('nested fields', function() { - it("should automatically wrap nested fields in KQL's nested syntax", () => { - const prefix = 'ch'; - const suffix = ''; - const suggestions = getSuggestions({ prefix, suffix }); - - const suggestion = suggestions.find(({ field }) => field.name === 'nestedField.child'); - expect(suggestion.text).to.be('nestedField:{ child }'); - - // For most suggestions the cursor can be placed at the end of the suggestion text, but - // for the nested field syntax we want to place the cursor inside the curly braces - expect(suggestion.cursorIndex).to.be(20); - }); - - it('should narrow suggestions to children of a nested path if provided', () => { - const prefix = 'ch'; - const suffix = ''; - - const allSuggestions = getSuggestions({ prefix, suffix }); - expect(allSuggestions.length).to.be.greaterThan(2); - - const nestedSuggestions = getSuggestions({ prefix, suffix, nestedPath: 'nestedField' }); - expect(nestedSuggestions).to.have.length(2); - }); - - it("should not wrap the suggestion in KQL's nested syntax if the correct nested path is already provided", () => { - const prefix = 'ch'; - const suffix = ''; - - const suggestions = getSuggestions({ prefix, suffix, nestedPath: 'nestedField' }); - const suggestion = suggestions.find(({ field }) => field.name === 'nestedField.child'); - expect(suggestion.text).to.be('child '); - }); - - it('should handle fields nested multiple levels deep', () => { - const prefix = 'doubly'; - const suffix = ''; - - const suggestionsWithNoPath = getSuggestions({ prefix, suffix }); - expect(suggestionsWithNoPath).to.have.length(1); - const [noPathSuggestion] = suggestionsWithNoPath; - expect(noPathSuggestion.text).to.be('nestedField.nestedChild:{ doublyNestedChild }'); - - const suggestionsWithPartialPath = getSuggestions({ - prefix, - suffix, - nestedPath: 'nestedField', - }); - expect(suggestionsWithPartialPath).to.have.length(1); - const [partialPathSuggestion] = suggestionsWithPartialPath; - expect(partialPathSuggestion.text).to.be('nestedChild:{ doublyNestedChild }'); - - const suggestionsWithFullPath = getSuggestions({ - prefix, - suffix, - nestedPath: 'nestedField.nestedChild', - }); - expect(suggestionsWithFullPath).to.have.length(1); - const [fullPathSuggestion] = suggestionsWithFullPath; - expect(fullPathSuggestion.text).to.be('doublyNestedChild '); - }); - }); -}); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/operator.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/operator.js deleted file mode 100644 index c248e3e8366a9..0000000000000 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/operator.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getSuggestionsProvider } from '../operator'; -import indexPatternResponse from '../__fixtures__/index_pattern_response.json'; - -describe('Kuery operator suggestions', function() { - let indexPatterns; - let getSuggestions; - - beforeEach(() => { - indexPatterns = [indexPatternResponse]; - getSuggestions = getSuggestionsProvider({ indexPatterns }); - }); - - it('should return a function', function() { - expect(typeof getSuggestions).to.be('function'); - }); - - it('should not return suggestions for non-fields', () => { - const fieldName = 'foo'; - const suggestions = getSuggestions({ fieldName }); - expect(suggestions.length).to.eql([]); - }); - - it('should return exists for every field', () => { - const fieldName = 'custom_user_field'; - const suggestions = getSuggestions({ fieldName }); - expect(suggestions.length).to.eql(1); - expect(suggestions[0].text).to.be(': * '); - }); - - it('should return equals for string fields', () => { - const fieldName = 'machine.os'; - const suggestions = getSuggestions({ fieldName }); - expect(suggestions.find(({ text }) => text === ': ')).to.be.ok(); - expect(suggestions.find(({ text }) => text === '< ')).to.not.be.ok(); - }); - - it('should return numeric operators for numeric fields', () => { - const fieldName = 'bytes'; - const suggestions = getSuggestions({ fieldName }); - expect(suggestions.find(({ text }) => text === ': ')).to.be.ok(); - expect(suggestions.find(({ text }) => text === '< ')).to.be.ok(); - }); - - it('should have descriptions', function() { - const fieldName = 'bytes'; - const suggestions = getSuggestions({ fieldName }); - expect(suggestions.length).to.be.greaterThan(0); - suggestions.forEach(suggestion => { - expect(suggestion).to.have.property('description'); - }); - }); - - it('should handle nested paths', () => { - const suggestions = getSuggestions({ fieldName: 'child', nestedPath: 'nestedField' }); - expect(suggestions.length).to.be.greaterThan(0); - }); -}); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/conjunction.test.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/conjunction.test.ts new file mode 100644 index 0000000000000..816ad53fb478f --- /dev/null +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/conjunction.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setupGetConjunctionSuggestions } from './conjunction'; +import { autocomplete, esKuery } from '../../../../../../src/plugins/data/public'; +import { coreMock } from '../../../../../../src/core/public/mocks'; + +const mockKueryNode = (kueryNode: Partial) => + (kueryNode as unknown) as esKuery.KueryNode; + +describe('Kuery conjunction suggestions', () => { + const querySuggestionsArgs = (null as unknown) as autocomplete.QuerySuggestionsGetFnArgs; + let getSuggestions: ReturnType; + + beforeEach(() => { + getSuggestions = setupGetConjunctionSuggestions(coreMock.createSetup()); + }); + + it('should return a function', function() { + expect(typeof getSuggestions).toBe('function'); + }); + + it('should not suggest anything for phrases not ending in whitespace', async () => { + const text = 'foo'; + const suggestions = await getSuggestions(querySuggestionsArgs, mockKueryNode({ text })); + + expect(suggestions).toEqual([]); + }); + + it('should suggest and/or for phrases ending in whitespace', async () => { + const text = 'foo '; + const suggestions = await getSuggestions(querySuggestionsArgs, mockKueryNode({ text })); + + expect(suggestions.length).toBe(2); + expect(suggestions.map(suggestion => suggestion.text)).toEqual(['and ', 'or ']); + }); + + it('should suggest to insert the suggestion at the end of the string', async () => { + const text = 'bar '; + const end = text.length; + const suggestions = await getSuggestions(querySuggestionsArgs, mockKueryNode({ text, end })); + + expect(suggestions.length).toBe(2); + expect(suggestions.map(suggestion => suggestion.start)).toEqual([end, end]); + expect(suggestions.map(suggestion => suggestion.end)).toEqual([end, end]); + }); + + it('should have descriptions', async () => { + const text = ' '; + const suggestions = await getSuggestions(querySuggestionsArgs, mockKueryNode({ text })); + + expect(typeof suggestions).toBe('object'); + expect(Object.keys(suggestions).length).toBe(2); + + suggestions.forEach(suggestion => { + expect(typeof suggestion).toBe('object'); + expect(suggestion).toHaveProperty('description'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/conjunction.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/conjunction.tsx similarity index 72% rename from x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/conjunction.js rename to x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/conjunction.tsx index 66f4e6f8eb341..b4c018f9aa85b 100644 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/conjunction.js +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/conjunction.tsx @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import { $Keys } from 'utility-types'; import { FormattedMessage } from '@kbn/i18n/react'; - -const type = 'conjunction'; +import { KqlQuerySuggestionProvider } from './types'; +import { autocomplete } from '../../../../../../src/plugins/data/public'; const bothArgumentsText = ( ); -const conjunctions = { +const conjunctions: Record = { and: (

{ + return (querySuggestionsArgs, { text, end }) => { + let suggestions: autocomplete.QuerySuggestion[] | [] = []; + + if (text.endsWith(' ')) { + suggestions = Object.keys(conjunctions).map((key: $Keys) => ({ + type: autocomplete.QuerySuggestionsTypes.conjunction, + text: `${key} `, + description: conjunctions[key], + start: end, + end, + })); + } -export function getSuggestionsProvider() { - return function getConjunctionSuggestions({ text, end }) { - if (!text.endsWith(' ')) return []; - const suggestions = Object.keys(conjunctions).map(conjunction => { - const text = `${conjunction} `; - const description = getDescription(conjunction); - return { type, text, description, start: end, end }; - }); - return suggestions; + return Promise.resolve(suggestions); }; -} +}; diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/field.test.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/field.test.ts new file mode 100644 index 0000000000000..2fd5cfd17eb69 --- /dev/null +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/field.test.ts @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import indexPatternResponse from './__fixtures__/index_pattern_response.json'; + +import { setupGetFieldSuggestions } from './field'; +import { isFilterable, autocomplete, esKuery } from '../../../../../../src/plugins/data/public'; +import { coreMock } from '../../../../../../src/core/public/mocks'; + +const mockKueryNode = (kueryNode: Partial) => + (kueryNode as unknown) as esKuery.KueryNode; + +describe('Kuery field suggestions', () => { + let querySuggestionsArgs: autocomplete.QuerySuggestionsGetFnArgs; + let getSuggestions: ReturnType; + + beforeEach(() => { + querySuggestionsArgs = ({ + indexPatterns: [indexPatternResponse], + } as unknown) as autocomplete.QuerySuggestionsGetFnArgs; + + getSuggestions = setupGetFieldSuggestions(coreMock.createSetup()); + }); + + test('should return a function', () => { + expect(typeof getSuggestions).toBe('function'); + }); + + test('should return filterable fields', async () => { + const prefix = ''; + const suffix = ''; + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + const filterableFields = indexPatternResponse.fields.filter(isFilterable); + + expect(suggestions.length).toBe(filterableFields.length); + }); + + test('should filter suggestions based on the query', async () => { + const prefix = 'machine'; + const suffix = ''; + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + + expect(suggestions.find(({ text }) => text === 'machine.os ')).toBeDefined(); + }); + + test('should filter suggestions case insensitively', async () => { + const prefix = 'MACHINE'; + const suffix = ''; + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + + expect(suggestions.find(({ text }) => text === 'machine.os ')).toBeDefined(); + }); + + test('should return suggestions where the query matches somewhere in the middle', async () => { + const prefix = '.'; + const suffix = ''; + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + + expect(suggestions.find(({ text }) => text === 'machine.os ')).toBeDefined(); + }); + + test('should return field names that start with the query first', async () => { + const prefix = 'e'; + const suffix = ''; + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + const extensionIndex = suggestions.findIndex(({ text }) => text === 'extension '); + const bytesIndex = suggestions.findIndex(({ text }) => text === 'bytes '); + + expect(extensionIndex).toBeLessThan(bytesIndex); + }); + + test('should sort keyword fields before analyzed versions', async () => { + const prefix = ''; + const suffix = ''; + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + const analyzedIndex = suggestions.findIndex(({ text }) => text === 'machine.os '); + const keywordIndex = suggestions.findIndex(({ text }) => text === 'machine.os.raw '); + + expect(keywordIndex).toBeLessThan(analyzedIndex); + }); + + test('should have descriptions', async () => { + const prefix = ''; + const suffix = ''; + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + expect(suggestions.length).toBeGreaterThan(0); + suggestions.forEach(suggestion => { + expect(suggestion).toHaveProperty('description'); + }); + }); + + describe('nested fields', () => { + test("should automatically wrap nested fields in KQL's nested syntax", async () => { + const prefix = 'ch'; + const suffix = ''; + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + + const suggestion = suggestions.find(({ field }) => field.name === 'nestedField.child'); + + expect(suggestion).toBeDefined(); + + if (suggestion) { + expect(suggestion.text).toBe('nestedField:{ child }'); + + // For most suggestions the cursor can be placed at the end of the suggestion text, but + // for the nested field syntax we want to place the cursor inside the curly braces + expect(suggestion.cursorIndex).toBe(20); + } + }); + + test('should narrow suggestions to children of a nested path if provided', async () => { + const prefix = 'ch'; + const suffix = ''; + + const allSuggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + expect(allSuggestions.length).toBeGreaterThan(2); + + const nestedSuggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + prefix, + suffix, + nestedPath: 'nestedField', + }) + ); + expect(nestedSuggestions).toHaveLength(2); + }); + + test("should not wrap the suggestion in KQL's nested syntax if the correct nested path is already provided", async () => { + const prefix = 'ch'; + const suffix = ''; + + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + prefix, + suffix, + nestedPath: 'nestedField', + }) + ); + const suggestion = suggestions.find(({ field }) => field.name === 'nestedField.child'); + + expect(suggestion).toBeDefined(); + + if (suggestion) { + expect(suggestion.text).toBe('child '); + } + }); + + test('should handle fields nested multiple levels deep', async () => { + const prefix = 'doubly'; + const suffix = ''; + + const suggestionsWithNoPath = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ prefix, suffix }) + ); + expect(suggestionsWithNoPath).toHaveLength(1); + const [noPathSuggestion] = suggestionsWithNoPath; + expect(noPathSuggestion.text).toBe('nestedField.nestedChild:{ doublyNestedChild }'); + + const suggestionsWithPartialPath = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + prefix, + suffix, + nestedPath: 'nestedField', + }) + ); + expect(suggestionsWithPartialPath).toHaveLength(1); + const [partialPathSuggestion] = suggestionsWithPartialPath; + expect(partialPathSuggestion.text).toBe('nestedChild:{ doublyNestedChild }'); + + const suggestionsWithFullPath = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + prefix, + suffix, + nestedPath: 'nestedField.nestedChild', + }) + ); + expect(suggestionsWithFullPath).toHaveLength(1); + const [fullPathSuggestion] = suggestionsWithFullPath; + expect(fullPathSuggestion.text).toBe('doublyNestedChild '); + }); + }); +}); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/field.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/field.tsx similarity index 66% rename from x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/field.js rename to x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/field.tsx index 3e5c92dfc007f..cff378ee4a9f1 100644 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/field.js +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/field.tsx @@ -5,32 +5,42 @@ */ import React from 'react'; import { flatten } from 'lodash'; -import { escapeKuery } from './escape_kuery'; -import { sortPrefixFirst } from './sort_prefix_first'; -import { isFilterable } from '../../../../../../src/plugins/data/public'; import { FormattedMessage } from '@kbn/i18n/react'; +import { escapeKuery } from './lib/escape_kuery'; +import { sortPrefixFirst } from './sort_prefix_first'; +import { IFieldType, isFilterable, autocomplete } from '../../../../../../src/plugins/data/public'; +import { KqlQuerySuggestionProvider } from './types'; -const type = 'field'; - -function getDescription(fieldName) { +const getDescription = (field: IFieldType) => { return (

{fieldName} }} + values={{ fieldName: {field.name} }} />

); -} +}; -export function getSuggestionsProvider({ indexPatterns }) { - const allFields = flatten( - indexPatterns.map(indexPattern => { - return indexPattern.fields.filter(isFilterable); - }) - ); - return function getFieldSuggestions({ start, end, prefix, suffix, nestedPath = '' }) { +const keywordComparator = (first: IFieldType, second: IFieldType) => { + const extensions = ['raw', 'keyword']; + if (extensions.map(ext => `${first.name}.${ext}`).includes(second.name)) { + return 1; + } else if (extensions.map(ext => `${second.name}.${ext}`).includes(first.name)) { + return -1; + } + + return first.name.localeCompare(second.name); +}; + +export const setupGetFieldSuggestions: KqlQuerySuggestionProvider = core => { + return ({ indexPatterns }, { start, end, prefix, suffix, nestedPath = '' }) => { + const allFields = flatten( + indexPatterns.map(indexPattern => { + return indexPattern.fields.filter(isFilterable); + }) + ); const search = `${prefix}${suffix}`.trim().toLowerCase(); const matchingFields = allFields.filter(field => { return ( @@ -44,7 +54,8 @@ export function getSuggestionsProvider({ indexPatterns }) { ); }); const sortedFields = sortPrefixFirst(matchingFields.sort(keywordComparator), search, 'name'); - const suggestions = sortedFields.map(field => { + + const suggestions: autocomplete.FieldQuerySuggestion[] = sortedFields.map(field => { const remainingPath = field.subType && field.subType.nested ? field.subType.nested.path.slice(nestedPath ? nestedPath.length + 1 : 0) @@ -55,23 +66,23 @@ export function getSuggestionsProvider({ indexPatterns }) { field.name.slice(field.subType.nested.path.length + 1) )} }` : `${escapeKuery(field.name.slice(nestedPath ? nestedPath.length + 1 : 0))} `; - const description = getDescription(field.name); + const description = getDescription(field); const cursorIndex = field.subType && field.subType.nested && remainingPath.length > 0 ? text.length - 2 : text.length; - return { type, text, description, start, end, cursorIndex, field }; + + return { + type: autocomplete.QuerySuggestionsTypes.field, + text, + description, + start, + end, + cursorIndex, + field, + }; }); - return suggestions; - }; -} -function keywordComparator(first, second) { - const extensions = ['raw', 'keyword']; - if (extensions.map(ext => `${first.name}.${ext}`).includes(second.name)) { - return 1; - } else if (extensions.map(ext => `${second.name}.${ext}`).includes(first.name)) { - return -1; - } - return first.name.localeCompare(second.name); -} + return Promise.resolve(suggestions); + }; +}; diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/index.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/index.js deleted file mode 100644 index b877f9eb852d5..0000000000000 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/index.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { flatten, uniq } from 'lodash'; -import { getSuggestionsProvider as field } from './field'; -import { getSuggestionsProvider as value } from './value'; -import { getSuggestionsProvider as operator } from './operator'; -import { getSuggestionsProvider as conjunction } from './conjunction'; -import { esKuery } from '../../../../../../src/plugins/data/public'; - -const cursorSymbol = '@kuery-cursor@'; -const providers = { - field, - value, - operator, - conjunction, -}; - -function dedup(suggestions) { - return uniq(suggestions, ({ type, text, start, end }) => [type, text, start, end].join('|')); -} - -const getProviderByType = (type, args) => providers[type](args); - -export const setupKqlQuerySuggestionProvider = ({ uiSettings }) => ({ - indexPatterns, - boolFilter, - query, - selectionStart, - selectionEnd, - signal, -}) => { - const cursoredQuery = `${query.substr(0, selectionStart)}${cursorSymbol}${query.substr( - selectionEnd - )}`; - - let cursorNode; - try { - cursorNode = esKuery.fromKueryExpression(cursoredQuery, { cursorSymbol, parseCursor: true }); - } catch (e) { - cursorNode = {}; - } - - const { suggestionTypes = [] } = cursorNode; - const suggestionsByType = suggestionTypes.map(type => - getProviderByType(type, { - config: uiSettings, - indexPatterns, - boolFilter, - })(cursorNode, signal) - ); - return Promise.all(suggestionsByType).then(suggestionsByType => - dedup(flatten(suggestionsByType)) - ); -}; diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/index.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/index.ts new file mode 100644 index 0000000000000..2cc15fe4c9280 --- /dev/null +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/index.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'kibana/public'; +import { $Keys } from 'utility-types'; +import { flatten, uniq } from 'lodash'; +import { setupGetFieldSuggestions } from './field'; +import { setupGetValueSuggestions } from './value'; +import { setupGetOperatorSuggestions } from './operator'; +import { setupGetConjunctionSuggestions } from './conjunction'; +import { esKuery, autocomplete } from '../../../../../../src/plugins/data/public'; + +const cursorSymbol = '@kuery-cursor@'; + +const dedup = (suggestions: autocomplete.QuerySuggestion[]): autocomplete.QuerySuggestion[] => + uniq(suggestions, ({ type, text, start, end }) => [type, text, start, end].join('|')); + +export const setupKqlQuerySuggestionProvider = ( + core: CoreSetup +): autocomplete.QuerySuggestionsGetFn => { + const providers = { + field: setupGetFieldSuggestions(core), + value: setupGetValueSuggestions(core), + operator: setupGetOperatorSuggestions(core), + conjunction: setupGetConjunctionSuggestions(core), + }; + + const getSuggestionsByType = ( + cursoredQuery: string, + querySuggestionsArgs: autocomplete.QuerySuggestionsGetFnArgs + ): Array> | [] => { + try { + const cursorNode = esKuery.fromKueryExpression(cursoredQuery, { + cursorSymbol, + parseCursor: true, + }); + + return cursorNode.suggestionTypes.map((type: $Keys) => + providers[type](querySuggestionsArgs, cursorNode) + ); + } catch (e) { + return []; + } + }; + + return querySuggestionsArgs => { + const { query, selectionStart, selectionEnd } = querySuggestionsArgs; + const cursoredQuery = `${query.substr(0, selectionStart)}${cursorSymbol}${query.substr( + selectionEnd + )}`; + + return Promise.all( + getSuggestionsByType(cursoredQuery, querySuggestionsArgs) + ).then(suggestionsByType => dedup(flatten(suggestionsByType))); + }; +}; diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/escape_kuery.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/lib/escape_kuery.test.ts similarity index 55% rename from x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/escape_kuery.js rename to x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/lib/escape_kuery.test.ts index 2127194c9a890..a4a1d977a207f 100644 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/__tests__/escape_kuery.js +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/lib/escape_kuery.test.ts @@ -4,55 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { escapeQuotes, escapeKuery } from '../escape_kuery'; +import { escapeQuotes, escapeKuery } from './escape_kuery'; -describe('Kuery escape', function() { - it('should escape quotes', function() { +describe('Kuery escape', () => { + test('should escape quotes', () => { const value = 'I said, "Hello."'; const expected = 'I said, \\"Hello.\\"'; - expect(escapeQuotes(value)).to.be(expected); + + expect(escapeQuotes(value)).toBe(expected); }); - it('should escape special characters', function() { + test('should escape special characters', () => { const value = `This \\ has (a lot of) characters, don't you *think*? "Yes."`; const expected = `This \\\\ has \\(a lot of\\) \\ characters, don't you \\*think\\*? \\"Yes.\\"`; - expect(escapeKuery(value)).to.be(expected); + + expect(escapeKuery(value)).toBe(expected); }); - it('should escape keywords', function() { + test('should escape keywords', () => { const value = 'foo and bar or baz not qux'; const expected = 'foo \\and bar \\or baz \\not qux'; - expect(escapeKuery(value)).to.be(expected); + + expect(escapeKuery(value)).toBe(expected); }); - it('should escape keywords next to each other', function() { + test('should escape keywords next to each other', () => { const value = 'foo and bar or not baz'; const expected = 'foo \\and bar \\or \\not baz'; - expect(escapeKuery(value)).to.be(expected); + + expect(escapeKuery(value)).toBe(expected); }); - it('should not escape keywords without surrounding spaces', function() { + test('should not escape keywords without surrounding spaces', () => { const value = 'And this has keywords, or does it not?'; const expected = 'And this has keywords, \\or does it not?'; - expect(escapeKuery(value)).to.be(expected); + + expect(escapeKuery(value)).toBe(expected); }); - it('should escape uppercase keywords', function() { + test('should escape uppercase keywords', () => { const value = 'foo AND bar'; const expected = 'foo \\AND bar'; - expect(escapeKuery(value)).to.be(expected); + + expect(escapeKuery(value)).toBe(expected); }); - it('should escape both keywords and special characters', function() { + test('should escape both keywords and special characters', () => { const value = 'Hello, world, and to meet you!'; const expected = 'Hello, world, \\and \\ to meet you!'; - expect(escapeKuery(value)).to.be(expected); + + expect(escapeKuery(value)).toBe(expected); }); - it('should escape newlines and tabs', () => { + test('should escape newlines and tabs', () => { const value = 'This\nhas\tnewlines\r\nwith\ttabs'; const expected = 'This\\nhas\\tnewlines\\r\\nwith\\ttabs'; - expect(escapeKuery(value)).to.be(expected); + + expect(escapeKuery(value)).toBe(expected); }); }); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/escape_kuery.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/lib/escape_kuery.ts similarity index 57% rename from x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/escape_kuery.js rename to x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/lib/escape_kuery.ts index 5d9bfe6143c22..a00082f8c7d7c 100644 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/escape_kuery.js +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/lib/escape_kuery.ts @@ -6,29 +6,29 @@ import { flow } from 'lodash'; -export function escapeQuotes(string) { - return string.replace(/"/g, '\\"'); +export function escapeQuotes(str: string) { + return str.replace(/"/g, '\\"'); } export const escapeKuery = flow(escapeSpecialCharacters, escapeAndOr, escapeNot, escapeWhitespace); // See the SpecialCharacter rule in kuery.peg -function escapeSpecialCharacters(string) { - return string.replace(/[\\():<>"*]/g, '\\$&'); // $& means the whole matched string +function escapeSpecialCharacters(str: string) { + return str.replace(/[\\():<>"*]/g, '\\$&'); // $& means the whole matched string } // See the Keyword rule in kuery.peg -function escapeAndOr(string) { - return string.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); +function escapeAndOr(str: string) { + return str.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); } -function escapeNot(string) { - return string.replace(/not(\s+)/gi, '\\$&'); +function escapeNot(str: string) { + return str.replace(/not(\s+)/gi, '\\$&'); } // See the Space rule in kuery.peg -function escapeWhitespace(string) { - return string +function escapeWhitespace(str: string) { + return str .replace(/\t/g, '\\t') .replace(/\r/g, '\\r') .replace(/\n/g, '\\n'); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/operator.test.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/operator.test.ts new file mode 100644 index 0000000000000..acafc4e169c8f --- /dev/null +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/operator.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import indexPatternResponse from './__fixtures__/index_pattern_response.json'; + +import { setupGetOperatorSuggestions } from './operator'; +import { autocomplete, esKuery } from '../../../../../../src/plugins/data/public'; +import { coreMock } from '../../../../../../src/core/public/mocks'; + +const mockKueryNode = (kueryNode: Partial) => + (kueryNode as unknown) as esKuery.KueryNode; + +describe('Kuery operator suggestions', () => { + let getSuggestions: ReturnType; + let querySuggestionsArgs: autocomplete.QuerySuggestionsGetFnArgs; + + beforeEach(() => { + querySuggestionsArgs = ({ + indexPatterns: [indexPatternResponse], + } as unknown) as autocomplete.QuerySuggestionsGetFnArgs; + + getSuggestions = setupGetOperatorSuggestions(coreMock.createSetup()); + }); + + test('should return a function', () => { + expect(typeof getSuggestions).toBe('function'); + }); + + test('should not return suggestions for non-fields', async () => { + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ fieldName: 'foo' }) + ); + + expect(suggestions).toEqual([]); + }); + + test('should return exists for every field', async () => { + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'custom_user_field', + }) + ); + + expect(suggestions.length).toEqual(1); + expect(suggestions[0].text).toBe(': * '); + }); + + test('should return equals for string fields', async () => { + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ fieldName: 'machine.os' }) + ); + + expect(suggestions.find(({ text }) => text === ': ')).toBeDefined(); + expect(suggestions.find(({ text }) => text === '< ')).not.toBeDefined(); + }); + + test('should return numeric operators for numeric fields', async () => { + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ fieldName: 'bytes' }) + ); + + expect(suggestions.find(({ text }) => text === ': ')).toBeDefined(); + expect(suggestions.find(({ text }) => text === '< ')).toBeDefined(); + }); + + test('should have descriptions', async () => { + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ fieldName: 'bytes' }) + ); + + expect(suggestions.length).toBeGreaterThan(0); + + suggestions.forEach(suggestion => { + expect(suggestion).toHaveProperty('description'); + }); + }); + + test('should handle nested paths', async () => { + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'child', + nestedPath: 'nestedField', + }) + ); + + expect(suggestions.length).toBeGreaterThan(0); + }); +}); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/operator.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/operator.tsx similarity index 82% rename from x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/operator.js rename to x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/operator.tsx index 173a24b3f5f1e..6fdc161fc991f 100644 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/operator.js +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/operator.tsx @@ -6,8 +6,11 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { $Keys } from 'utility-types'; import { flatten } from 'lodash'; -const type = 'operator'; + +import { KqlQuerySuggestionProvider } from './types'; +import { autocomplete } from '../../../../../../src/plugins/data/public'; const equalsText = ( ), + fieldTypes: undefined, }, }; -function getDescription(operator) { - const { description } = operators[operator]; - return

{description}

; -} +type Operators = $Keys; + +const getOperatorByName = (operator: string) => operators[operator as Operators]; +const getDescription = (operator: string) =>

{getOperatorByName(operator).description}

; -export function getSuggestionsProvider({ indexPatterns }) { - const allFields = flatten( - indexPatterns.map(indexPattern => { - return indexPattern.fields.slice(); - }) - ); - return function getOperatorSuggestions({ end, fieldName, nestedPath }) { +export const setupGetOperatorSuggestions: KqlQuerySuggestionProvider = () => { + return ({ indexPatterns }, { end, fieldName, nestedPath }) => { + const allFields = flatten( + indexPatterns.map(indexPattern => { + return indexPattern.fields.slice(); + }) + ); const fullFieldName = nestedPath ? `${nestedPath}.${fieldName}` : fieldName; - const fields = allFields.filter(field => field.name === fullFieldName); - return flatten( - fields.map(field => { + const fields = allFields + .filter(field => field.name === fullFieldName) + .map(field => { const matchingOperators = Object.keys(operators).filter(operator => { - const { fieldTypes } = operators[operator]; + const { fieldTypes } = getOperatorByName(operator); + return !fieldTypes || fieldTypes.includes(field.type); }); - const suggestions = matchingOperators.map(operator => { - const text = operator + ' '; - const description = getDescription(operator); - return { type, text, description, start: end, end }; - }); + + const suggestions = matchingOperators.map(operator => ({ + type: autocomplete.QuerySuggestionsTypes.operator, + text: operator + ' ', + description: getDescription(operator), + start: end, + end, + })); return suggestions; - }) - ); + }); + + return Promise.resolve(flatten(fields)); }; -} +}; diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/sort_prefix_first.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/sort_prefix_first.ts index 123e440b75231..03e1a9099f1ab 100644 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/sort_prefix_first.ts +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/sort_prefix_first.ts @@ -14,7 +14,9 @@ export function sortPrefixFirst(array: any[], prefix?: string | number, property const partitions = partition(array, entry => { const value = ('' + (property ? entry[property] : entry)).toLowerCase(); + return value.startsWith(lowerCasePrefix); }); + return [...partitions[0], ...partitions[1]]; } diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/types.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/types.ts new file mode 100644 index 0000000000000..c51b75e001b9f --- /dev/null +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'kibana/public'; +import { esKuery, autocomplete } from '../../../../../../src/plugins/data/public'; + +export type KqlQuerySuggestionProvider = ( + core: CoreSetup +) => ( + querySuggestionsGetFnArgs: autocomplete.QuerySuggestionsGetFnArgs, + kueryNode: esKuery.KueryNode +) => Promise; diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.js deleted file mode 100644 index 9d0d70fd95747..0000000000000 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { flatten } from 'lodash'; -import { escapeQuotes } from './escape_kuery'; -import { npStart } from 'ui/new_platform'; - -const type = 'value'; - -export function getSuggestionsProvider({ indexPatterns, boolFilter }) { - const allFields = flatten( - indexPatterns.map(indexPattern => { - return indexPattern.fields.map(field => ({ - ...field, - indexPattern, - })); - }) - ); - - return function getValueSuggestions( - { start, end, prefix, suffix, fieldName, nestedPath }, - signal - ) { - const fullFieldName = nestedPath ? `${nestedPath}.${fieldName}` : fieldName; - const fields = allFields.filter(field => field.name === fullFieldName); - const query = `${prefix}${suffix}`.trim(); - const { getValueSuggestions } = npStart.plugins.data.autocomplete; - - const suggestionsByField = fields.map(field => - getValueSuggestions({ - indexPattern: field.indexPattern, - field, - query, - boolFilter, - signal, - }).then(data => { - const quotedValues = data.map(value => - typeof value === 'string' ? `"${escapeQuotes(value)}"` : `${value}` - ); - return wrapAsSuggestions(start, end, query, quotedValues); - }) - ); - - return Promise.all(suggestionsByField).then(suggestions => flatten(suggestions)); - }; -} - -function wrapAsSuggestions(start, end, query, values) { - return values - .filter(value => value.toLowerCase().includes(query.toLowerCase())) - .map(value => { - const text = `${value} `; - return { type, text, start, end }; - }); -} diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.test.js b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.test.ts similarity index 55% rename from x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.test.js rename to x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.test.ts index f5b652d2e2164..241e0dab485e1 100644 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.test.js +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.test.ts @@ -3,10 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { npStart } from 'ui/new_platform'; -import { getSuggestionsProvider } from './value'; +import { setupGetValueSuggestions } from './value'; import indexPatternResponse from './__fixtures__/index_pattern_response.json'; -import { npStart } from 'ui/new_platform'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { autocomplete, esKuery } from '../../../../../../src/plugins/data/public'; jest.mock('ui/new_platform', () => ({ npStart: { @@ -14,7 +16,8 @@ jest.mock('ui/new_platform', () => ({ data: { autocomplete: { getValueSuggestions: jest.fn(({ field }) => { - let res; + let res: any[]; + if (field.type === 'boolean') { res = [true, false]; } else if (field.name === 'machine.os') { @@ -32,17 +35,23 @@ jest.mock('ui/new_platform', () => ({ }, })); -describe('Kuery value suggestions', function() { - let indexPatterns; - let getSuggestions; +const mockKueryNode = (kueryNode: Partial) => + (kueryNode as unknown) as esKuery.KueryNode; + +describe('Kuery value suggestions', () => { + let getSuggestions: ReturnType; + let querySuggestionsArgs: autocomplete.QuerySuggestionsGetFnArgs; beforeEach(() => { - indexPatterns = [indexPatternResponse]; - getSuggestions = getSuggestionsProvider({ indexPatterns }); + getSuggestions = setupGetValueSuggestions(coreMock.createSetup()); + querySuggestionsArgs = ({ + indexPatterns: [indexPatternResponse], + } as unknown) as autocomplete.QuerySuggestionsGetFnArgs; + jest.clearAllMocks(); }); - test('should return a function', function() { + test('should return a function', () => { expect(typeof getSuggestions).toBe('function'); }); @@ -51,22 +60,28 @@ describe('Kuery value suggestions', function() { const prefix = ''; const suffix = ''; - const suggestions = await getSuggestions({ fieldName, prefix, suffix }); - expect(suggestions.map(({ text }) => text)).toEqual([]); + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ fieldName, prefix, suffix }) + ); + expect(suggestions.map(({ text }) => text)).toEqual([]); expect(npStart.plugins.data.autocomplete.getValueSuggestions).toHaveBeenCalledTimes(0); }); test('should format suggestions', async () => { const start = 1; const end = 5; - const suggestions = await getSuggestions({ - fieldName: 'ssl', - prefix: '', - suffix: '', - start, - end, - }); + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'ssl', + prefix: '', + suffix: '', + start, + end, + }) + ); expect(suggestions[0].type).toEqual('value'); expect(suggestions[0].start).toEqual(start); @@ -74,37 +89,62 @@ describe('Kuery value suggestions', function() { }); test('should handle nested paths', async () => { - const suggestions = await getSuggestions({ - fieldName: 'child', - nestedPath: 'nestedField', - prefix: '', - suffix: '', - }); + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'child', + nestedPath: 'nestedField', + prefix: '', + suffix: '', + }) + ); + expect(suggestions.length).toEqual(1); expect(suggestions[0].text).toEqual('"foo" '); }); - describe('Boolean suggestions', function() { + describe('Boolean suggestions', () => { test('should stringify boolean fields', async () => { - const suggestions = await getSuggestions({ fieldName: 'ssl', prefix: '', suffix: '' }); + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'ssl', + prefix: '', + suffix: '', + }) + ); expect(suggestions.map(({ text }) => text)).toEqual(['true ', 'false ']); expect(npStart.plugins.data.autocomplete.getValueSuggestions).toHaveBeenCalledTimes(1); }); test('should filter out boolean suggestions', async () => { - const suggestions = await getSuggestions({ fieldName: 'ssl', prefix: 'fa', suffix: '' }); + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'ssl', + prefix: 'fa', + suffix: '', + }) + ); expect(suggestions.length).toEqual(1); }); }); - describe('String suggestions', function() { + describe('String suggestions', () => { test('should merge prefix and suffix', async () => { const prefix = 'he'; const suffix = 'llo'; - await getSuggestions({ fieldName: 'machine.os.raw', prefix, suffix }); + await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'machine.os.raw', + prefix, + suffix, + }) + ); expect(npStart.plugins.data.autocomplete.getValueSuggestions).toHaveBeenCalledTimes(1); expect(npStart.plugins.data.autocomplete.getValueSuggestions).toBeCalledWith( @@ -116,7 +156,14 @@ describe('Kuery value suggestions', function() { }); test('should escape quotes in suggestions', async () => { - const suggestions = await getSuggestions({ fieldName: 'machine.os', prefix: '', suffix: '' }); + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'machine.os', + prefix: '', + suffix: '', + }) + ); expect(suggestions[0].text).toEqual('"Windo\\"ws" '); expect(suggestions[1].text).toEqual('"Mac\'" '); @@ -124,21 +171,27 @@ describe('Kuery value suggestions', function() { }); test('should filter out string suggestions', async () => { - const suggestions = await getSuggestions({ - fieldName: 'machine.os', - prefix: 'banana', - suffix: '', - }); + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'machine.os', + prefix: 'banana', + suffix: '', + }) + ); expect(suggestions.length).toEqual(0); }); test('should partially filter out string suggestions - case insensitive', async () => { - const suggestions = await getSuggestions({ - fieldName: 'machine.os', - prefix: 'ma', - suffix: '', - }); + const suggestions = await getSuggestions( + querySuggestionsArgs, + mockKueryNode({ + fieldName: 'machine.os', + prefix: 'ma', + suffix: '', + }) + ); expect(suggestions.length).toEqual(1); }); diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.ts new file mode 100644 index 0000000000000..194d8def7fdf7 --- /dev/null +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/kql_query_suggestion/value.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { npStart } from 'ui/new_platform'; +import { flatten } from 'lodash'; +import { escapeQuotes } from './lib/escape_kuery'; +import { KqlQuerySuggestionProvider } from './types'; +import { autocomplete } from '../../../../../../src/plugins/data/public'; + +const wrapAsSuggestions = (start: number, end: number, query: string, values: string[]) => + values + .filter(value => value.toLowerCase().includes(query.toLowerCase())) + .map(value => ({ + type: autocomplete.QuerySuggestionsTypes.value, + text: `${value} `, + start, + end, + })); + +export const setupGetValueSuggestions: KqlQuerySuggestionProvider = () => { + return async ( + { indexPatterns, boolFilter, signal }, + { start, end, prefix, suffix, fieldName, nestedPath } + ): Promise => { + const allFields = flatten( + indexPatterns.map(indexPattern => + indexPattern.fields.map(field => ({ + ...field, + indexPattern, + })) + ) + ); + + const fullFieldName = nestedPath ? `${nestedPath}.${fieldName}` : fieldName; + const fields = allFields.filter(field => field.name === fullFieldName); + const query = `${prefix}${suffix}`.trim(); + + // TODO: refactoring; + const { getValueSuggestions } = npStart.plugins.data.autocomplete; + + const data = await Promise.all( + fields.map(field => + getValueSuggestions({ + indexPattern: field.indexPattern, + field, + query, + boolFilter, + signal, + }).then(valueSuggestions => { + const quotedValues = valueSuggestions.map(value => + typeof value === 'string' ? `"${escapeQuotes(value)}"` : `${value}` + ); + + return wrapAsSuggestions(start, end, query, quotedValues); + }) + ) + ); + + return flatten(data); + }; +}; diff --git a/x-pack/legacy/plugins/kuery_autocomplete/public/plugin.ts b/x-pack/legacy/plugins/kuery_autocomplete/public/plugin.ts index 216e0f49ccd34..ed81b77914721 100644 --- a/x-pack/legacy/plugins/kuery_autocomplete/public/plugin.ts +++ b/x-pack/legacy/plugins/kuery_autocomplete/public/plugin.ts @@ -26,7 +26,7 @@ export class KueryAutocompletePlugin implements Plugin, void> { } public async setup(core: CoreSetup, plugins: KueryAutocompletePluginSetupDependencies) { - const kueryProvider = setupKqlQuerySuggestionProvider(core, plugins); + const kueryProvider = setupKqlQuerySuggestionProvider(core); plugins.data.autocomplete.addQuerySuggestionProvider(KUERY_LANGUAGE_NAME, kueryProvider); } diff --git a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/__examples__/index.stories.tsx b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/__examples__/index.stories.tsx index 4d92e8cb1335d..4519374842aae 100644 --- a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/__examples__/index.stories.tsx +++ b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/__examples__/index.stories.tsx @@ -16,7 +16,7 @@ const suggestion: autocomplete.QuerySuggestion = { end: 3, start: 1, text: 'Text...', - type: 'value', + type: autocomplete.QuerySuggestionsTypes.value, }; storiesOf('components/SuggestionItem', module).add('example', () => ( diff --git a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/index.test.tsx index ef16f79a4b83c..15c24639cb4c4 100644 --- a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/index.test.tsx @@ -18,7 +18,7 @@ import { AutocompleteField } from '.'; const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.ephemeral_id ', description: '

Filter results that contain agent.ephemeral_id

', @@ -26,7 +26,7 @@ const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ end: 1, }, { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.hostname ', description: '

Filter results that contain agent.hostname

', @@ -34,7 +34,7 @@ const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ end: 1, }, { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.id ', description: '

Filter results that contain agent.id

', @@ -42,7 +42,7 @@ const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ end: 1, }, { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.name ', description: '

Filter results that contain agent.name

', @@ -50,7 +50,7 @@ const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ end: 1, }, { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.type ', description: '

Filter results that contain agent.type

', @@ -58,7 +58,7 @@ const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ end: 1, }, { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.version ', description: '

Filter results that contain agent.version

', @@ -66,7 +66,7 @@ const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ end: 1, }, { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.test1 ', description: '

Filter results that contain agent.test1

', @@ -74,7 +74,7 @@ const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ end: 1, }, { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.test2 ', description: '

Filter results that contain agent.test2

', @@ -82,7 +82,7 @@ const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ end: 1, }, { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.test3 ', description: '

Filter results that contain agent.test3

', @@ -90,7 +90,7 @@ const mockAutoCompleteData: autocomplete.QuerySuggestion[] = [ end: 1, }, { - type: 'field', + type: autocomplete.QuerySuggestionsTypes.field, text: 'agent.test4 ', description: '

Filter results that contain agent.test4

',