From ba2ecd5821dac319a4a9179ecadc80206ed37448 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 9 Oct 2024 03:51:57 +1100 Subject: [PATCH] [8.x] [ES|QL] Recommended queries (#194418) (#195442) # Backport This will backport the following commits from `main` to `8.x`: - [[ES|QL] Recommended queries (#194418)](https://github.com/elastic/kibana/pull/194418) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Stratoula Kalafateli --- .../kbn-esql-validation-autocomplete/index.ts | 2 + .../autocomplete.command.from.test.ts | 12 +- .../__tests__/autocomplete.suggest.test.ts | 5 +- .../src/autocomplete/autocomplete.test.ts | 57 ++++-- .../src/autocomplete/autocomplete.ts | 49 ++++- .../src/autocomplete/helper.ts | 4 +- .../recommended_queries/suggestions.ts | 40 ++++ .../recommended_queries/templates.ts | 129 +++++++++++++ .../src/autocomplete/types.ts | 6 + .../src/definitions/commands.ts | 1 + .../src/definitions/types.ts | 1 + .../esql_menu_popover.test.tsx | 14 +- .../query_string_input/esql_menu_popover.tsx | 179 ++++++++++++------ .../query_string_input/query_bar_top_row.tsx | 7 + .../public/search_bar/search_bar.test.tsx | 39 ++-- src/plugins/unified_search/tsconfig.json | 1 + 16 files changed, 424 insertions(+), 122 deletions(-) create mode 100644 packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts create mode 100644 packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/templates.ts diff --git a/packages/kbn-esql-validation-autocomplete/index.ts b/packages/kbn-esql-validation-autocomplete/index.ts index bd41d6ec43a5a..0bfc4274fe84d 100644 --- a/packages/kbn-esql-validation-autocomplete/index.ts +++ b/packages/kbn-esql-validation-autocomplete/index.ts @@ -76,3 +76,5 @@ export { } from './src/shared/resources_helpers'; export { wrapAsEditorMessage } from './src/code_actions/utils'; + +export { getRecommendedQueries } from './src/autocomplete/recommended_queries/templates'; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.from.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.from.test.ts index fa2a969384a09..fa2e81ded897e 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.from.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.from.test.ts @@ -9,6 +9,7 @@ import { METADATA_FIELDS } from '../../shared/constants'; import { setup, indexes, integrations } from './helpers'; +import { getRecommendedQueries } from '../recommended_queries/templates'; const visibleIndices = indexes .filter(({ hidden }) => !hidden) @@ -72,8 +73,17 @@ describe('autocomplete.suggest', () => { const metadataFieldsAndIndex = metadataFields.filter((field) => field !== '_index'); test('on SPACE without comma ",", suggests adding metadata', async () => { + const recommendedQueries = getRecommendedQueries({ + fromCommand: '', + timeField: 'dateField', + }); const { assertSuggestions } = await setup(); - const expected = ['METADATA $0', ',', '| '].sort(); + const expected = [ + 'METADATA $0', + ',', + '| ', + ...recommendedQueries.map((query) => query.queryString), + ].sort(); await assertSuggestions('from a, b /', expected); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts index c4803e769c1fc..51302d0d4cde5 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts @@ -36,12 +36,9 @@ const setup = async (caret = '?') => { }; describe('autocomplete.suggest', () => { - test('does not load fields when suggesting within a single FROM, SHOW, ROW command', async () => { + test('does not load fields when suggesting within a single SHOW, ROW command', async () => { const { suggest, callbacks } = await setup(); - await suggest('FROM kib, ? |'); - await suggest('FROM ?'); - await suggest('FROM ? |'); await suggest('sHoW ?'); await suggest('row ? |'); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts index 095cb2ffc9d14..84779f1dd36b5 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -35,8 +35,16 @@ import { import { METADATA_FIELDS } from '../shared/constants'; import { ESQL_COMMON_NUMERIC_TYPES, ESQL_STRING_TYPES } from '../shared/esql_types'; import { log10ParameterTypes, powParameterTypes } from './__tests__/constants'; +import { getRecommendedQueries } from './recommended_queries/templates'; const commandDefinitions = unmodifiedCommandDefinitions.filter(({ hidden }) => !hidden); + +const getRecommendedQueriesSuggestions = (fromCommand: string, timeField?: string) => + getRecommendedQueries({ + fromCommand, + timeField, + }); + describe('autocomplete', () => { type TestArgs = [ string, @@ -82,10 +90,11 @@ describe('autocomplete', () => { const sourceCommands = ['row', 'from', 'show']; describe('New command', () => { - testSuggestions( - '/', - sourceCommands.map((name) => name.toUpperCase() + ' $0') - ); + const recommendedQuerySuggestions = getRecommendedQueriesSuggestions('FROM logs*', 'dateField'); + testSuggestions('/', [ + ...sourceCommands.map((name) => name.toUpperCase() + ' $0'), + ...recommendedQuerySuggestions.map((q) => q.queryString), + ]); testSuggestions( 'from a | /', commandDefinitions @@ -523,10 +532,11 @@ describe('autocomplete', () => { */ describe('Invoke trigger kind (all commands)', () => { // source command - testSuggestions( - 'f/', - sourceCommands.map((cmd) => `${cmd.toUpperCase()} $0`) - ); + let recommendedQuerySuggestions = getRecommendedQueriesSuggestions('FROM logs*', 'dateField'); + testSuggestions('f/', [ + ...sourceCommands.map((cmd) => `${cmd.toUpperCase()} $0`), + ...recommendedQuerySuggestions.map((q) => q.queryString), + ]); // pipe command testSuggestions( @@ -575,7 +585,13 @@ describe('autocomplete', () => { ]); // FROM source METADATA - testSuggestions('FROM index1 M/', [',', 'METADATA $0', '| ']); + recommendedQuerySuggestions = getRecommendedQueriesSuggestions('', 'dateField'); + testSuggestions('FROM index1 M/', [ + ',', + 'METADATA $0', + '| ', + ...recommendedQuerySuggestions.map((q) => q.queryString), + ]); // FROM source METADATA field testSuggestions('FROM index1 METADATA _/', METADATA_FIELDS); @@ -710,12 +726,12 @@ describe('autocomplete', () => { ...s, asSnippet: true, }); - + let recommendedQuerySuggestions = getRecommendedQueriesSuggestions('FROM logs*', 'dateField'); // Source command - testSuggestions( - 'F/', - ['FROM $0', 'ROW $0', 'SHOW $0'].map(attachTriggerCommand).map(attachAsSnippet) - ); + testSuggestions('F/', [ + ...['FROM $0', 'ROW $0', 'SHOW $0'].map(attachTriggerCommand).map(attachAsSnippet), + ...recommendedQuerySuggestions.map((q) => q.queryString), + ]); // Pipe command testSuggestions( @@ -787,11 +803,14 @@ describe('autocomplete', () => { ); }); + recommendedQuerySuggestions = getRecommendedQueriesSuggestions('', 'dateField'); + // PIPE (|) testSuggestions('FROM a /', [ attachTriggerCommand('| '), ',', attachAsSnippet(attachTriggerCommand('METADATA $0')), + ...recommendedQuerySuggestions.map((q) => q.queryString), ]); // Assignment @@ -833,6 +852,7 @@ describe('autocomplete', () => { ], ] ); + recommendedQuerySuggestions = getRecommendedQueriesSuggestions('index1', 'dateField'); testSuggestions( 'FROM index1/', @@ -840,6 +860,7 @@ describe('autocomplete', () => { { text: 'index1 | ', filterText: 'index1', command: TRIGGER_SUGGESTION_COMMAND }, { text: 'index1, ', filterText: 'index1', command: TRIGGER_SUGGESTION_COMMAND }, { text: 'index1 METADATA ', filterText: 'index1', command: TRIGGER_SUGGESTION_COMMAND }, + ...recommendedQuerySuggestions.map((q) => q.queryString), ], undefined, [ @@ -851,12 +872,14 @@ describe('autocomplete', () => { ] ); + recommendedQuerySuggestions = getRecommendedQueriesSuggestions('index2', 'dateField'); testSuggestions( 'FROM index1, index2/', [ { text: 'index2 | ', filterText: 'index2', command: TRIGGER_SUGGESTION_COMMAND }, { text: 'index2, ', filterText: 'index2', command: TRIGGER_SUGGESTION_COMMAND }, { text: 'index2 METADATA ', filterText: 'index2', command: TRIGGER_SUGGESTION_COMMAND }, + ...recommendedQuerySuggestions.map((q) => q.queryString), ], undefined, [ @@ -872,6 +895,7 @@ describe('autocomplete', () => { // meaning that Monaco by default will only set the replacement // range to cover "bar" and not "foo$bar". We have to make sure // we're setting it ourselves. + recommendedQuerySuggestions = getRecommendedQueriesSuggestions('foo$bar', 'dateField'); testSuggestions( 'FROM foo$bar/', [ @@ -894,18 +918,21 @@ describe('autocomplete', () => { command: TRIGGER_SUGGESTION_COMMAND, rangeToReplace: { start: 6, end: 13 }, }, + ...recommendedQuerySuggestions.map((q) => q.queryString), ], undefined, [, [{ name: 'foo$bar', hidden: false }]] ); // This is an identifier that matches multiple sources + recommendedQuerySuggestions = getRecommendedQueriesSuggestions('i*', 'dateField'); testSuggestions( 'FROM i*/', [ { text: 'i* | ', filterText: 'i*', command: TRIGGER_SUGGESTION_COMMAND }, { text: 'i*, ', filterText: 'i*', command: TRIGGER_SUGGESTION_COMMAND }, { text: 'i* METADATA ', filterText: 'i*', command: TRIGGER_SUGGESTION_COMMAND }, + ...recommendedQuerySuggestions.map((q) => q.queryString), ], undefined, [ @@ -918,11 +945,13 @@ describe('autocomplete', () => { ); }); + recommendedQuerySuggestions = getRecommendedQueriesSuggestions('', 'dateField'); // FROM source METADATA testSuggestions('FROM index1 M/', [ ',', attachAsSnippet(attachTriggerCommand('METADATA $0')), '| ', + ...recommendedQuerySuggestions.map((q) => q.queryString), ]); describe('ENRICH', () => { diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index 41060675a73a8..2433f5d496521 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -19,7 +19,7 @@ import type { } from '@kbn/esql-ast'; import { i18n } from '@kbn/i18n'; import { ESQL_NUMBER_TYPES, isNumericType } from '../shared/esql_types'; -import type { EditorContext, ItemKind, SuggestionRawDefinition } from './types'; +import type { EditorContext, ItemKind, SuggestionRawDefinition, GetFieldsByTypeFn } from './types'; import { getColumnForASTNode, getCommandDefinition, @@ -113,12 +113,8 @@ import { import { metadataOption } from '../definitions/options'; import { comparisonFunctions } from '../definitions/builtin'; import { countBracketsUnclosed } from '../shared/helpers'; +import { getRecommendedQueriesSuggestions } from './recommended_queries/suggestions'; -type GetFieldsByTypeFn = ( - type: string | string[], - ignored?: string[], - options?: { advanceCursor?: boolean; openSuggestions?: boolean; addComma?: boolean } -) => Promise; type GetFieldsMapFn = () => Promise>; type GetPoliciesFn = () => Promise; type GetPolicyMetadataFn = (name: string) => Promise; @@ -176,7 +172,7 @@ export async function suggest( ); const { getFieldsByType, getFieldsMap } = getFieldsByTypeRetriever( - queryForFields, + queryForFields.replace(EDITOR_MARKER, ''), resourceRetriever ); const getSources = getSourcesHelper(resourceRetriever); @@ -187,7 +183,26 @@ export async function suggest( // filter source commands if already defined const suggestions = commandAutocompleteDefinitions; if (!ast.length) { - return suggestions.filter(isSourceCommand); + // Display the recommended queries if there are no commands (empty state) + const recommendedQueriesSuggestions: SuggestionRawDefinition[] = []; + if (getSources) { + let fromCommand = ''; + const sources = await getSources(); + const visibleSources = sources.filter((source) => !source.hidden); + if (visibleSources.find((source) => source.name.startsWith('logs'))) { + fromCommand = 'FROM logs*'; + } else fromCommand = `FROM ${visibleSources[0].name}`; + + const { getFieldsByType: getFieldsByTypeEmptyState } = getFieldsByTypeRetriever( + fromCommand, + resourceRetriever + ); + recommendedQueriesSuggestions.push( + ...(await getRecommendedQueriesSuggestions(getFieldsByTypeEmptyState, fromCommand)) + ); + } + const sourceCommandsSuggestions = suggestions.filter(isSourceCommand); + return [...sourceCommandsSuggestions, ...recommendedQueriesSuggestions]; } return suggestions.filter((def) => !isSourceCommand(def)); @@ -519,6 +534,7 @@ async function getExpressionSuggestionsByType( const optArg = optionsAlreadyDeclared.find(({ name: optionName }) => optionName === name); return (!optArg && !optionsAlreadyDeclared.length) || (optArg && index > optArg.index); }); + const hasRecommendedQueries = Boolean(commandDef?.hasRecommendedQueries); // get the next definition for the given command let argDef = commandDef.signature.params[argIndex]; // tune it for the variadic case @@ -910,6 +926,11 @@ async function getExpressionSuggestionsByType( if (lastIndex && lastIndex.text && lastIndex.text !== EDITOR_MARKER) { const sources = await getSources(); + + const recommendedQueriesSuggestions = hasRecommendedQueries + ? await getRecommendedQueriesSuggestions(getFieldsByType) + : []; + const suggestionsToAdd = await handleFragment( innerText, (fragment) => @@ -952,8 +973,13 @@ async function getExpressionSuggestionsByType( asSnippet: false, // turn this off because $ could be contained within the source name rangeToReplace, }, + ...recommendedQueriesSuggestions.map((suggestion) => ({ + ...suggestion, + rangeToReplace, + filterText: fragment, + text: fragment + suggestion.text, + })), ]; - return _suggestions; } } @@ -1005,6 +1031,11 @@ async function getExpressionSuggestionsByType( })); suggestions.push(...finalSuggestions); } + + // handle recommended queries for from + if (hasRecommendedQueries) { + suggestions.push(...(await getRecommendedQueriesSuggestions(getFieldsByType))); + } } // Due to some logic overlapping functions can be repeated // so dedupe here based on text string (it can differ from name) diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index f42d7de5a38ab..41f6a92dc313d 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -86,9 +86,7 @@ export function strictlyGetParamAtPosition( export function getQueryForFields(queryString: string, commands: ESQLCommand[]) { // If there is only one source command and it does not require fields, do not // fetch fields, hence return an empty string. - return commands.length === 1 && ['from', 'row', 'show'].includes(commands[0].name) - ? '' - : queryString; + return commands.length === 1 && ['row', 'show'].includes(commands[0].name) ? '' : queryString; } export function getSourcesFromCommands(commands: ESQLCommand[], sourceType: 'index' | 'policy') { diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts new file mode 100644 index 0000000000000..fbcfbabb2b63c --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/suggestions.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SuggestionRawDefinition, GetFieldsByTypeFn } from '../types'; +import { getRecommendedQueries } from './templates'; + +export const getRecommendedQueriesSuggestions = async ( + getFieldsByType: GetFieldsByTypeFn, + fromCommand: string = '' +): Promise => { + const fieldSuggestions = await getFieldsByType('date', [], { + openSuggestions: true, + }); + let timeField = ''; + if (fieldSuggestions.length) { + timeField = + fieldSuggestions?.find((field) => field.label === '@timestamp')?.label || + fieldSuggestions[0].label; + } + + const recommendedQueries = getRecommendedQueries({ fromCommand, timeField }); + + const suggestions: SuggestionRawDefinition[] = recommendedQueries.map((query) => { + return { + label: query.label, + text: query.queryString, + kind: 'Issue', + detail: query.description, + sortText: 'D', + }; + }); + + return suggestions; +}; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/templates.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/templates.ts new file mode 100644 index 0000000000000..f910d3ba05a3b --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/recommended_queries/templates.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; + +// Order starts with the simple ones and goes to more complex ones + +export const getRecommendedQueries = ({ + fromCommand, + timeField, +}: { + fromCommand: string; + timeField?: string; +}) => { + const queries = [ + { + label: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.aggregateExample.label', + { + defaultMessage: 'Aggregate with STATS', + } + ), + description: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.aggregateExample.description', + { + defaultMessage: 'Count aggregation', + } + ), + queryString: `${fromCommand}\n | STATS count = COUNT(*) // you can group by a field using the BY operator`, + }, + ...(timeField + ? [ + { + label: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.sortByTime.label', + { + defaultMessage: 'Sort by time', + } + ), + description: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.sortByTime.description', + { + defaultMessage: 'Sort by time', + } + ), + queryString: `${fromCommand}\n | SORT ${timeField} // Data is not sorted by default`, + }, + { + label: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.dateIntervals.label', + { + defaultMessage: 'Create 5 minute time buckets with EVAL', + } + ), + description: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.dateIntervals.description', + { + defaultMessage: 'Count aggregation over time', + } + ), + queryString: `${fromCommand}\n | EVAL buckets = DATE_TRUNC(5 minute, ${timeField}) | STATS count = COUNT(*) BY buckets // try out different intervals`, + }, + ] + : []), + { + label: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.caseExample.label', + { + defaultMessage: 'Create a conditional with CASE', + } + ), + description: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.caseExample.description', + { + defaultMessage: 'Conditional', + } + ), + queryString: `${fromCommand}\n | STATS count = COUNT(*)\n | EVAL newField = CASE(count < 100, "groupA", count > 100 and count < 500, "groupB", "Other")\n | KEEP newField`, + }, + ...(timeField + ? [ + { + label: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.dateHistogram.label', + { + defaultMessage: 'Create a date histogram', + } + ), + description: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.dateHistogram.description', + { + defaultMessage: 'Count aggregation over time', + } + ), + queryString: `${fromCommand}\n | WHERE ${timeField} <=?_tend and ${timeField} >?_tstart\n | STATS count = COUNT(*) BY \`Over time\` = BUCKET(${timeField}, 50, ?_tstart, ?_tend) // ?_tstart and ?_tend take the values of the time picker`, + }, + { + label: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.lastHour.label', + { + defaultMessage: 'Total count vs count last hour', + } + ), + description: i18n.translate( + 'kbn-esql-validation-autocomplete.recommendedQueries.lastHour.description', + { + defaultMessage: 'A more complicated example', + } + ), + queryString: `${fromCommand} + | SORT ${timeField} + | EVAL now = NOW() + | EVAL key = CASE(${timeField} < (now - 1 hour) AND ${timeField} > (now - 2 hour), "Last hour", "Other") + | STATS count = COUNT(*) BY key + | EVAL count_last_hour = CASE(key == "Last hour", count), count_rest = CASE(key == "Other", count) + | EVAL total_visits = TO_DOUBLE(COALESCE(count_last_hour, 0::LONG) + COALESCE(count_rest, 0::LONG)) + | STATS count_last_hour = SUM(count_last_hour), total_visits = SUM(total_visits)`, + }, + ] + : []), + ]; + return queries; +}; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts index 40b688265f3fe..030bff4da181c 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/types.ts @@ -80,3 +80,9 @@ export interface EditorContext { */ triggerKind: number; } + +export type GetFieldsByTypeFn = ( + type: string | string[], + ignored?: string[], + options?: { advanceCursor?: boolean; openSuggestions?: boolean; addComma?: boolean } +) => Promise; diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts index 979e718fb4174..e02024968306b 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts @@ -173,6 +173,7 @@ export const commandDefinitions: CommandDefinition[] = [ examples: ['from logs', 'from logs-*', 'from logs_*, events-*'], options: [metadataOption], modes: [], + hasRecommendedQueries: true, signature: { multipleParams: true, params: [{ name: 'index', type: 'source', wildcards: true }], diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts index a6e297771cebe..2b1bd618449c3 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts @@ -204,6 +204,7 @@ export interface CommandDefinition extends CommandBaseDefinition { examples: string[]; validate?: (option: ESQLCommand) => ESQLMessage[]; modes: CommandModeDefinition[]; + hasRecommendedQueries?: boolean; } export interface Literals { diff --git a/src/plugins/unified_search/public/query_string_input/esql_menu_popover.test.tsx b/src/plugins/unified_search/public/query_string_input/esql_menu_popover.test.tsx index 9a263aa79510d..2a44f1957d266 100644 --- a/src/plugins/unified_search/public/query_string_input/esql_menu_popover.test.tsx +++ b/src/plugins/unified_search/public/query_string_input/esql_menu_popover.test.tsx @@ -11,18 +11,20 @@ import React from 'react'; import { screen, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { stubIndexPattern } from '@kbn/data-plugin/public/stubs'; import { coreMock } from '@kbn/core/public/mocks'; +import type { DataView } from '@kbn/data-views-plugin/common'; import { ESQLMenuPopover } from './esql_menu_popover'; describe('ESQLMenuPopover', () => { - const renderESQLPopover = () => { + const renderESQLPopover = (adHocDataview?: DataView) => { const startMock = coreMock.createStart(); const services = { docLinks: startMock.docLinks, }; return render( - {' '} + ); }; @@ -37,8 +39,14 @@ describe('ESQLMenuPopover', () => { expect(screen.getByTestId('esql-menu-button')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button')); expect(screen.getByTestId('esql-quick-reference')).toBeInTheDocument(); - expect(screen.getByTestId('esql-examples')).toBeInTheDocument(); + expect(screen.queryByTestId('esql-recommended-queries')).not.toBeInTheDocument(); expect(screen.getByTestId('esql-about')).toBeInTheDocument(); expect(screen.getByTestId('esql-feedback')).toBeInTheDocument(); }); + + it('should have recommended queries if a dataview is passed', async () => { + renderESQLPopover(stubIndexPattern); + await userEvent.click(screen.getByRole('button')); + expect(screen.queryByTestId('esql-recommended-queries')).toBeInTheDocument(); + }); }); diff --git a/src/plugins/unified_search/public/query_string_input/esql_menu_popover.tsx b/src/plugins/unified_search/public/query_string_input/esql_menu_popover.tsx index cfffa8ae7f83a..71e54c3376abb 100644 --- a/src/plugins/unified_search/public/query_string_input/esql_menu_popover.tsx +++ b/src/plugins/unified_search/public/query_string_input/esql_menu_popover.tsx @@ -11,23 +11,28 @@ import React, { useMemo, useState, useCallback } from 'react'; import { EuiPopover, EuiButton, - EuiContextMenuPanel, - type EuiContextMenuPanelProps, + type EuiContextMenuPanelDescriptor, EuiContextMenuItem, - EuiHorizontalRule, + EuiContextMenu, } from '@elastic/eui'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { i18n } from '@kbn/i18n'; +import type { DataView } from '@kbn/data-views-plugin/public'; import { FEEDBACK_LINK } from '@kbn/esql-utils'; +import { getRecommendedQueries } from '@kbn/esql-validation-autocomplete'; import { LanguageDocumentationFlyout } from '@kbn/language-documentation'; import type { IUnifiedSearchPluginServices } from '../types'; export interface ESQLMenuPopoverProps { onESQLDocsFlyoutVisibilityChanged?: (isOpen: boolean) => void; + adHocDataview?: DataView | string; + onESQLQuerySubmit?: (query: string) => void; } export const ESQLMenuPopover: React.FC = ({ onESQLDocsFlyoutVisibilityChanged, + adHocDataview, + onESQLQuerySubmit, }) => { const kibana = useKibana(); @@ -48,63 +53,115 @@ export const ESQLMenuPopover: React.FC = ({ [setIsLanguageComponentOpen, onESQLDocsFlyoutVisibilityChanged] ); - const esqlPanelItems = useMemo(() => { - const panelItems: EuiContextMenuPanelProps['items'] = []; - panelItems.push( - toggleLanguageComponent()} - > - {i18n.translate('unifiedSearch.query.queryBar.esqlMenu.quickReference', { - defaultMessage: 'Quick Reference', - })} - , - setIsESQLMenuPopoverOpen(false)} - > - {i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', { - defaultMessage: 'Documentation', - })} - , - setIsESQLMenuPopoverOpen(false)} - > - {i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', { - defaultMessage: 'Example queries', - })} - , - , - setIsESQLMenuPopoverOpen(false)} - > - {i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', { - defaultMessage: 'Submit feedback', - })} - - ); - return panelItems; - }, [ - docLinks.links.query.queryESQL, - docLinks.links.query.queryESQLExamples, - toggleLanguageComponent, - ]); + const esqlContextMenuPanels = useMemo(() => { + const recommendedQueries = []; + if (adHocDataview && typeof adHocDataview !== 'string') { + const queryString = `from ${adHocDataview.name}`; + const timeFieldName = + adHocDataview.timeFieldName ?? adHocDataview.fields?.getByType('date')?.[0]?.name; + + recommendedQueries.push( + ...getRecommendedQueries({ + fromCommand: queryString, + timeField: timeFieldName, + }) + ); + } + const panels = [ + { + id: 0, + items: [ + { + name: i18n.translate('unifiedSearch.query.queryBar.esqlMenu.quickReference', { + defaultMessage: 'Quick Reference', + }), + icon: 'nedocumentationsted', + renderItem: () => ( + toggleLanguageComponent()} + > + {i18n.translate('unifiedSearch.query.queryBar.esqlMenu.quickReference', { + defaultMessage: 'Quick Reference', + })} + + ), + }, + { + name: i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', { + defaultMessage: 'Documentation', + }), + icon: 'iInCircle', + renderItem: () => ( + setIsESQLMenuPopoverOpen(false)} + > + {i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', { + defaultMessage: 'Documentation', + })} + + ), + }, + ...(Boolean(recommendedQueries.length) + ? [ + { + name: i18n.translate('unifiedSearch.query.queryBar.esqlMenu.exampleQueries', { + defaultMessage: 'Recommended queries', + }), + icon: 'nested', + panel: 1, + 'data-test-subj': 'esql-recommended-queries', + }, + ] + : []), + { + name: i18n.translate('unifiedSearch.query.queryBar.esqlMenu.feedback', { + defaultMessage: 'Submit feedback', + }), + icon: 'editorComment', + renderItem: () => ( + setIsESQLMenuPopoverOpen(false)} + > + {i18n.translate('unifiedSearch.query.queryBar.esqlMenu.feedback', { + defaultMessage: 'Submit feedback', + })} + + ), + }, + ], + }, + { + id: 1, + initialFocusedItemIndex: 1, + title: i18n.translate('unifiedSearch.query.queryBar.esqlMenu.exampleQueries', { + defaultMessage: 'Recommended queries', + }), + items: recommendedQueries.map((query) => { + return { + name: query.label, + onClick: () => { + onESQLQuerySubmit?.(query.queryString); + setIsESQLMenuPopoverOpen(false); + }, + }; + }), + }, + ]; + return panels as EuiContextMenuPanelDescriptor[]; + }, [adHocDataview, docLinks.links.query.queryESQL, onESQLQuerySubmit, toggleLanguageComponent]); return ( <> @@ -130,7 +187,7 @@ export const ESQLMenuPopover: React.FC = ({ panelPaddingSize="s" display="block" > - + { + onSubmit({ + query: { esql: queryString } as QT, + dateRange: dateRangeRef.current, + }); + }} + adHocDataview={props.indexPatterns?.[0]} /> )} ({ clear: jest.fn(), }); -const mockIndexPattern = { - id: '1234', - title: 'logstash-*', - fields: [ - { - name: 'response', - type: 'number', - esTypes: ['integer'], - aggregatable: true, - filterable: true, - searchable: true, - }, - ], -} as DataView; - const kqlQuery = { query: 'response:200', language: 'kuery', @@ -155,7 +140,7 @@ describe('SearchBar', () => { it('Should render query bar when no options provided (in reality - timepicker)', () => { const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], }) ); @@ -167,7 +152,7 @@ describe('SearchBar', () => { it('Should render filter bar, when required fields are provided', () => { const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], showDatePicker: false, showQueryInput: true, showFilterBar: true, @@ -184,7 +169,7 @@ describe('SearchBar', () => { it('Should NOT render filter bar, if disabled', () => { const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], showFilterBar: false, filters: [], onFiltersUpdated: noop, @@ -200,7 +185,7 @@ describe('SearchBar', () => { it('Should render query bar, when required fields are provided', () => { const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], screenTitle: 'test screen', onQuerySubmit: noop, query: kqlQuery, @@ -215,7 +200,7 @@ describe('SearchBar', () => { it('Should NOT render the input query input, if disabled', () => { const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], screenTitle: 'test screen', onQuerySubmit: noop, query: kqlQuery, @@ -231,7 +216,7 @@ describe('SearchBar', () => { it('Should NOT render the query menu button, if disabled', () => { const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], screenTitle: 'test screen', onQuerySubmit: noop, query: kqlQuery, @@ -245,7 +230,7 @@ describe('SearchBar', () => { it('Should render query bar and filter bar', () => { const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], screenTitle: 'test screen', showQueryInput: true, onQuerySubmit: noop, @@ -264,7 +249,7 @@ describe('SearchBar', () => { it('Should NOT render the input query input, for es|ql query', () => { const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], screenTitle: 'test screen', onQuerySubmit: noop, query: esqlQuery, @@ -277,7 +262,7 @@ describe('SearchBar', () => { it('Should render in isDisabled state', () => { const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], screenTitle: 'test screen', onQuerySubmit: noop, isDisabled: true, @@ -316,7 +301,7 @@ describe('SearchBar', () => { const mockedOnQuerySubmit = jest.fn(); const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], screenTitle: 'test screen', onQuerySubmit: mockedOnQuerySubmit, query: kqlQuery, @@ -344,7 +329,7 @@ describe('SearchBar', () => { const mockedOnQuerySubmit = jest.fn(); const component = mount( wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], + indexPatterns: [stubIndexPattern], screenTitle: 'test screen', onQuerySubmit: mockedOnQuerySubmit, query: kqlQuery, diff --git a/src/plugins/unified_search/tsconfig.json b/src/plugins/unified_search/tsconfig.json index c19bfee778122..f5d2e6ff53c7a 100644 --- a/src/plugins/unified_search/tsconfig.json +++ b/src/plugins/unified_search/tsconfig.json @@ -45,6 +45,7 @@ "@kbn/react-kibana-context-render", "@kbn/data-view-utils", "@kbn/esql-utils", + "@kbn/esql-validation-autocomplete", "@kbn/react-kibana-mount", "@kbn/field-utils", "@kbn/language-documentation"