[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)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Stratoula
Kalafateli","email":"efstratia.kalafateli@elastic.co"},"sourceCommit":{"committedDate":"2024-10-08T14:52:21Z","message":"[ES|QL]
Recommended queries (#194418)\n\n## Summary\r\n\r\nCloses
https://github.com/elastic/kibana/issues/187325\r\n\r\n<img
width=\"927\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/46220c26-f54c-4bd7-9a8b-d1d29591dc68\">\r\n\r\n<img
width=\"539\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/f4d938af-f2b6-400d-918f-3dcf1d22618f\">\r\n\r\n\r\nThis
is the first iteration of this feature. We want to help the
users\r\nfamiliarize themselves with popular operations. This PR:\r\n-
adds the recommended queries list in the help menu of unified
search\r\n- adds the list after the users select a datasource with the
from\r\ncommand\r\n- adds the list in the editor's empty
state\r\n\r\n### Checklist\r\n\r\n- [
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [ ] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"149e801109d7c9edf2d6eef41ebb0e281314a19f","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["v9.0.0","release_note:feature","backport:prev-minor","Feature:ES|QL","Team:ESQL","v8.16.0"],"title":"[ES|QL]
Recommended
queries","number":194418,"url":"https://github.com/elastic/kibana/pull/194418","mergeCommit":{"message":"[ES|QL]
Recommended queries (#194418)\n\n## Summary\r\n\r\nCloses
https://github.com/elastic/kibana/issues/187325\r\n\r\n<img
width=\"927\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/46220c26-f54c-4bd7-9a8b-d1d29591dc68\">\r\n\r\n<img
width=\"539\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/f4d938af-f2b6-400d-918f-3dcf1d22618f\">\r\n\r\n\r\nThis
is the first iteration of this feature. We want to help the
users\r\nfamiliarize themselves with popular operations. This PR:\r\n-
adds the recommended queries list in the help menu of unified
search\r\n- adds the list after the users select a datasource with the
from\r\ncommand\r\n- adds the list in the editor's empty
state\r\n\r\n### Checklist\r\n\r\n- [
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [ ] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"149e801109d7c9edf2d6eef41ebb0e281314a19f"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/194418","number":194418,"mergeCommit":{"message":"[ES|QL]
Recommended queries (#194418)\n\n## Summary\r\n\r\nCloses
https://github.com/elastic/kibana/issues/187325\r\n\r\n<img
width=\"927\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/46220c26-f54c-4bd7-9a8b-d1d29591dc68\">\r\n\r\n<img
width=\"539\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/f4d938af-f2b6-400d-918f-3dcf1d22618f\">\r\n\r\n\r\nThis
is the first iteration of this feature. We want to help the
users\r\nfamiliarize themselves with popular operations. This PR:\r\n-
adds the recommended queries list in the help menu of unified
search\r\n- adds the list after the users select a datasource with the
from\r\ncommand\r\n- adds the list in the editor's empty
state\r\n\r\n### Checklist\r\n\r\n- [
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [ ] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"149e801109d7c9edf2d6eef41ebb0e281314a19f"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Kibana Machine 2024-10-09 03:51:57 +11:00 committed by GitHub
parent 9bd5789f88
commit ba2ecd5821
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 424 additions and 122 deletions

View file

@ -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';

View file

@ -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 <kbd>SPACE</kbd> 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);
});

View file

@ -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 ? |');

View file

@ -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', () => {

View file

@ -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<SuggestionRawDefinition[]>;
type GetFieldsMapFn = () => Promise<Map<string, ESQLRealField>>;
type GetPoliciesFn = () => Promise<SuggestionRawDefinition[]>;
type GetPolicyMetadataFn = (name: string) => Promise<ESQLPolicy | undefined>;
@ -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)

View file

@ -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') {

View file

@ -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<SuggestionRawDefinition[]> => {
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;
};

View file

@ -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;
};

View file

@ -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<SuggestionRawDefinition[]>;

View file

@ -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 }],

View file

@ -204,6 +204,7 @@ export interface CommandDefinition extends CommandBaseDefinition {
examples: string[];
validate?: (option: ESQLCommand) => ESQLMessage[];
modes: CommandModeDefinition[];
hasRecommendedQueries?: boolean;
}
export interface Literals {

View file

@ -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(
<KibanaContextProvider services={services}>
<ESQLMenuPopover />{' '}
<ESQLMenuPopover adHocDataview={adHocDataview} />
</KibanaContextProvider>
);
};
@ -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();
});
});

View file

