diff --git a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx index 5fbc71d5b9375..79fc2a9b06681 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx @@ -336,56 +336,27 @@ const ESQLEditorInternal = function ESQLEditor({ openTimePickerPopover(); }); - monaco.editor.registerCommand('esql.control.time_literal.create', async (...args) => { - const position = editor1.current?.getPosition(); - await triggerControl( - fixedQuery, - ESQLVariableType.TIME_LITERAL, - position, - uiActions, - esqlVariables, - controlsContext?.onSaveControl, - controlsContext?.onCancelControl - ); - }); - - monaco.editor.registerCommand('esql.control.fields.create', async (...args) => { - const position = editor1.current?.getPosition(); - await triggerControl( - fixedQuery, - ESQLVariableType.FIELDS, - position, - uiActions, - esqlVariables, - controlsContext?.onSaveControl, - controlsContext?.onCancelControl - ); - }); - - monaco.editor.registerCommand('esql.control.values.create', async (...args) => { - const position = editor1.current?.getPosition(); - await triggerControl( - fixedQuery, - ESQLVariableType.VALUES, - position, - uiActions, - esqlVariables, - controlsContext?.onSaveControl, - controlsContext?.onCancelControl - ); - }); - - monaco.editor.registerCommand('esql.control.functions.create', async (...args) => { - const position = editor1.current?.getPosition(); - await triggerControl( - fixedQuery, - ESQLVariableType.FUNCTIONS, - position, - uiActions, - esqlVariables, - controlsContext?.onSaveControl, - controlsContext?.onCancelControl - ); + const controlCommands = [ + { command: 'esql.control.multi_values.create', variableType: ESQLVariableType.MULTI_VALUES }, + { command: 'esql.control.time_literal.create', variableType: ESQLVariableType.TIME_LITERAL }, + { command: 'esql.control.fields.create', variableType: ESQLVariableType.FIELDS }, + { command: 'esql.control.values.create', variableType: ESQLVariableType.VALUES }, + { command: 'esql.control.functions.create', variableType: ESQLVariableType.FUNCTIONS }, + ]; + + controlCommands.forEach(({ command, variableType }) => { + monaco.editor.registerCommand(command, async (...args) => { + const position = editor1.current?.getPosition(); + await triggerControl( + fixedQuery, + variableType, + position, + uiActions, + esqlVariables, + controlsContext?.onSaveControl, + controlsContext?.onCancelControl + ); + }); }); editor1.current?.addCommand( diff --git a/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts b/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts index d6144d8d39834..829b383410b81 100644 --- a/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts +++ b/src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts @@ -511,6 +511,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D queryDsl: `${ELASTIC_DOCS}explore-analyze/query-filter/languages/querydsl`, queryESQL: `${ELASTIC_DOCS}explore-analyze/query-filter/languages/esql`, queryESQLExamples: `${ELASTIC_DOCS}explore-analyze/query-filter/languages/esql`, + queryESQLMultiValueControls: `${ELASTIC_DOCS}explore-analyze/query-filter/languages/esql-kibana#esql-multi-values-controls`, }, search: { sessions: `${ELASTIC_DOCS}explore-analyze/discover/search-sessions`, diff --git a/src/platform/packages/shared/kbn-doc-links/src/types.ts b/src/platform/packages/shared/kbn-doc-links/src/types.ts index 844e95bf97b85..5df094f67d025 100644 --- a/src/platform/packages/shared/kbn-doc-links/src/types.ts +++ b/src/platform/packages/shared/kbn-doc-links/src/types.ts @@ -368,6 +368,7 @@ export interface DocLinks { readonly queryDsl: string; readonly queryESQL: string; readonly queryESQLExamples: string; + readonly queryESQLMultiValueControls: string; }; readonly date: { readonly dateMath: string; diff --git a/src/platform/packages/shared/kbn-es-types/src/search.ts b/src/platform/packages/shared/kbn-es-types/src/search.ts index e727191a84de8..5af00e13403b3 100644 --- a/src/platform/packages/shared/kbn-es-types/src/search.ts +++ b/src/platform/packages/shared/kbn-es-types/src/search.ts @@ -686,5 +686,10 @@ export interface ESQLSearchParams { dropNullColumns?: boolean; params?: | estypes.ScalarValue[] - | Array | undefined>>; + | Array< + Record< + string, + string | number | (string | number)[] | Record | undefined + > + >; } diff --git a/src/platform/packages/shared/kbn-esql-ast/scripts/functions.ts b/src/platform/packages/shared/kbn-esql-ast/scripts/functions.ts index c59f8a9d4b82c..3118490d6a1c8 100644 --- a/src/platform/packages/shared/kbn-esql-ast/scripts/functions.ts +++ b/src/platform/packages/shared/kbn-esql-ast/scripts/functions.ts @@ -167,6 +167,12 @@ export function enrichFunctionParameters(functionDefinition: FunctionDefinition) }); } + if (functionDefinition.name === 'mv_contains') { + return enrichFunctionSignatures(functionDefinition, 'superset', { + supportsMultiValues: true, + }); + } + if (functionDefinition.name === 'qstr') { return { ...functionDefinition, diff --git a/src/platform/packages/shared/kbn-esql-ast/src/definitions/generated/scalar_functions.ts b/src/platform/packages/shared/kbn-esql-ast/src/definitions/generated/scalar_functions.ts index c75ba089600ed..af77b17990df9 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/definitions/generated/scalar_functions.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/definitions/generated/scalar_functions.ts @@ -7582,6 +7582,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'boolean', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7597,6 +7598,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'cartesian_point', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7612,6 +7614,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'cartesian_shape', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7627,6 +7630,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'date', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7642,6 +7646,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'date_nanos', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7657,6 +7662,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'double', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7672,6 +7678,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geo_point', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7687,6 +7694,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geo_shape', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7702,6 +7710,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geohash', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7717,6 +7726,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geohex', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7732,6 +7742,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geotile', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7747,6 +7758,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'integer', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7762,6 +7774,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'ip', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7777,6 +7790,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'keyword', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7792,6 +7806,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'keyword', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7807,6 +7822,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'long', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7822,6 +7838,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'text', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7837,6 +7854,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'text', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7852,6 +7870,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'unsigned_long', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7867,6 +7886,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'version', optional: false, + supportsMultiValues: true, }, { name: 'subset', diff --git a/src/platform/packages/shared/kbn-esql-ast/src/definitions/types.ts b/src/platform/packages/shared/kbn-esql-ast/src/definitions/types.ts index 7cb2de5a4eb47..d9fe88275edf0 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/definitions/types.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/definitions/types.ts @@ -183,6 +183,11 @@ export interface FunctionParameter { suggestedValues?: string[]; mapParams?: string; + + /** If true, this parameter supports multiple values (arrays). Default is false. + * This indicates that the parameter can accept multiple values, which will be passed as an array. + */ + supportsMultiValues?: boolean; } export interface ElasticsearchCommandDefinition { diff --git a/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/expressions/positions/empty_expression.ts b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/expressions/positions/empty_expression.ts index f5a0639ff8513..f1a02b4bdbda4 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/expressions/positions/empty_expression.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/expressions/positions/empty_expression.ts @@ -206,11 +206,15 @@ async function buildFieldAndFunctionSuggestions( const hasNonConstantParam = paramDefinitions.some(({ constantOnly }) => !constantOnly); const isVariadicOrUnknownPosition = paramDefinitions.length === 0; if (hasNonConstantParam || isVariadicOrUnknownPosition) { + const canBeMultiValue = paramDefinitions.some( + (t) => t && (t.supportsMultiValues === true || t.name === 'values') + ); await builder.addFields({ types: config.acceptedTypes, ignoredColumns, addComma: config.shouldAddComma, promoteToTop: true, + canBeMultiValue, }); } diff --git a/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/expressions/suggestion_builder.ts b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/expressions/suggestion_builder.ts index 3a17a861091fa..7fc054fa12ffc 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/expressions/suggestion_builder.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/expressions/suggestion_builder.ts @@ -32,6 +32,7 @@ export class SuggestionBuilder { promoteToTop?: boolean; openSuggestions?: boolean; values?: boolean; + canBeMultiValue?: boolean; }): Promise { const types = options?.types ?? ['any']; const addComma = options?.addComma ?? false; @@ -40,6 +41,7 @@ export class SuggestionBuilder { const ignoredColumns = options?.ignoredColumns ?? []; const openSuggestions = options?.openSuggestions ?? (addSpaceAfterField || addComma); const values = options?.values; + const canBeMultiValue = options?.canBeMultiValue ?? false; const getByType = this.context.callbacks?.getByType ?? (() => Promise.resolve([])); @@ -50,6 +52,7 @@ export class SuggestionBuilder { addComma, promoteToTop, values, + canBeMultiValue, }); this.suggestions.push(...fieldSuggestions); diff --git a/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/helpers.ts b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/helpers.ts index 4350ad821c6b9..2cee9580f3423 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/helpers.ts @@ -142,6 +142,7 @@ interface FieldSuggestionsOptions { openSuggestions?: boolean; addComma?: boolean; promoteToTop?: boolean; + canBeMultiValue?: boolean; } export async function getFieldsSuggestions( @@ -156,13 +157,20 @@ export async function getFieldsSuggestions( openSuggestions = false, addComma = false, promoteToTop = true, + canBeMultiValue = false, } = options; + const variableType = (() => { + if (canBeMultiValue) return ESQLVariableType.MULTI_VALUES; + if (values) return ESQLVariableType.VALUES; + return ESQLVariableType.FIELDS; + })(); + const suggestions = await getFieldsByType(types, ignoreColumns, { advanceCursor: addSpaceAfterField, openSuggestions, addComma, - variableType: values ? ESQLVariableType.VALUES : ESQLVariableType.FIELDS, + variableType, }); return pushItUpInTheList(suggestions as ISuggestionItem[], promoteToTop); diff --git a/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/functions.ts b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/functions.ts index 597368aff0a6b..5afc09a139829 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/functions.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/functions.ts @@ -386,6 +386,7 @@ export const buildColumnSuggestions = ( addComma?: boolean; variableType?: ESQLVariableType; supportsControls?: boolean; + supportsMultiValue?: boolean; }, variables?: ESQLControlVariable[] ): ISuggestionItem[] => { diff --git a/src/platform/packages/shared/kbn-esql-types/src/variables_types.ts b/src/platform/packages/shared/kbn-esql-types/src/variables_types.ts index c9bbb6e0b1bc8..b8cc3b9181e4b 100644 --- a/src/platform/packages/shared/kbn-esql-types/src/variables_types.ts +++ b/src/platform/packages/shared/kbn-esql-types/src/variables_types.ts @@ -20,6 +20,7 @@ export enum ESQLVariableType { TIME_LITERAL = 'time_literal', FIELDS = 'fields', VALUES = 'values', + MULTI_VALUES = 'multi_values', FUNCTIONS = 'functions', } @@ -35,7 +36,7 @@ export enum EsqlControlType { export interface ESQLControlVariable { key: string; - value: string | number; + value: string | number | (string | number)[]; type: ESQLVariableType; } @@ -48,6 +49,7 @@ export type ControlWidthOptions = 'small' | 'medium' | 'large'; export interface ESQLControlState { grow?: boolean; width?: ControlWidthOptions; + singleSelect?: boolean; title: string; selectedOptions: string[]; variableName: string; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.variables.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.variables.test.ts index dc510dc939336..6d43c327d7a64 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.variables.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.variables.test.ts @@ -176,6 +176,39 @@ describe('autocomplete.suggest', () => { }); }); + test('suggests `?multiValue` option', async () => { + const { suggest } = await setup(); + + const suggestions = await suggest('FROM index_a | WHERE MV_CONTAINS( /', { + callbacks: { + canSuggestVariables: () => true, + getVariables: () => [ + { + key: 'interval', + value: '1 hour', + type: ESQLVariableType.TIME_LITERAL, + }, + { + key: 'multiValue', + value: ['value1', 'value2'], + type: ESQLVariableType.MULTI_VALUES, + }, + ], + getColumnsFor: () => + Promise.resolve([{ name: '@timestamp', type: 'date', userDefined: false }]), + }, + }); + + expect(suggestions).toContainEqual({ + label: '?multiValue', + text: '?multiValue', + kind: 'Constant', + detail: 'Named parameter', + command: undefined, + sortText: '11A', + }); + }); + test('suggests `Create control` option when ? is being typed', async () => { const { suggest } = await setup(); diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.test.tsx b/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.test.tsx index 5e7af0a519e57..0000100859edc 100644 --- a/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.test.tsx @@ -67,6 +67,7 @@ describe('initializeESQLControlSelections', () => { "selectedOptions": Array [ "option1", ], + "singleSelect": true, "title": "", "variableName": "variable1", "variableType": "values", @@ -105,6 +106,7 @@ describe('initializeESQLControlSelections', () => { "selectedOptions": Array [ "option1", ], + "singleSelect": true, "title": "", "variableName": "variable1", "variableType": "values", @@ -112,4 +114,54 @@ describe('initializeESQLControlSelections', () => { `); }); }); + + describe('esqlVariable$', () => { + test('should emit single value for single-select mode', async () => { + const initialState = { + selectedOptions: ['option1'], + availableOptions: ['option1', 'option2'], + variableName: 'myVariable', + variableType: 'values', + controlType: EsqlControlType.STATIC_VALUES, + singleSelect: true, + title: 'Test Control', + esqlQuery: '', + } as ESQLControlState; + + const selections = initializeESQLControlSelections(initialState, controlFetch$, jest.fn()); + + await waitFor(() => { + const variable = selections.api.esqlVariable$.getValue(); + expect(variable).toEqual({ + key: 'myVariable', + value: 'option1', + type: 'values', + }); + }); + }); + + test('should emit array for multi-select mode', async () => { + const initialState = { + selectedOptions: ['option1', 'option2'], + availableOptions: ['option1', 'option2', 'option3'], + variableName: 'myVariable', + variableType: 'values', + controlType: EsqlControlType.STATIC_VALUES, + singleSelect: false, + title: 'Test Control', + esqlQuery: '', + } as ESQLControlState; + + const selections = initializeESQLControlSelections(initialState, controlFetch$, jest.fn()); + + await waitFor(() => { + const variable = selections.api.esqlVariable$.getValue(); + expect(variable).toEqual({ + key: 'myVariable', + value: ['option1', 'option2'], + type: 'values', + }); + }); + }); + }); }); diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts b/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts index 1716cf06ccc8b..f4fb170267263 100644 --- a/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts @@ -32,6 +32,7 @@ export const selectionComparators: StateComparators< | 'selectedOptions' | 'availableOptions' | 'variableName' + | 'singleSelect' | 'variableType' | 'controlType' | 'esqlQuery' @@ -54,6 +55,7 @@ export const selectionComparators: StateComparators< controlType: 'referenceEquality', esqlQuery: 'referenceEquality', title: 'referenceEquality', + singleSelect: 'referenceEquality', }; export function initializeESQLControlSelections( @@ -64,6 +66,7 @@ export function initializeESQLControlSelections( const availableOptions$ = new BehaviorSubject(initialState.availableOptions ?? []); const selectedOptions$ = new BehaviorSubject(initialState.selectedOptions ?? []); const hasSelections$ = new BehaviorSubject(false); // hardcoded to false to prevent clear action from appearing. + const singleSelect$ = new BehaviorSubject(initialState.singleSelect ?? true); const variableName$ = new BehaviorSubject(initialState.variableName ?? ''); const variableType$ = new BehaviorSubject( initialState.variableType ?? ESQLVariableType.VALUES @@ -123,19 +126,39 @@ export function initializeESQLControlSelections( }); // derive ESQL control variable from state. - const getEsqlVariable = () => ({ - key: variableName$.value, - value: isNaN(Number(selectedOptions$.value[0])) - ? selectedOptions$.value[0] - : Number(selectedOptions$.value[0]), - type: variableType$.value, - }); + const getEsqlVariable = () => { + const isSingleSelect = singleSelect$.value; + const selectedValues = selectedOptions$.value; + + // For single select, return the first value; for multi-select, return the array + let value: ESQLControlVariable['value']; + + if (isSingleSelect) { + // Single select: return the first value or empty string if none selected + const firstValue = selectedValues[0]; + if (firstValue !== undefined) { + value = isNaN(Number(firstValue)) ? firstValue : Number(firstValue); + } else { + value = ''; + } + } else { + // Multi-select: return array of all selected values + value = selectedValues.map((val) => (isNaN(Number(val)) ? val : Number(val))); + } + + return { + key: variableName$.value, + value, + type: variableType$.value, + }; + }; const esqlVariable$ = new BehaviorSubject(getEsqlVariable()); const variableSubscriptions = combineLatest([ variableName$, variableType$, selectedOptions$, availableOptions$, + singleSelect$, ]).subscribe(() => esqlVariable$.next(getEsqlVariable())); return { @@ -147,11 +170,13 @@ export function initializeESQLControlSelections( api: { hasSelections$: hasSelections$ as PublishingSubject, esqlVariable$: esqlVariable$ as PublishingSubject, + singleSelect$: singleSelect$ as PublishingSubject, }, anyStateChange$: merge( selectedOptions$, availableOptions$, variableName$, + singleSelect$, variableType$, controlType$, esqlQuery$, @@ -161,6 +186,7 @@ export function initializeESQLControlSelections( setSelectedOptions(lastSaved?.selectedOptions ?? []); availableOptions$.next(lastSaved?.availableOptions ?? []); variableName$.next(lastSaved?.variableName ?? ''); + singleSelect$.next(lastSaved?.singleSelect ?? true); variableType$.next(lastSaved?.variableType ?? ESQLVariableType.VALUES); if (lastSaved?.controlType) controlType$.next(lastSaved?.controlType); esqlQuery$.next(lastSaved?.esqlQuery ?? ''); @@ -173,6 +199,7 @@ export function initializeESQLControlSelections( ? { availableOptions: availableOptions$.getValue() ?? [] } : {}), variableName: variableName$.getValue() ?? '', + singleSelect: singleSelect$.getValue() ?? true, variableType: variableType$.getValue() ?? ESQLVariableType.VALUES, controlType: controlType$.getValue(), esqlQuery: esqlQuery$.getValue() ?? '', diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx index d1cd3769b6bc1..8e3782ae36a3b 100644 --- a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx @@ -85,6 +85,7 @@ describe('ESQLControlApi', () => { title: '', variableName: 'variable1', variableType: 'values', + singleSelect: true, width: 'medium', }, references: [], diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx index 8064a083dc72c..09228bf33eee7 100644 --- a/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { BehaviorSubject, merge } from 'rxjs'; import type { ESQLControlState } from '@kbn/esql-types'; import { apiPublishesESQLVariables } from '@kbn/esql-types'; -import { initializeStateManager } from '@kbn/presentation-publishing'; +import { initializeStateManager, type PublishingSubject } from '@kbn/presentation-publishing'; import { initializeUnsavedChanges } from '@kbn/presentation-containers'; import { ESQL_CONTROL } from '@kbn/controls-constants'; import type { OptionsListSelection } from '../../../common/options_list'; @@ -115,7 +115,7 @@ export const getESQLControlFactory = (): ControlFactory k !== key) : [...current, key]; + selections.internalApi.setSelectedOptions(newSelection); + } }, // Pass no-ops and default values for all of the features of OptionsList that ES|QL controls don't currently use ...componentStaticStateManager.api, + singleSelect$: selections.api.singleSelect$ as PublishingSubject, deselectOption: () => {}, - selectAll: () => {}, - deselectAll: () => {}, + selectAll: (keys: string[]) => { + selections.internalApi.setSelectedOptions(keys); + }, + deselectAll: () => { + selections.internalApi.setSelectedOptions([]); + }, loadMoreSubject: new BehaviorSubject(undefined), fieldFormatter: new BehaviorSubject((v: string) => v), }; diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/types.ts b/src/platform/plugins/shared/controls/public/controls/esql_control/types.ts index 81b79163da758..af4d8f9991272 100644 --- a/src/platform/plugins/shared/controls/public/controls/esql_control/types.ts +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/types.ts @@ -28,13 +28,11 @@ type DisableLoadSuggestionsUnusedState = Pick< OptionsListComponentState, 'dataLoading' | 'requestSize' | 'runPastTimeout' >; -type DisableMultiSelectUnusedState = Pick; type DisableInvalidSelectionsUnusedState = Pick; export type OptionsListESQLUnusedState = HideExcludeUnusedState & HideExistsUnusedState & HideSortUnusedState & DisableLoadSuggestionsUnusedState & - DisableMultiSelectUnusedState & DisableInvalidSelectionsUnusedState & Pick; diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.test.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.test.ts index b83259da48ae3..19014b3de80ee 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.test.ts @@ -7,9 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { createTabItem } from './utils'; +import { createTabItem, extractEsqlVariables } from './utils'; import { type TabState } from './types'; import { getTabStateMock } from './__mocks__/internal_state.mocks'; +import type { ControlPanelsState, ControlPanelState } from '@kbn/controls-plugin/public'; +import type { ESQLControlState } from '@kbn/esql-types'; +import { ESQLVariableType, EsqlControlType } from '@kbn/esql-types'; +import { ESQL_CONTROL } from '@kbn/controls-constants'; const createMockTabState = (id: string, label: string): TabState => getTabStateMock({ id, label }); @@ -58,3 +62,101 @@ describe('createTabItem', () => { expect(result.label).toBe('Untitled 2'); }); }); + +describe('extractEsqlVariables', () => { + const createMockESQLControlPanel = ( + variableName: string, + variableType: ESQLVariableType, + selectedOptions: string[], + singleSelect: boolean = true, + order: number = 0 + ): ControlPanelState => ({ + type: ESQL_CONTROL, + order, + variableName, + variableType, + selectedOptions, + singleSelect, + availableOptions: selectedOptions, + title: `Control for ${variableName}`, + width: 'medium', + grow: false, + controlType: EsqlControlType.STATIC_VALUES, + esqlQuery: '', + }); + + it('should extract single-select string variable', () => { + const panels: ControlPanelsState = { + panel1: createMockESQLControlPanel( + 'myVar', + ESQLVariableType.VALUES, + ['option1', 'option2'], + true + ), + }; + + const result = extractEsqlVariables(panels); + expect(result).toEqual([ + { + key: 'myVar', + type: ESQLVariableType.VALUES, + value: 'option1', // First selected value as string + }, + ]); + }); + + it('should extract single-select numeric variable', () => { + const panels: ControlPanelsState = { + panel1: createMockESQLControlPanel('numVar', ESQLVariableType.VALUES, ['123', '456'], true), + }; + + const result = extractEsqlVariables(panels); + expect(result).toEqual([ + { + key: 'numVar', + type: ESQLVariableType.VALUES, + value: 123, // First selected value converted to number + }, + ]); + }); + + it('should extract multi-select string variables', () => { + const panels: ControlPanelsState = { + panel1: createMockESQLControlPanel( + 'multiVar', + ESQLVariableType.MULTI_VALUES, + ['apple', 'banana', 'cherry'], + false + ), + }; + + const result = extractEsqlVariables(panels); + expect(result).toEqual([ + { + key: 'multiVar', + type: ESQLVariableType.MULTI_VALUES, + value: ['apple', 'banana', 'cherry'], + }, + ]); + }); + + it('should extract multi-select numeric variables', () => { + const panels: ControlPanelsState = { + panel1: createMockESQLControlPanel( + 'multiVar', + ESQLVariableType.MULTI_VALUES, + ['1', '2', '3'], + false + ), + }; + + const result = extractEsqlVariables(panels); + expect(result).toEqual([ + { + key: 'multiVar', + type: ESQLVariableType.MULTI_VALUES, + value: [1, 2, 3], + }, + ]); + }); +}); diff --git a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.ts b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.ts index 862250ba2ba66..4a123b68ec2e1 100644 --- a/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.ts +++ b/src/platform/plugins/shared/discover/public/application/main/state_management/redux/utils.ts @@ -105,13 +105,24 @@ export const extractEsqlVariables = ( } const variables = Object.values(panels).reduce((acc: ESQLControlVariable[], panel) => { if (panel.type === ESQL_CONTROL) { + const isSingleSelect = panel.singleSelect ?? true; + const selectedValues = panel.selectedOptions || []; + + let value: string | number | (string | number)[]; + + if (isSingleSelect) { + // Single select: return the first selected value, converting to number if possible + const singleValue = selectedValues[0]; + value = isNaN(Number(singleValue)) ? singleValue : Number(singleValue); + } else { + // Multi select: return array with numbers converted from strings when possible + value = selectedValues.map((val) => (isNaN(Number(val)) ? val : Number(val))); + } + acc.push({ key: panel.variableName, type: panel.variableType, - // If the selected option is not a number, keep it as a string - value: isNaN(Number(panel.selectedOptions?.[0])) - ? panel.selectedOptions?.[0] - : Number(panel.selectedOptions?.[0]), + value, }); } return acc; diff --git a/src/platform/plugins/shared/esql/public/kibana_services.ts b/src/platform/plugins/shared/esql/public/kibana_services.ts index d919eac07dee8..579afa231b8ef 100644 --- a/src/platform/plugins/shared/esql/public/kibana_services.ts +++ b/src/platform/plugins/shared/esql/public/kibana_services.ts @@ -8,7 +8,7 @@ */ import { BehaviorSubject } from 'rxjs'; -import type { CoreStart } from '@kbn/core/public'; +import type { CoreStart, DocLinksStart } from '@kbn/core/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; @@ -19,7 +19,7 @@ import type { EsqlPluginStart } from './plugin'; export let core: CoreStart; -interface ServiceDeps { +export interface ServiceDeps { core: CoreStart; dataViews: DataViewsPublicPluginStart; data: DataPublicPluginStart; @@ -28,6 +28,7 @@ interface ServiceDeps { fieldsMetadata?: FieldsMetadataPublicStart; usageCollection?: UsageCollectionStart; esql: EsqlPluginStart; + docLinks: DocLinksStart; } const servicesReady$ = new BehaviorSubject(undefined); @@ -62,6 +63,7 @@ export const setKibanaServices = ( uiActions, fieldsMetadata, usageCollection, + docLinks: core.docLinks, esql, }); }; diff --git a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/helpers.ts b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/helpers.ts index 9679662016704..e9b20403991f4 100644 --- a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/helpers.ts +++ b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/helpers.ts @@ -94,6 +94,8 @@ export const getVariableSuggestion = (variableType: ESQLVariableType) => { return 'function'; case ESQLVariableType.TIME_LITERAL: return 'interval'; + case ESQLVariableType.MULTI_VALUES: + return 'values'; default: return 'variable'; } @@ -159,7 +161,8 @@ export const getVariableTypeFromQuery = (str: string, variableType: ESQLVariable if ( leadingQuestionMarksCount === 1 && variableType !== ESQLVariableType.TIME_LITERAL && - variableType !== ESQLVariableType.VALUES + variableType !== ESQLVariableType.VALUES && + variableType !== ESQLVariableType.MULTI_VALUES ) { return ESQLVariableType.VALUES; } @@ -174,6 +177,7 @@ export const getVariableNamePrefix = (type: ESQLVariableType) => { return VariableNamePrefix.IDENTIFIER; case ESQLVariableType.VALUES: case ESQLVariableType.TIME_LITERAL: + case ESQLVariableType.MULTI_VALUES: default: return VariableNamePrefix.VALUE; } diff --git a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/shared_form_components.tsx b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/shared_form_components.tsx index 290bacb97e665..aab6115aa37f5 100644 --- a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/shared_form_components.tsx +++ b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/shared_form_components.tsx @@ -18,6 +18,7 @@ import { EuiFieldText, EuiFormRow, EuiComboBox, + EuiRadioGroup, type EuiComboBoxOptionOption, EuiButtonGroup, EuiSpacer, @@ -28,6 +29,7 @@ import { EuiFlexItem, EuiButtonEmpty, EuiButton, + EuiLink, EuiFlyoutHeader, EuiTitle, EuiBetaBadge, @@ -35,7 +37,11 @@ import { EuiText, EuiTextColor, EuiCode, + EuiCallOut, + useEuiTheme, } from '@elastic/eui'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { ServiceDeps } from '../../../kibana_services'; import { checkVariableExistence } from './helpers'; const controlTypeOptions = [ @@ -76,6 +82,21 @@ const minimumWidthButtonGroup = [ }, ]; +const selectionTypeOptions = [ + { + id: 'single', + label: i18n.translate('esql.flyout.selectionType.single', { + defaultMessage: 'Only allow a single selection', + }), + }, + { + id: 'multi', + label: i18n.translate('esql.flyout.selectionType.multi', { + defaultMessage: 'Allow multiple selections', + }), + }, +]; + export function ControlType({ isDisabled, initialControlFlyoutType, @@ -318,6 +339,73 @@ export function ControlWidth({ ); } +export function ControlSelectionType({ + singleSelect, + onSelectionTypeChange, +}: { + singleSelect: boolean; + onSelectionTypeChange: (isSingleSelect: boolean) => void; +}) { + const theme = useEuiTheme(); + const { + services: { docLinks }, + } = useKibana(); + const multiValuesGuideLink = docLinks?.links.query.queryESQLMultiValueControls ?? ''; + return ( + <> + + + { + const newSingleSelect = id === 'single'; + onSelectionTypeChange(newSingleSelect); + }} + name="selectionType" + data-test-subj="esqlControlSelectionType" + /> + + {!singleSelect ? ( + <> + + + + + MV_CONTAINS + + ), + }} + /> + + + + ) : null} + + ); +} + export function Header({ isInEditMode, ariaLabelledBy, diff --git a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.test.tsx b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.test.tsx index 80235ba6dbf28..4ea606cf889a4 100644 --- a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.test.tsx +++ b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.test.tsx @@ -107,6 +107,14 @@ describe('ValueControlForm', () => { ); expect(pressedWidth).toHaveAttribute('aria-pressed', 'true'); + // control type radio should be rendered and default to 'single' + const selectionTypeContainer = await findByTestId('esqlControlSelectionType'); + expect(selectionTypeContainer).toBeInTheDocument(); + const singleRadioButton = within(selectionTypeContainer).getByLabelText( + 'Only allow a single selection' + ); + expect(singleRadioButton).toBeChecked(); + // control grow switch should be rendered and default to 'false' expect(await findByTestId('esqlControlGrow')).toBeInTheDocument(); const growSwitch = await findByTestId('esqlControlGrow'); diff --git a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.tsx b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.tsx index 39ff6de2b71e4..1a85e19f301f7 100644 --- a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.tsx +++ b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.tsx @@ -29,7 +29,7 @@ import { appendStatsByToQuery, } from '@kbn/esql-utils'; import { ESQLLangEditor } from '../../../create_editor'; -import { ControlWidth, ControlLabel } from './shared_form_components'; +import { ControlWidth, ControlLabel, ControlSelectionType } from './shared_form_components'; import { ChooseColumnPopover } from './choose_column_popover'; interface ValueControlFormProps { @@ -102,6 +102,11 @@ export function ValueControlForm({ ); const [label, setLabel] = useState(initialState?.title ?? ''); const [minimumWidth, setMinimumWidth] = useState(initialState?.width ?? 'medium'); + const shouldDefaultToMultiSelect = variableType === ESQLVariableType.MULTI_VALUES; + const [singleSelect, setSingleSelect] = useState( + initialState?.singleSelect ?? !shouldDefaultToMultiSelect + ); + const [grow, setGrow] = useState(initialState?.grow ?? false); const onValuesChange = useCallback((selectedOptions: EuiComboBoxOptionOption[]) => { @@ -145,6 +150,10 @@ export function ValueControlForm({ } }, []); + const onSelectionTypeChange = useCallback((isSingleSelect: boolean) => { + setSingleSelect(isSingleSelect); + }, []); + const onGrowChange = useCallback((e: EuiSwitchEvent) => { setGrow(e.target.checked); }, []); @@ -219,9 +228,10 @@ export function ValueControlForm({ availableOptions, selectedOptions: [availableOptions[0]], width: minimumWidth, + singleSelect, title: label || variableNameWithoutQuestionmark, variableName: variableNameWithoutQuestionmark, - variableType, + variableType: singleSelect ? variableType : ESQLVariableType.MULTI_VALUES, esqlQuery: valuesQuery || queryString, controlType: controlFlyoutType, grow, @@ -230,6 +240,7 @@ export function ValueControlForm({ setControlState(state); } }, [ + singleSelect, controlFlyoutType, grow, initialState, @@ -371,6 +382,11 @@ export function ValueControlForm({ // we will hide this possibility for now hideFitToSpace={currentApp === 'discover'} /> + + ); } diff --git a/src/platform/test/functional/apps/discover/tabs/_controls.ts b/src/platform/test/functional/apps/discover/tabs/_controls.ts index 82660c273711d..c9ffb7b08183b 100644 --- a/src/platform/test/functional/apps/discover/tabs/_controls.ts +++ b/src/platform/test/functional/apps/discover/tabs/_controls.ts @@ -24,6 +24,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); + const comboBox = getService('comboBox'); + const dataGrid = getService('dataGrid'); describe('discover - ES|QL controls', function () { it('should add an ES|QL value control', async () => { @@ -45,6 +47,60 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await discover.waitUntilTabIsLoaded(); }); + it('should add an ES|QL multi - value control', async () => { + await discover.selectTextBaseLang(); + await discover.waitUntilTabIsLoaded(); + + await esql.openEsqlControlFlyout('FROM logstash-* | WHERE MV_CONTAINS( '); + + await comboBox.setCustom('esqlValuesOptions', 'IN'); + await comboBox.setCustom('esqlValuesOptions', 'US'); + + // create the control + await testSubjects.click('saveEsqlControlsFlyoutButton'); + + await discover.waitUntilTabIsLoaded(); + + await retry.try(async () => { + const controlGroupVisible = await testSubjects.exists('controls-group-wrapper'); + expect(controlGroupVisible).to.be(true); + }); + + // Check Discover editor has been updated accordingly + const editorValue = await esql.getEsqlEditorQuery(); + expect(editorValue).to.contain('FROM logstash-* | WHERE MV_CONTAINS( ?values'); + + await discover.waitUntilTabIsLoaded(); + + // Update the query + await esql.typeEsqlEditorQuery( + 'FROM logstash-* | WHERE MV_CONTAINS( ?values, geo.dest ) | KEEP geo.dest' + ); + await discover.waitUntilTabIsLoaded(); + + // run the query to make sure the table is updated + await testSubjects.click('querySubmitButton'); + await discover.waitUntilTabIsLoaded(); + + const dataGridExists = await testSubjects.exists('docTable'); + expect(dataGridExists).to.be(true); + + // Check that the table has data + const rowCount = await dataGrid.getDocCount(); + expect(rowCount).to.be.greaterThan(0); + + // Select all options in the control + const controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.optionsListOpenPopover(controlId, true); + await dashboardControls.optionsListPopoverSelectOption('US'); + + await discover.waitUntilTabIsLoaded(); + + const actualRowsText = await dataGrid.getRowsText(); + // Both 'US' and 'IN' should be present in the results + expect(actualRowsText.every((row) => row.includes('US') || row.includes('IN'))).to.be(true); + }); + it('should keep the ES|QL control after a browser refresh', async () => { await discover.selectTextBaseLang(); await discover.waitUntilTabIsLoaded(); diff --git a/src/platform/test/functional/services/esql.ts b/src/platform/test/functional/services/esql.ts index cda8a1242a8c3..b9bea74e22906 100644 --- a/src/platform/test/functional/services/esql.ts +++ b/src/platform/test/functional/services/esql.ts @@ -163,8 +163,7 @@ export class ESQLService extends FtrService { await this.monacoEditor.typeCodeEditorValue(query, editorSubjId); } - public async createEsqlControl(query: string) { - await this.waitESQLEditorLoaded(); + public async openEsqlControlFlyout(query: string) { await this.retry.waitFor('control flyout to open', async () => { await this.typeEsqlEditorQuery(query); // Wait until suggestions are loaded @@ -174,6 +173,11 @@ export class ESQLService extends FtrService { return await this.testSubjects.exists('create_esql_control_flyout'); }); + } + + public async createEsqlControl(query: string) { + await this.waitESQLEditorLoaded(); + await this.openEsqlControlFlyout(query); // create the control await this.testSubjects.waitForEnabled('saveEsqlControlsFlyoutButton');