[ES|QL] Creates controls for stats functions (#210170)

## Summary

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

Allows the creation of function controls. These are only available for
STATS

<img width="880" alt="image"
src="https://github.com/user-attachments/assets/fe57c3e5-f42a-4d9c-95b3-4a5a12938821"
/>


### Release notes
Allows the creation of dynamic aggregations controls for ES|QL charts.

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [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
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Stratoula Kalafateli 2025-02-13 12:28:18 +01:00 committed by GitHub
parent 159910d06f
commit f96b68dbac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 502 additions and 290 deletions

View file

@ -317,6 +317,19 @@ export const ESQLEditor = memo(function ESQLEditor({
);
});
monaco.editor.registerCommand('esql.control.functions.create', async (...args) => {
const position = editor1.current?.getPosition();
await triggerControl(
query.esql,
ESQLVariableType.FUNCTIONS,
position,
uiActions,
esqlVariables,
onSaveControl,
onCancelControl
);
});
const styles = esqlEditorStyles(
theme.euiTheme,
editorHeight,

View file

@ -82,6 +82,11 @@ describe('run query helpers', () => {
value: 'go',
type: ESQLVariableType.VALUES,
},
{
key: 'function',
value: 'count',
type: ESQLVariableType.FUNCTIONS,
},
];
const params = getNamedParams(query, time, variables);
expect(params).toStrictEqual([
@ -96,6 +101,11 @@ describe('run query helpers', () => {
{
agent_name: 'go',
},
{
function: {
identifier: 'count',
},
},
]);
});
@ -119,9 +129,14 @@ describe('run query helpers', () => {
value: 'go',
type: ESQLVariableType.VALUES,
},
{
key: 'function',
value: 'count',
type: ESQLVariableType.FUNCTIONS,
},
];
const params = getNamedParams(query, time, variables);
expect(params).toHaveLength(5);
expect(params).toHaveLength(6);
expect(params[0]).toHaveProperty('_tstart');
expect(params[1]).toHaveProperty('_tend');
expect(params[2]).toStrictEqual({
@ -135,6 +150,12 @@ describe('run query helpers', () => {
expect(params[4]).toStrictEqual({
agent_name: 'go',
});
expect(params[5]).toStrictEqual({
function: {
identifier: 'count',
},
});
});
});

View file

@ -47,7 +47,7 @@ export const getNamedParams = (
const namedParams: ESQLSearchParams['params'] = getStartEndParams(query, timeRange);
if (variables?.length) {
variables?.forEach(({ key, value, type }) => {
if (type === ESQLVariableType.FIELDS) {
if (type === ESQLVariableType.FIELDS || type === ESQLVariableType.FUNCTIONS) {
namedParams.push({ [key]: { identifier: value } });
} else {
namedParams.push({ [key]: value });

View file

@ -68,6 +68,7 @@ export {
} from './src/shared/helpers';
export { ENRICH_MODES } from './src/definitions/settings';
export { timeUnits } from './src/definitions/literals';
export { aggregationFunctionDefinitions } from './src/definitions/generated/aggregation_functions';
export { getFunctionSignatures } from './src/definitions/helpers';
export {

View file

@ -373,7 +373,55 @@ describe('autocomplete.suggest', () => {
});
describe('create control suggestion', () => {
test('suggests `Create control` option', async () => {
test('suggests `Create control` option for aggregations', async () => {
const { suggest } = await setup();
const suggestions = await suggest('FROM a | STATS /', {
callbacks: {
canSuggestVariables: () => true,
getVariablesByType: () => [],
getColumnsFor: () => Promise.resolve([{ name: 'clientip', type: 'ip' }]),
},
});
expect(suggestions).toContainEqual({
label: 'Create control',
text: '',
kind: 'Issue',
detail: 'Click to create',
command: { id: 'esql.control.functions.create', title: 'Click to create' },
sortText: '1',
});
});
test('suggests `?function` option', async () => {
const { suggest } = await setup();
const suggestions = await suggest('FROM a | STATS var0 = /', {
callbacks: {
canSuggestVariables: () => true,
getVariablesByType: () => [
{
key: 'function',
value: 'avg',
type: ESQLVariableType.FUNCTIONS,
},
],
getColumnsFor: () => Promise.resolve([{ name: 'clientip', type: 'ip' }]),
},
});
expect(suggestions).toContainEqual({
label: '?function',
text: '?function',
kind: 'Constant',
detail: 'Named parameter',
command: undefined,
sortText: '1A',
});
});
test('suggests `Create control` option for grouping', async () => {
const { suggest } = await setup();
const suggestions = await suggest('FROM a | STATS BY /', {
@ -390,7 +438,7 @@ describe('autocomplete.suggest', () => {
kind: 'Issue',
detail: 'Click to create',
command: { id: 'esql.control.fields.create', title: 'Click to create' },
sortText: '11A',
sortText: '11',
});
});

View file

@ -407,7 +407,7 @@ describe('WHERE <expression>', () => {
kind: 'Issue',
detail: 'Click to create',
command: { id: 'esql.control.values.create', title: 'Click to create' },
sortText: '11A',
sortText: '11',
rangeToReplace: { start: 31, end: 31 },
});
});

View file

@ -220,9 +220,10 @@ export async function suggest(
getFieldsByType,
getFieldsMap,
getPolicies,
getPolicyMetadata,
getVariablesByType,
resourceRetriever?.getPreferences,
resourceRetriever
resourceRetriever,
supportsControls
);
}
if (astContext.type === 'setting') {
@ -395,9 +396,10 @@ async function getSuggestionsWithinCommandExpression(
getColumnsByType: GetColumnsByTypeFn,
getFieldsMap: GetFieldsMapFn,
getPolicies: GetPoliciesFn,
getPolicyMetadata: GetPolicyMetadataFn,
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined,
getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined>,
callbacks?: ESQLCallbacks
callbacks?: ESQLCallbacks,
supportsControls?: boolean
) {
const commandDef = getCommandDefinition(command.name);
@ -424,6 +426,8 @@ async function getSuggestionsWithinCommandExpression(
getSourcesFromQuery: (type) => getSourcesFromCommands(commands, type),
previousCommands: commands,
callbacks,
getVariablesByType,
supportsControls,
});
} else {
// The deprecated path.
@ -434,8 +438,7 @@ async function getSuggestionsWithinCommandExpression(
getSources,
getColumnsByType,
getFieldsMap,
getPolicies,
getPolicyMetadata
getPolicies
);
}
}
@ -459,8 +462,7 @@ async function getExpressionSuggestionsByType(
getSources: () => Promise<ESQLSourceResult[]>,
getFieldsByType: GetColumnsByTypeFn,
getFieldsMap: GetFieldsMapFn,
getPolicies: GetPoliciesFn,
getPolicyMetadata: GetPolicyMetadataFn
getPolicies: GetPoliciesFn
) {
const commandDef = getCommandDefinition(command.name);
const { argIndex, prevIndex, lastArg, nodeArg } = extractArgMeta(command, node);

View file

@ -13,7 +13,9 @@ import {
TRIGGER_SUGGESTION_COMMAND,
getNewVariableSuggestion,
getFunctionSuggestions,
getControlSuggestionIfSupported,
} from '../../factories';
import { ESQLVariableType } from '../../../shared/types';
import { commaCompleteItem, pipeCompleteItem } from '../../complete_items';
import { pushItUpInTheList } from '../../helper';
import { byCompleteItem, getDateHistogramCompletionItem, getPosition } from './util';
@ -24,6 +26,8 @@ export async function suggest({
getColumnsByType,
getSuggestedVariableName,
getPreferences,
getVariablesByType,
supportsControls,
}: CommandSuggestParams<'stats'>): Promise<SuggestionRawDefinition[]> {
const pos = getPosition(innerText, command);
@ -31,16 +35,22 @@ export async function suggest({
await getColumnsByType('any', [], { advanceCursor: true, openSuggestions: true }),
true
);
const controlSuggestions = getControlSuggestionIfSupported(
Boolean(supportsControls),
ESQLVariableType.FUNCTIONS,
getVariablesByType
);
switch (pos) {
case 'expression_without_assignment':
return [
...controlSuggestions,
...getFunctionSuggestions({ command: 'stats' }),
getNewVariableSuggestion(getSuggestedVariableName()),
];
case 'expression_after_assignment':
return [...getFunctionSuggestions({ command: 'stats' })];
return [...controlSuggestions, ...getFunctionSuggestions({ command: 'stats' })];
case 'expression_complete':
return [

View file

@ -583,6 +583,23 @@ export function getDateLiterals(options?: {
];
}
export function getControlSuggestionIfSupported(
supportsControls: boolean,
type: ESQLVariableType,
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined
) {
if (!supportsControls) {
return [];
}
const variableType = type;
const variables = getVariablesByType?.(variableType) ?? [];
const controlSuggestion = getControlSuggestion(
variableType,
variables?.map((v) => `?${v.key}`)
);
return controlSuggestion;
}
export function getControlSuggestion(
type: ESQLVariableType,
variables?: string[]
@ -603,7 +620,7 @@ export function getControlSuggestion(
defaultMessage: 'Click to create',
}
),
sortText: '1A',
sortText: '1',
command: {
id: `esql.control.${type}.create`,
title: i18n.translate(

View file

@ -16,7 +16,12 @@ import type {
ESQLSource,
} from '@kbn/esql-ast';
import { GetColumnsByTypeFn, SuggestionRawDefinition } from '../autocomplete/types';
import type { ESQLCallbacks, ESQLSourceResult } from '../shared/types';
import type {
ESQLCallbacks,
ESQLControlVariable,
ESQLVariableType,
ESQLSourceResult,
} from '../shared/types';
/**
* All supported field types in ES|QL. This is all the types
@ -245,6 +250,8 @@ export interface CommandSuggestParams<CommandName extends string> {
*/
previousCommands?: ESQLCommand[];
callbacks?: ESQLCallbacks;
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined;
supportsControls?: boolean;
}
export type CommandSuggestFunction<CommandName extends string> = (

View file

@ -46,6 +46,7 @@ export enum ESQLVariableType {
TIME_LITERAL = 'time_literal',
FIELDS = 'fields',
VALUES = 'values',
FUNCTIONS = 'functions',
}
export interface ESQLCallbacks {

View file

@ -1,201 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 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 React from 'react';
import { render, within, fireEvent } from '@testing-library/react';
import { monaco } from '@kbn/monaco';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { ESQLVariableType } from '@kbn/esql-validation-autocomplete';
import { FieldControlForm } from './field_control_form';
import { ESQLControlState, EsqlControlType } from '../types';
jest.mock('@kbn/esql-utils', () => ({
getESQLQueryColumnsRaw: jest.fn().mockResolvedValue([{ name: 'column1' }, { name: 'column2' }]),
}));
describe('FieldControlForm', () => {
const dataMock = dataPluginMock.createStartContract();
const searchMock = dataMock.search.search;
it('should default correctly if no initial state is given', async () => {
const { findByTestId, findByTitle } = render(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// control type dropdown should be rendered and default to 'STATIC_VALUES'
// no need to test further as the control type is disabled
expect(await findByTestId('esqlControlTypeDropdown')).toBeInTheDocument();
const controlTypeInputPopover = await findByTestId('esqlControlTypeInputPopover');
expect(within(controlTypeInputPopover).getByRole('combobox')).toHaveValue(`Static values`);
// variable name input should be rendered and with the default value
expect(await findByTestId('esqlVariableName')).toHaveValue('field');
// fields dropdown should be rendered with available fields column1 and column2
const fieldsOptionsDropdown = await findByTestId('esqlFieldsOptions');
expect(fieldsOptionsDropdown).toBeInTheDocument();
const fieldsOptionsDropdownSearchInput = within(fieldsOptionsDropdown).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
expect(fieldsOptionsDropdownSearchInput).toHaveValue('');
expect(await findByTitle('column1')).toBeDefined();
expect(await findByTitle('column2')).toBeDefined();
// variable label input should be rendered and with the default value (empty)
expect(await findByTestId('esqlControlLabel')).toHaveValue('');
// control width dropdown should be rendered and default to 'MEDIUM'
expect(await findByTestId('esqlControlMinimumWidth')).toBeInTheDocument();
const pressedWidth = within(await findByTestId('esqlControlMinimumWidth')).getByTitle('Medium');
expect(pressedWidth).toHaveAttribute('aria-pressed', 'true');
// control grow switch should be rendered and default to 'false'
expect(await findByTestId('esqlControlGrow')).toBeInTheDocument();
const growSwitch = await findByTestId('esqlControlGrow');
expect(growSwitch).not.toBeChecked();
});
it('should call the onCreateControl callback, if no initialState is given', async () => {
const onCreateControlSpy = jest.fn();
const { findByTestId, findByTitle } = render(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={onCreateControlSpy}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// select the first field
const fieldsOptionsDropdownSearchInput = within(
await findByTestId('esqlFieldsOptions')
).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
fireEvent.click(await findByTitle('column1'));
// click on the create button
fireEvent.click(await findByTestId('saveEsqlControlsFlyoutButton'));
expect(onCreateControlSpy).toHaveBeenCalled();
});
it('should call the onCancelControl callback, if Cancel button is clicked', async () => {
const onCancelControlSpy = jest.fn();
const { findByTestId } = render(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
onCancelControl={onCancelControlSpy}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// click on the cancel button
fireEvent.click(await findByTestId('cancelEsqlControlsFlyoutButton'));
expect(onCancelControlSpy).toHaveBeenCalled();
});
it('should default correctly if initial state is given', async () => {
const initialState = {
grow: true,
width: 'small',
title: 'my control',
availableOptions: ['column2'],
selectedOptions: ['column2'],
variableName: 'myField',
variableType: ESQLVariableType.FIELDS,
esqlQuery: 'FROM foo | STATS BY',
controlType: EsqlControlType.STATIC_VALUES,
} as ESQLControlState;
const { findByTestId } = render(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
initialState={initialState}
esqlVariables={[]}
/>
);
// variable name input should be rendered and with the default value
expect(await findByTestId('esqlVariableName')).toHaveValue('myField');
// fields dropdown should be rendered with column2 selected
const fieldsOptionsDropdown = await findByTestId('esqlFieldsOptions');
const fieldsOptionsDropdownBadge = within(fieldsOptionsDropdown).getByTestId('column2');
expect(fieldsOptionsDropdownBadge).toBeInTheDocument();
// variable label input should be rendered and with the default value (my control)
expect(await findByTestId('esqlControlLabel')).toHaveValue('my control');
// control width dropdown should be rendered and default to 'MEDIUM'
expect(await findByTestId('esqlControlMinimumWidth')).toBeInTheDocument();
const pressedWidth = within(await findByTestId('esqlControlMinimumWidth')).getByTitle('Small');
expect(pressedWidth).toHaveAttribute('aria-pressed', 'true');
// control grow switch should be rendered and default to 'false'
expect(await findByTestId('esqlControlGrow')).toBeInTheDocument();
const growSwitch = await findByTestId('esqlControlGrow');
expect(growSwitch).toBeChecked();
});
it('should call the onEditControl callback, if initialState is given', async () => {
const initialState = {
grow: true,
width: 'small',
title: 'my control',
availableOptions: ['column2'],
selectedOptions: ['column2'],
variableName: 'myField',
variableType: ESQLVariableType.FIELDS,
esqlQuery: 'FROM foo | STATS BY',
controlType: EsqlControlType.STATIC_VALUES,
} as ESQLControlState;
const onEditControlSpy = jest.fn();
const { findByTestId, findByTitle } = render(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={onEditControlSpy}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
initialState={initialState}
esqlVariables={[]}
/>
);
// select the first field
const fieldsOptionsDropdownSearchInput = within(
await findByTestId('esqlFieldsOptions')
).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
fireEvent.click(await findByTitle('column1'));
// click on the create button
fireEvent.click(await findByTestId('saveEsqlControlsFlyoutButton'));
expect(onEditControlSpy).toHaveBeenCalled();
});
});

View file

@ -75,14 +75,14 @@ describe('helpers', () => {
describe('getRecurrentVariableName', () => {
it('should return a new name if the name already exists', () => {
const name = 'field';
const existingNames = ['field', 'field1', 'field2'];
const existingNames = new Set(['field', 'field1', 'field2']);
const newName = getRecurrentVariableName(name, existingNames);
expect(newName).toBe('field3');
});
it('should return the same name if the name does not exist', () => {
const name = 'field';
const existingNames = ['field1', 'field2'];
const existingNames = new Set(['field1', 'field2']);
const newName = getRecurrentVariableName(name, existingNames);
expect(newName).toBe('field');
});

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { monaco } from '@kbn/monaco';
import { timeUnits } from '@kbn/esql-validation-autocomplete';
import { timeUnits, ESQLVariableType } from '@kbn/esql-validation-autocomplete';
function inKnownTimeInterval(timeIntervalUnit: string): boolean {
return timeUnits.some((unit) => unit === timeIntervalUnit.toLowerCase());
@ -65,10 +65,23 @@ export const areValuesIntervalsValid = (values: string[]) => {
});
};
export const getRecurrentVariableName = (name: string, existingNames: string[]) => {
export const getVariablePrefix = (variableType: ESQLVariableType) => {
switch (variableType) {
case ESQLVariableType.FIELDS:
return 'field';
case ESQLVariableType.FUNCTIONS:
return 'function';
case ESQLVariableType.TIME_LITERAL:
return 'interval';
default:
return 'variable';
}
};
export const getRecurrentVariableName = (name: string, existingNames: Set<string>) => {
let newName = name;
let i = 1;
while (existingNames.includes(newName)) {
while (existingNames.has(newName)) {
newName = `${name}${i}`;
i++;
}

View file

@ -0,0 +1,243 @@
/*
* 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 React from 'react';
import { render, within, fireEvent } from '@testing-library/react';
import { monaco } from '@kbn/monaco';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { ESQLVariableType } from '@kbn/esql-validation-autocomplete';
import { IdentifierControlForm } from './identifier_control_form';
import { ESQLControlState, EsqlControlType } from '../types';
jest.mock('@kbn/esql-utils', () => ({
getESQLQueryColumnsRaw: jest.fn().mockResolvedValue([{ name: 'column1' }, { name: 'column2' }]),
}));
describe('IdentifierControlForm', () => {
const dataMock = dataPluginMock.createStartContract();
const searchMock = dataMock.search.search;
beforeEach(() => {
jest.clearAllMocks();
});
describe('Field type', () => {
it('should default correctly if no initial state is given', async () => {
const { findByTestId, findByTitle } = render(
<IdentifierControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// control type dropdown should be rendered and default to 'STATIC_VALUES'
// no need to test further as the control type is disabled
expect(await findByTestId('esqlControlTypeDropdown')).toBeInTheDocument();
const controlTypeInputPopover = await findByTestId('esqlControlTypeInputPopover');
expect(within(controlTypeInputPopover).getByRole('combobox')).toHaveValue(`Static values`);
// variable name input should be rendered and with the default value
expect(await findByTestId('esqlVariableName')).toHaveValue('field');
// fields dropdown should be rendered with available fields column1 and column2
const fieldsOptionsDropdown = await findByTestId('esqlIdentifiersOptions');
expect(fieldsOptionsDropdown).toBeInTheDocument();
const fieldsOptionsDropdownSearchInput = within(fieldsOptionsDropdown).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
expect(fieldsOptionsDropdownSearchInput).toHaveValue('');
expect(await findByTitle('column1')).toBeDefined();
expect(await findByTitle('column2')).toBeDefined();
// variable label input should be rendered and with the default value (empty)
expect(await findByTestId('esqlControlLabel')).toHaveValue('');
// control width dropdown should be rendered and default to 'MEDIUM'
expect(await findByTestId('esqlControlMinimumWidth')).toBeInTheDocument();
const pressedWidth = within(await findByTestId('esqlControlMinimumWidth')).getByTitle(
'Medium'
);
expect(pressedWidth).toHaveAttribute('aria-pressed', 'true');
// control grow switch should be rendered and default to 'false'
expect(await findByTestId('esqlControlGrow')).toBeInTheDocument();
const growSwitch = await findByTestId('esqlControlGrow');
expect(growSwitch).not.toBeChecked();
});
it('should call the onCreateControl callback, if no initialState is given', async () => {
const onCreateControlSpy = jest.fn();
const { findByTestId, findByTitle } = render(
<IdentifierControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={onCreateControlSpy}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// select the first field
const fieldsOptionsDropdownSearchInput = within(
await findByTestId('esqlIdentifiersOptions')
).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
fireEvent.click(await findByTitle('column1'));
// click on the create button
fireEvent.click(await findByTestId('saveEsqlControlsFlyoutButton'));
expect(onCreateControlSpy).toHaveBeenCalled();
});
it('should call the onCancelControl callback, if Cancel button is clicked', async () => {
const onCancelControlSpy = jest.fn();
const { findByTestId } = render(
<IdentifierControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
onCancelControl={onCancelControlSpy}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// click on the cancel button
fireEvent.click(await findByTestId('cancelEsqlControlsFlyoutButton'));
expect(onCancelControlSpy).toHaveBeenCalled();
});
it('should default correctly if initial state is given', async () => {
const initialState = {
grow: true,
width: 'small',
title: 'my control',
availableOptions: ['column2'],
selectedOptions: ['column2'],
variableName: 'myField',
variableType: ESQLVariableType.FIELDS,
esqlQuery: 'FROM foo | STATS BY',
controlType: EsqlControlType.STATIC_VALUES,
} as ESQLControlState;
const { findByTestId } = render(
<IdentifierControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
initialState={initialState}
esqlVariables={[]}
/>
);
// variable name input should be rendered and with the default value
expect(await findByTestId('esqlVariableName')).toHaveValue('myField');
// fields dropdown should be rendered with column2 selected
const fieldsOptionsDropdown = await findByTestId('esqlIdentifiersOptions');
const fieldsOptionsDropdownBadge = within(fieldsOptionsDropdown).getByTestId('column2');
expect(fieldsOptionsDropdownBadge).toBeInTheDocument();
// variable label input should be rendered and with the default value (my control)
expect(await findByTestId('esqlControlLabel')).toHaveValue('my control');
// control width dropdown should be rendered and default to 'MEDIUM'
expect(await findByTestId('esqlControlMinimumWidth')).toBeInTheDocument();
const pressedWidth = within(await findByTestId('esqlControlMinimumWidth')).getByTitle(
'Small'
);
expect(pressedWidth).toHaveAttribute('aria-pressed', 'true');
// control grow switch should be rendered and default to 'false'
expect(await findByTestId('esqlControlGrow')).toBeInTheDocument();
const growSwitch = await findByTestId('esqlControlGrow');
expect(growSwitch).toBeChecked();
});
it('should call the onEditControl callback, if initialState is given', async () => {
const initialState = {
grow: true,
width: 'small',
title: 'my control',
availableOptions: ['column2'],
selectedOptions: ['column2'],
variableName: 'myField',
variableType: ESQLVariableType.FIELDS,
esqlQuery: 'FROM foo | STATS BY',
controlType: EsqlControlType.STATIC_VALUES,
} as ESQLControlState;
const onEditControlSpy = jest.fn();
const { findByTestId, findByTitle } = render(
<IdentifierControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={onEditControlSpy}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
initialState={initialState}
esqlVariables={[]}
/>
);
// select the first field
const fieldsOptionsDropdownSearchInput = within(
await findByTestId('esqlIdentifiersOptions')
).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
fireEvent.click(await findByTitle('column1'));
// click on the create button
fireEvent.click(await findByTestId('saveEsqlControlsFlyoutButton'));
expect(onEditControlSpy).toHaveBeenCalled();
});
});
describe('Functions type', () => {
it('should default correctly if no initial state is given', async () => {
const { findByTestId, findByTitle } = render(
<IdentifierControlForm
variableType={ESQLVariableType.FUNCTIONS}
queryString="FROM foo | STATS "
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 17, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// control type dropdown should be rendered and default to 'STATIC_VALUES'
expect(await findByTestId('esqlControlTypeDropdown')).toBeInTheDocument();
const controlTypeInputPopover = await findByTestId('esqlControlTypeInputPopover');
expect(within(controlTypeInputPopover).getByRole('combobox')).toHaveValue(`Static values`);
// variable name input should be rendered and with the default value
expect(await findByTestId('esqlVariableName')).toHaveValue('function');
// fields dropdown should be rendered with available functions
const fieldsOptionsDropdown = await findByTestId('esqlIdentifiersOptions');
expect(fieldsOptionsDropdown).toBeInTheDocument();
const fieldsOptionsDropdownSearchInput = within(fieldsOptionsDropdown).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
expect(await findByTitle('avg')).toBeDefined();
expect(await findByTitle('median')).toBeDefined();
});
});
});

View file

@ -8,6 +8,7 @@
*/
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import { i18n } from '@kbn/i18n';
import {
EuiComboBox,
@ -19,7 +20,11 @@ import {
import { css } from '@emotion/react';
import { monaco } from '@kbn/monaco';
import type { ISearchGeneric } from '@kbn/search-types';
import { ESQLVariableType, ESQLControlVariable } from '@kbn/esql-validation-autocomplete';
import {
ESQLVariableType,
ESQLControlVariable,
aggregationFunctionDefinitions,
} from '@kbn/esql-validation-autocomplete';
import { getESQLQueryColumnsRaw } from '@kbn/esql-utils';
import type { ESQLControlState, ControlWidthOptions } from '../types';
import {
@ -35,10 +40,11 @@ import {
getFlyoutStyling,
getQueryForFields,
validateVariableName,
getVariablePrefix,
} from './helpers';
import { EsqlControlType } from '../types';
interface FieldControlFormProps {
interface IdentifierControlFormProps {
search: ISearchGeneric;
variableType: ESQLVariableType;
queryString: string;
@ -51,7 +57,7 @@ interface FieldControlFormProps {
onCancelControl?: () => void;
}
export function FieldControlForm({
export function IdentifierControlForm({
variableType,
initialState,
queryString,
@ -62,23 +68,28 @@ export function FieldControlForm({
onCancelControl,
search,
closeFlyout,
}: FieldControlFormProps) {
}: IdentifierControlFormProps) {
const isMounted = useMountedState();
const suggestedVariableName = useMemo(() => {
const existingVariables = esqlVariables.filter((variable) => variable.type === variableType);
const existingVariables = new Set(
esqlVariables
.filter((variable) => variable.type === variableType)
.map((variable) => variable.key)
);
return initialState
? `${initialState.variableName}`
: getRecurrentVariableName(
'field',
existingVariables.map((variable) => variable.key)
);
if (initialState) {
return initialState.variableName;
}
const variablePrefix = getVariablePrefix(variableType);
return getRecurrentVariableName(variablePrefix, existingVariables);
}, [esqlVariables, initialState, variableType]);
const [availableFieldsOptions, setAvailableFieldsOptions] = useState<EuiComboBoxOptionOption[]>(
[]
);
const [availableIdentifiersOptions, setAvailableIdentifiersOptions] = useState<
EuiComboBoxOptionOption[]
>([]);
const [selectedFields, setSelectedFields] = useState<EuiComboBoxOptionOption[]>(
const [selectedIdentifiers, setSelectedIdentifiers] = useState<EuiComboBoxOptionOption[]>(
initialState
? initialState.availableOptions.map((option) => {
return {
@ -97,36 +108,59 @@ export function FieldControlForm({
const isControlInEditMode = useMemo(() => !!initialState, [initialState]);
useEffect(() => {
if (!availableFieldsOptions.length) {
const queryForFields = getQueryForFields(queryString, cursorPosition);
getESQLQueryColumnsRaw({
esqlQuery: queryForFields,
search,
}).then((columns) => {
setAvailableFieldsOptions(
columns.map((col) => {
return {
label: col.name,
key: col.name,
'data-test-subj': col.name,
};
})
);
});
}
}, [availableFieldsOptions.length, variableType, cursorPosition, queryString, search]);
useEffect(
function initAvailableIdentifiersOptions() {
if (availableIdentifiersOptions.length > 0) return;
if (variableType === ESQLVariableType.FIELDS) {
const queryForFields = getQueryForFields(queryString, cursorPosition);
getESQLQueryColumnsRaw({
esqlQuery: queryForFields,
search,
}).then((columns) => {
if (isMounted()) {
setAvailableIdentifiersOptions(
columns.map((col) => {
return {
label: col.name,
key: col.name,
'data-test-subj': col.name,
};
})
);
}
});
}
if (variableType === ESQLVariableType.FUNCTIONS) {
const aggregatedFunctions = aggregationFunctionDefinitions.map((func) => {
return {
label: func.name,
key: func.name,
'data-test-subj': func.name,
};
});
setAvailableIdentifiersOptions(aggregatedFunctions);
}
},
[
availableIdentifiersOptions.length,
cursorPosition,
isMounted,
queryString,
search,
variableType,
]
);
useEffect(() => {
const variableExists =
esqlVariables.some((variable) => variable.key === variableName.replace('?', '')) &&
!isControlInEditMode;
setFormIsInvalid(!selectedFields.length || !variableName || variableExists);
}, [esqlVariables, isControlInEditMode, selectedFields.length, variableName]);
setFormIsInvalid(!selectedIdentifiers.length || !variableName || variableExists);
}, [esqlVariables, isControlInEditMode, selectedIdentifiers.length, variableName]);
const onFieldsChange = useCallback((selectedOptions: EuiComboBoxOptionOption[]) => {
setSelectedFields(selectedOptions);
const onIdentifiersChange = useCallback((selectedOptions: EuiComboBoxOptionOption[]) => {
setSelectedIdentifiers(selectedOptions);
}, []);
const onVariableNameChange = useCallback(
@ -170,16 +204,16 @@ export function FieldControlForm({
(option) => option.label.trim().toLowerCase() === normalizedSearchValue
) === -1
) {
setAvailableFieldsOptions([...availableFieldsOptions, newOption]);
setAvailableIdentifiersOptions((prev) => [...prev, newOption]);
}
setSelectedFields((prevSelected) => [...prevSelected, newOption]);
setSelectedIdentifiers((prevSelected) => [...prevSelected, newOption]);
},
[availableFieldsOptions]
[]
);
const onCreateFieldControl = useCallback(async () => {
const availableOptions = selectedFields.map((field) => field.label);
const availableOptions = selectedIdentifiers.map((field) => field.label);
const state = {
availableOptions,
selectedOptions: [availableOptions[0]],
@ -201,7 +235,7 @@ export function FieldControlForm({
}
closeFlyout();
}, [
selectedFields,
selectedIdentifiers,
minimumWidth,
label,
variableName,
@ -246,11 +280,11 @@ export function FieldControlForm({
placeholder={i18n.translate('esql.flyout.fieldsOptions.placeholder', {
defaultMessage: 'Select or add values',
})}
options={availableFieldsOptions}
selectedOptions={selectedFields}
onChange={onFieldsChange}
options={availableIdentifiersOptions}
selectedOptions={selectedIdentifiers}
onChange={onIdentifiersChange}
onCreateOption={onCreateOption}
data-test-subj="esqlFieldsOptions"
data-test-subj="esqlIdentifiersOptions"
fullWidth
compressed
/>

View file

@ -13,7 +13,7 @@ import type { ISearchGeneric } from '@kbn/search-types';
import { monaco } from '@kbn/monaco';
import type { ESQLControlState } from '../types';
import { ValueControlForm } from './value_control_form';
import { FieldControlForm } from './field_control_form';
import { IdentifierControlForm } from './identifier_control_form';
import { updateQueryStringWithVariable } from './helpers';
interface ESQLControlsFlyoutProps {
@ -71,9 +71,12 @@ export function ESQLControlsFlyout({
search={search}
/>
);
} else if (variableType === ESQLVariableType.FIELDS) {
} else if (
variableType === ESQLVariableType.FIELDS ||
variableType === ESQLVariableType.FUNCTIONS
) {
return (
<FieldControlForm
<IdentifierControlForm
variableType={variableType}
esqlVariables={esqlVariables}
queryString={queryString}

View file

@ -8,6 +8,7 @@
*/
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import { i18n } from '@kbn/i18n';
import {
EuiComboBox,
@ -43,6 +44,7 @@ import {
getFlyoutStyling,
areValuesIntervalsValid,
validateVariableName,
getVariablePrefix,
} from './helpers';
import { EsqlControlType } from '../types';
import { ChooseColumnPopover } from './choose_column_popover';
@ -72,6 +74,7 @@ export function ValueControlForm({
onCreateControl,
onEditControl,
}: ValueControlFormProps) {
const isMounted = useMountedState();
const valuesField = useMemo(() => {
if (variableType === ESQLVariableType.VALUES) {
return getValuesFromQueryField(queryString);
@ -79,31 +82,25 @@ export function ValueControlForm({
return null;
}, [variableType, queryString]);
const suggestedVariableName = useMemo(() => {
const existingVariables = esqlVariables.filter((variable) => variable.type === variableType);
const existingVariables = new Set(
esqlVariables
.filter((variable) => variable.type === variableType)
.map((variable) => variable.key)
);
if (initialState) {
return initialState.variableName;
}
let variablePrefix = getVariablePrefix(variableType);
if (valuesField && variableType === ESQLVariableType.VALUES) {
// variables names can't have special characters, only underscore
const fieldVariableName = valuesField.replace(/[^a-zA-Z0-9]/g, '_');
return getRecurrentVariableName(
fieldVariableName,
existingVariables.map((variable) => variable.key)
);
variablePrefix = fieldVariableName;
}
if (variableType === ESQLVariableType.TIME_LITERAL) {
return getRecurrentVariableName(
'interval',
existingVariables.map((variable) => variable.key)
);
}
return getRecurrentVariableName(
'variable',
existingVariables.map((variable) => variable.key)
);
return getRecurrentVariableName(variablePrefix, existingVariables);
}, [esqlVariables, initialState, valuesField, variableType]);
const [controlFlyoutType, setControlFlyoutType] = useState<EsqlControlType>(
@ -243,6 +240,9 @@ export function ValueControlForm({
filter: undefined,
dropNullColumns: true,
}).then((results) => {
if (!isMounted()) {
return;
}
const columns = results.response.columns.map((col) => col.name);
setQueryColumns(columns);
@ -267,7 +267,7 @@ export function ValueControlForm({
setEsqlQueryErrors([e]);
}
},
[search]
[isMounted, search]
);
useEffect(() => {

View file

@ -69,8 +69,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
return await testSubjects.exists('create_esql_control_flyout');
});
await comboBox.set('esqlFieldsOptions', 'geo.dest');
await comboBox.set('esqlFieldsOptions', 'clientip');
await comboBox.set('esqlIdentifiersOptions', 'geo.dest');
await comboBox.set('esqlIdentifiersOptions', 'clientip');
// create the control
await testSubjects.click('saveEsqlControlsFlyoutButton');