@ -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<ESQLMenuPopoverProps> = ({
onESQLDocsFlyoutVisibilityChanged,
adHocDataview,
onESQLQuerySubmit,
}) => {
const kibana = useKibana<IUnifiedSearchPluginServices>();
@ -48,63 +53,115 @@ export const ESQLMenuPopover: React.FC<ESQLMenuPopoverProps> = ({
[setIsLanguageComponentOpen, onESQLDocsFlyoutVisibilityChanged]
);
const esqlPanelItems = useMemo(() => {
const panelItems: EuiContextMenuPanelProps['items'] = [];
panelItems.push(
<EuiContextMenuItem
key="quickReference"
icon="documentation"
data-test-subj="esql-quick-reference"
onClick={() => toggleLanguageComponent()}
>
{i18n.translate('unifiedSearch.query.queryBar.esqlMenu.quickReference', {
defaultMessage: 'Quick Reference',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="about"
icon="iInCircle"
data-test-subj="esql-about"
target="_blank"
href={docLinks.links.query.queryESQL}
onClick={() => setIsESQLMenuPopoverOpen(false)}
>
{i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', {
defaultMessage: 'Documentation',
})}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="examples"
icon="nested"
data-test-subj="esql-examples"
target="_blank"
href={docLinks.links.query.queryESQLExamples}
onClick={() => setIsESQLMenuPopoverOpen(false)}
>
{i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', {
defaultMessage: 'Example queries',
})}
</EuiContextMenuItem>,
<EuiHorizontalRule margin="xs" key="dataviewActions-divider" />,
<EuiContextMenuItem
key="feedback"
icon="editorComment"
data-test-subj="esql-feedback"
target="_blank"
href={FEEDBACK_LINK}
onClick={() => setIsESQLMenuPopoverOpen(false)}
>
{i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', {
defaultMessage: 'Submit feedback',
})}
</EuiContextMenuItem>
);
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: () => (
<EuiContextMenuItem
key="quickReference"
icon="documentation"
data-test-subj="esql-quick-reference"
onClick={() => toggleLanguageComponent()}
>
{i18n.translate('unifiedSearch.query.queryBar.esqlMenu.quickReference', {
defaultMessage: 'Quick Reference',
})}
</EuiContextMenuItem>
),
},
{
name: i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', {
defaultMessage: 'Documentation',
}),
icon: 'iInCircle',
renderItem: () => (
<EuiContextMenuItem
key="about"
icon="iInCircle"
data-test-subj="esql-about"
target="_blank"
href={docLinks.links.query.queryESQL}
onClick={() => setIsESQLMenuPopoverOpen(false)}
>
{i18n.translate('unifiedSearch.query.queryBar.esqlMenu.documentation', {
defaultMessage: 'Documentation',
})}
</EuiContextMenuItem>
),
},
...(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: () => (
<EuiContextMenuItem
key="feedback"
icon="editorComment"
data-test-subj="esql-feedback"
target="_blank"
href={FEEDBACK_LINK}
onClick={() => setIsESQLMenuPopoverOpen(false)}
>
{i18n.translate('unifiedSearch.query.queryBar.esqlMenu.feedback', {
defaultMessage: 'Submit feedback',
})}
</EuiContextMenuItem>
),
},
],
},
{
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<ESQLMenuPopoverProps> = ({
panelPaddingSize="s"
display="block"
>
<EuiContextMenuPanel size="s" items={esqlPanelItems} />
<EuiContextMenu initialPanelId={0} panels={esqlContextMenuPanels} />
</EuiPopover>
<LanguageDocumentationFlyout
searchInDescription

View file

@ -778,6 +778,13 @@ export const QueryBarTopRow = React.memo(
{Boolean(isQueryLangSelected) && (
<ESQLMenuPopover
onESQLDocsFlyoutVisibilityChanged={props.onESQLDocsFlyoutVisibilityChanged}
onESQLQuerySubmit={(queryString: string) => {
onSubmit({
query: { esql: queryString } as QT,
dateRange: dateRangeRef.current,
});
}}
adHocDataview={props.indexPatterns?.[0]}
/>
)}
<EuiFlexItem

View file

@ -13,12 +13,12 @@ import SearchBar from './search_bar';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { indexPatternEditorPluginMock as dataViewEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks';
import { I18nProvider } from '@kbn/i18n-react';
import { stubIndexPattern } from '@kbn/data-plugin/public/stubs';
import { coreMock } from '@kbn/core/public/mocks';
const startMock = coreMock.createStart();
import { mount } from 'enzyme';
import { DataView } from '@kbn/data-views-plugin/public';
import { EuiSuperDatePicker, EuiSuperUpdateButton, EuiThemeProvider } from '@elastic/eui';
import { FilterItems } from '../filter_bar';
import { DataViewPicker } from '..';
@ -54,21 +54,6 @@ const createMockStorage = () => ({
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,

View file

@ -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"