mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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 ?   ### 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:
parent
0e882dda8a
commit
751e44d5da
11 changed files with 239 additions and 27 deletions
|
@ -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', () => {
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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(), []);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue