[ES|QL] Creates control by typing a questionmark (#216839)

## Summary

Closes https://github.com/elastic/kibana/issues/213877

Gives the users the ability to create a control by typing a ?


![meow](https://github.com/user-attachments/assets/1df4e138-9d7b-4850-886b-922c375a498c)


![meow](https://github.com/user-attachments/assets/7691b619-407f-407d-94ff-6c057f2723ea)

### Checklist

- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Stratoula Kalafateli 2025-04-09 14:39:19 +02:00 committed by GitHub
parent 0e882dda8a
commit 751e44d5da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 239 additions and 27 deletions

View file

@ -602,6 +602,34 @@ describe('esql query helpers', () => {
} as monaco.Position);
expect(values).toEqual(undefined);
});
it('should return undefined if the query has a questionmark at the last position', () => {
const queryString = 'FROM my_index | STATS COUNT() BY ?';
const values = getValuesFromQueryField(queryString, {
lineNumber: 1,
column: 34,
} as monaco.Position);
expect(values).toEqual(undefined);
});
it('should return undefined if the query has a questionmark at the second last position', () => {
const queryString = 'FROM my_index | STATS PERCENTILE(bytes, ?)';
const values = getValuesFromQueryField(queryString, {
lineNumber: 1,
column: 42,
} as monaco.Position);
expect(values).toEqual(undefined);
});
it('should return undefined if the query has a questionmark at the last cursor position', () => {
const queryString =
'FROM my_index | STATS COUNT() BY BUCKET(@timestamp, ?, ?_tstart, ?_tend)';
const values = getValuesFromQueryField(queryString, {
lineNumber: 1,
column: 52,
} as monaco.Position);
expect(values).toEqual(undefined);
});
});
describe('fixESQLQueryWithVariables', () => {

View file

@ -192,7 +192,7 @@ export const mapVariableToColumn = (
return columns;
};
const getQueryUpToCursor = (queryString: string, cursorPosition?: monaco.Position) => {
export const getQueryUpToCursor = (queryString: string, cursorPosition?: monaco.Position) => {
const lines = queryString.split('\n');
const lineNumber = cursorPosition?.lineNumber ?? lines.length;
const column = cursorPosition?.column ?? lines[lineNumber - 1].length;
@ -210,9 +210,24 @@ const getQueryUpToCursor = (queryString: string, cursorPosition?: monaco.Positio
return previousLines + '\n' + currentLine;
};
const hasQuestionMarkAtEndOrSecondLastPosition = (queryString: string) => {
if (typeof queryString !== 'string' || queryString.length === 0) {
return false;
}
const lastChar = queryString.slice(-1);
const secondLastChar = queryString.slice(-2, -1);
return lastChar === '?' || secondLastChar === '?';
};
export const getValuesFromQueryField = (queryString: string, cursorPosition?: monaco.Position) => {
const queryInCursorPosition = getQueryUpToCursor(queryString, cursorPosition);
if (hasQuestionMarkAtEndOrSecondLastPosition(queryInCursorPosition)) {
return undefined;
}
const validQuery = `${queryInCursorPosition} ""`;
const { root } = parse(validQuery);
const lastCommand = root.commands[root.commands.length - 1];

View file

@ -11,15 +11,38 @@ import { setup } from './helpers';
describe('autocomplete.suggest', () => {
describe('LIMIT <number>', () => {
it('suggests numbers', async () => {
test('suggests numbers', async () => {
const { assertSuggestions } = await setup();
assertSuggestions('from a | limit /', ['10 ', '100 ', '1000 ']);
assertSuggestions('from a | limit /', ['10 ', '100 ', '1000 '], { triggerCharacter: ' ' });
});
it('suggests pipe after number', async () => {
test('suggests pipe after number', async () => {
const { assertSuggestions } = await setup();
assertSuggestions('from a | limit 4 /', ['| ']);
});
});
describe('create control suggestion', () => {
test('suggests `Create control` option if questionmark is typed', async () => {
const { suggest } = await setup();
const suggestions = await suggest('FROM a | LIMIT ?/', {
callbacks: {
canSuggestVariables: () => true,
getVariables: () => [],
getColumnsFor: () => Promise.resolve([{ name: 'agent.name', type: 'keyword' }]),
},
});
expect(suggestions).toContainEqual({
label: 'Create control',
text: '',
kind: 'Issue',
detail: 'Click to create',
command: { id: 'esql.control.values.create', title: 'Click to create' },
sortText: '1',
});
});
});
});

View file

@ -578,6 +578,25 @@ describe('autocomplete.suggest', () => {
sortText: '1A',
});
});
test('suggests `Create control` option when ? is being typed', async () => {
const suggestions = await suggest('FROM a | STATS PERCENTILE(bytes, ?/)', {
callbacks: {
canSuggestVariables: () => true,
getVariables: () => [],
getColumnsFor: () => Promise.resolve([{ name: 'bytes', type: 'double' }]),
},
});
expect(suggestions).toContainEqual({
label: 'Create control',
text: '',
kind: 'Issue',
detail: 'Click to create',
command: { id: 'esql.control.values.create', title: 'Click to create' },
sortText: '1',
});
});
});
});
});

View file

@ -445,6 +445,27 @@ describe('WHERE <expression>', () => {
rangeToReplace: { start: 30, end: 30 },
});
});
test('suggests `Create control` option when a questionmark is typed', async () => {
const { suggest } = await setup();
const suggestions = await suggest('FROM a | WHERE agent.name == ?/', {
callbacks: {
canSuggestVariables: () => true,
getVariables: () => [],
getColumnsFor: () => Promise.resolve([{ name: 'agent.name', type: 'keyword' }]),
},
});
expect(suggestions).toContainEqual({
label: 'Create control',
text: '',
kind: 'Issue',
detail: 'Click to create',
command: { id: 'esql.control.values.create', title: 'Click to create' },
sortText: '1',
});
});
});
});
});

View file

@ -16,7 +16,7 @@ import {
type ESQLFunction,
type ESQLSingleAstItem,
} from '@kbn/esql-ast';
import type { ESQLControlVariable } from '@kbn/esql-types';
import { type ESQLControlVariable, ESQLVariableType } from '@kbn/esql-types';
import { isNumericType } from '../shared/esql_types';
import type { EditorContext, ItemKind, SuggestionRawDefinition, GetColumnsByTypeFn } from './types';
import {
@ -49,6 +49,7 @@ import {
buildValueDefinitions,
getDateLiterals,
buildFieldsDefinitionsWithMetadata,
getControlSuggestionIfSupported,
} from './factories';
import { EDITOR_MARKER, FULL_TEXT_SEARCH_FUNCTIONS } from '../shared/constants';
import { getAstContext } from '../shared/context';
@ -107,7 +108,8 @@ export async function suggest(
const { getFieldsByType, getFieldsMap } = getFieldsByTypeRetriever(
queryForFields.replace(EDITOR_MARKER, ''),
resourceRetriever
resourceRetriever,
innerText
);
const supportsControls = resourceRetriever?.canSuggestVariables?.() ?? false;
const getVariables = resourceRetriever?.getVariables;
@ -131,7 +133,8 @@ export async function suggest(
const { getFieldsByType: getFieldsByTypeEmptyState } = getFieldsByTypeRetriever(
fromCommand,
resourceRetriever
resourceRetriever,
innerText
);
recommendedQueriesSuggestions.push(
...(await getRecommendedQueriesSuggestions(getFieldsByTypeEmptyState, fromCommand))
@ -144,8 +147,21 @@ export async function suggest(
return suggestions.filter((def) => !isSourceCommand(def));
}
// ToDo: Reconsider where it belongs when this is resolved https://github.com/elastic/kibana/issues/216492
const lastCharacterTyped = innerText[innerText.length - 1];
let controlSuggestions: SuggestionRawDefinition[] = [];
if (lastCharacterTyped === '?') {
controlSuggestions = getControlSuggestionIfSupported(
Boolean(supportsControls),
ESQLVariableType.VALUES,
getVariables
);
return controlSuggestions;
}
if (astContext.type === 'expression') {
return getSuggestionsWithinCommandExpression(
const commandsSpecificSuggestions = await getSuggestionsWithinCommandExpression(
innerText,
ast,
astContext,
@ -159,9 +175,10 @@ export async function suggest(
resourceRetriever,
supportsControls
);
return commandsSpecificSuggestions;
}
if (astContext.type === 'function') {
return getFunctionArgsSuggestions(
const functionsSpecificSuggestions = await getFunctionArgsSuggestions(
innerText,
ast,
astContext,
@ -172,6 +189,7 @@ export async function suggest(
getVariables,
supportsControls
);
return functionsSpecificSuggestions;
}
if (astContext.type === 'list') {
return getListArgsSuggestions(
@ -187,12 +205,17 @@ export async function suggest(
}
export function getFieldsByTypeRetriever(
queryString: string,
resourceRetriever?: ESQLCallbacks
queryForFields: string,
resourceRetriever?: ESQLCallbacks,
fullQuery?: string
): { getFieldsByType: GetColumnsByTypeFn; getFieldsMap: GetFieldsMapFn } {
const helpers = getFieldsByTypeHelper(queryString, resourceRetriever);
const helpers = getFieldsByTypeHelper(queryForFields, resourceRetriever);
const getVariables = resourceRetriever?.getVariables;
const supportsControls = resourceRetriever?.canSuggestVariables?.() ?? false;
const canSuggestVariables = resourceRetriever?.canSuggestVariables?.() ?? false;
const queryString = fullQuery ?? queryForFields;
const lastCharacterTyped = queryString[queryString.length - 1];
const lastCharIsQuestionMark = lastCharacterTyped === '?';
return {
getFieldsByType: async (
expectedType: Readonly<string> | Readonly<string[]> = 'any',
@ -201,7 +224,7 @@ export function getFieldsByTypeRetriever(
) => {
const updatedOptions = {
...options,
supportsControls,
supportsControls: canSuggestVariables && !lastCharIsQuestionMark,
};
const fields = await helpers.getFieldsByType(expectedType, ignored);
return buildFieldsDefinitionsWithMetadata(fields, updatedOptions, getVariables);

View file

@ -95,7 +95,7 @@ export const ESQLLang: CustomLangModuleType<ESQLCallbacks> = {
},
getSuggestionProvider: (callbacks?: ESQLCallbacks): monaco.languages.CompletionItemProvider => {
return {
triggerCharacters: [',', '(', '=', ' ', '[', ''],
triggerCharacters: [',', '(', '=', ' ', '[', '', '?'],
async provideCompletionItems(
model: monaco.editor.ITextModel,
position: monaco.Position,

View file

@ -70,7 +70,7 @@ async function getHoverItemForFunction(
buildQueryUntilPreviousCommand(ast, correctedQuery),
ast
);
const { getFieldsMap } = getFieldsByTypeRetriever(queryForFields, resourceRetriever);
const { getFieldsMap } = getFieldsByTypeRetriever(queryForFields, resourceRetriever, innerText);
const fnDefinition = getFunctionDefinition(node.name);
// early exit on no hit

View file

@ -42,6 +42,70 @@ describe('helpers', () => {
);
expect(updatedQueryString).toBe('FROM my_index \n| STATS BY ?my_variable');
});
it('should adjust the query string for trailing question mark', () => {
const queryString = 'FROM my_index | STATS BY ?';
const cursorPosition = { column: 27, lineNumber: 1 } as monaco.Position;
const variable = '?my_variable';
const updatedQueryString = updateQueryStringWithVariable(
queryString,
variable,
cursorPosition
);
expect(updatedQueryString).toBe('FROM my_index | STATS BY ?my_variable');
});
it('should adjust the query string if there is a ? at the second last position', () => {
const queryString = 'FROM my_index | STATS PERCENTILE(bytes, ?)';
const cursorPosition = { column: 42, lineNumber: 1 } as monaco.Position;
const variable = '?my_variable';
const updatedQueryString = updateQueryStringWithVariable(
queryString,
variable,
cursorPosition
);
expect(updatedQueryString).toBe('FROM my_index | STATS PERCENTILE(bytes, ?my_variable)');
});
it('should adjust the query string if there is a ? at the last cursor position', () => {
const queryString =
'FROM my_index | STATS COUNT() BY BUCKET(@timestamp, ?, ?_tstart, ?_tend)';
const cursorPosition = {
lineNumber: 1,
column: 54,
} as monaco.Position;
const variable = '?my_variable';
const updatedQueryString = updateQueryStringWithVariable(
queryString,
variable,
cursorPosition
);
expect(updatedQueryString).toBe(
'FROM my_index | STATS COUNT() BY BUCKET(@timestamp, ?my_variable, ?_tstart, ?_tend)'
);
});
it('should adjust the query string if there is a ? at the last cursor position for multilines query', () => {
const queryString =
'FROM my_index \n| STATS COUNT() BY BUCKET(@timestamp, ?, ?_tstart, ?_tend)';
const cursorPosition = {
lineNumber: 2,
column: 40,
} as monaco.Position;
const variable = '?my_variable';
const updatedQueryString = updateQueryStringWithVariable(
queryString,
variable,
cursorPosition
);
expect(updatedQueryString).toBe(
'FROM my_index \n| STATS COUNT() BY BUCKET(@timestamp, ?my_variable, ?_tstart, ?_tend)'
);
});
});
describe('getQueryForFields', () => {

View file

@ -15,6 +15,32 @@ function inKnownTimeInterval(timeIntervalUnit: string): boolean {
return timeUnits.some((unit) => unit === timeIntervalUnit.toLowerCase());
}
const getQueryPart = (queryString: string, cursorColumn: number, variable: string) => {
const queryStringTillCursor = queryString.slice(0, cursorColumn);
const lastChar = queryStringTillCursor.slice(-1);
const secondLastChar = queryStringTillCursor.slice(-2, -1);
if (lastChar === '?') {
return [
queryString.slice(0, cursorColumn - 2),
variable,
queryString.slice(cursorColumn - 1),
].join('');
} else if (secondLastChar === '?') {
return [
queryString.slice(0, cursorColumn - 2),
variable,
queryString.slice(cursorColumn - 1),
].join('');
}
return [
queryString.slice(0, cursorColumn - 1),
variable,
queryString.slice(cursorColumn - 1),
].join('');
};
export const updateQueryStringWithVariable = (
queryString: string,
variable: string,
@ -27,20 +53,13 @@ export const updateQueryStringWithVariable = (
if (lines.length > 1) {
const queryArray = queryString.split('\n');
const queryPartToBeUpdated = queryArray[cursorLine - 1];
const queryWithVariable = [
queryPartToBeUpdated.slice(0, cursorColumn - 1),
variable,
queryPartToBeUpdated.slice(cursorColumn - 1),
].join('');
const queryWithVariable = getQueryPart(queryPartToBeUpdated, cursorColumn, variable);
queryArray[cursorLine - 1] = queryWithVariable;
return queryArray.join('\n');
}
return [
queryString.slice(0, cursorColumn - 1),
variable,
queryString.slice(cursorColumn - 1),
].join('');
return [getQueryPart(queryString, cursorColumn, variable)].join('');
};
export const getQueryForFields = (queryString: string, cursorPosition?: monaco.Position) => {

View file

@ -59,10 +59,10 @@ export function ESQLControlsFlyout({
);
const valuesField = useMemo(() => {
if (initialVariableType === ESQLVariableType.VALUES) {
return getValuesFromQueryField(queryString);
return getValuesFromQueryField(queryString, cursorPosition);
}
return undefined;
}, [initialVariableType, queryString]);
}, [cursorPosition, initialVariableType, queryString]);
const isControlInEditMode = useMemo(() => !!initialState, [initialState]);
const styling = useMemo(() => getFlyoutStyling(), []);