From c229a3188134fbee43dba0a4b5829ec8703467b0 Mon Sep 17 00:00:00 2001 From: Stratoula Date: Wed, 15 Oct 2025 14:07:07 +0200 Subject: [PATCH 01/16] [ES|QL] Supports multiple select variables --- .../kbn-esql-types/src/variables_types.ts | 1 + .../control_flyout/shared_form_components.tsx | 64 +++++++++++++++++++ .../value_control_form.test.tsx | 8 +++ .../control_flyout/value_control_form.tsx | 18 +++++- 4 files changed, 90 insertions(+), 1 deletion(-) 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..3d25f1c66cc1f 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 @@ -48,6 +48,7 @@ export type ControlWidthOptions = 'small' | 'medium' | 'large'; export interface ESQLControlState { grow?: boolean; width?: ControlWidthOptions; + selectionType: 'single' | 'multi'; title: string; selectedOptions: string[]; variableName: string; 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..cbb484706783e 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, @@ -35,6 +36,7 @@ import { EuiText, EuiTextColor, EuiCode, + EuiCallOut, } from '@elastic/eui'; import { checkVariableExistence } from './helpers'; @@ -76,6 +78,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 +335,53 @@ export function ControlWidth({ ); } +export function ControlSelectionType({ + selectionType, + onSelectionTypeChange, +}: { + selectionType: 'single' | 'multi'; + onSelectionTypeChange: (type: 'single' | 'multi') => void; +}) { + return ( + <> + + + { + const newSelectionType = id === 'single' ? 'single' : 'multi'; + onSelectionTypeChange(newSelectionType); + }} + name="selectionType" + data-test-subj="esqlControlSelectionType" + /> + + {selectionType === 'multi' ? ( + <> + + + + ) : 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 2b38f0a106823..3bbffa92d7082 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 043dda8a61a0b..c6323d337ac42 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,9 @@ export function ValueControlForm({ ); const [label, setLabel] = useState(initialState?.title ?? ''); const [minimumWidth, setMinimumWidth] = useState(initialState?.width ?? 'medium'); + const [selectionType, setSelectionType] = useState<'single' | 'multi'>( + initialState?.selectionType ?? 'single' + ); const [grow, setGrow] = useState(initialState?.grow ?? false); const onValuesChange = useCallback((selectedOptions: EuiComboBoxOptionOption[]) => { @@ -145,6 +148,12 @@ export function ValueControlForm({ } }, []); + const onSelectionTypeChange = useCallback((type: 'single' | 'multi') => { + if (type) { + setSelectionType(type); + } + }, []); + const onGrowChange = useCallback((e: EuiSwitchEvent) => { setGrow(e.target.checked); }, []); @@ -219,6 +228,7 @@ export function ValueControlForm({ availableOptions, selectedOptions: [availableOptions[0]], width: minimumWidth, + selectionType, title: label || variableNameWithoutQuestionmark, variableName: variableNameWithoutQuestionmark, variableType, @@ -230,6 +240,7 @@ export function ValueControlForm({ setControlState(state); } }, [ + selectionType, controlFlyoutType, grow, initialState, @@ -371,6 +382,11 @@ export function ValueControlForm({ // we will hide this possibility for now hideFitToSpace={currentApp === 'discover'} /> + + ); } From a10a299faa141e12bcd8a989e292643ea859861c Mon Sep 17 00:00:00 2001 From: Stratoula Date: Wed, 15 Oct 2025 14:08:58 +0200 Subject: [PATCH 02/16] Make the type optional --- .../packages/shared/kbn-esql-types/src/variables_types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3d25f1c66cc1f..0ee12510f8fc6 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 @@ -48,7 +48,7 @@ export type ControlWidthOptions = 'small' | 'medium' | 'large'; export interface ESQLControlState { grow?: boolean; width?: ControlWidthOptions; - selectionType: 'single' | 'multi'; + selectionType?: 'single' | 'multi'; title: string; selectedOptions: string[]; variableName: string; From beff3b46f628b2258fcbfe64089e3ba4d9eb2ce1 Mon Sep 17 00:00:00 2001 From: Stratoula Date: Thu, 16 Oct 2025 10:27:08 +0200 Subject: [PATCH 03/16] WIP, add logic to the controls plugin --- .../kbn-esql-types/src/variables_types.ts | 2 +- .../esql_control/esql_control_selections.ts | 7 +++++ .../esql_control/get_esql_control_factory.tsx | 5 ++-- .../public/controls/esql_control/types.ts | 2 -- .../control_flyout/shared_form_components.tsx | 14 ++++----- .../control_flyout/value_control_form.tsx | 30 ++++++++----------- 6 files changed, 31 insertions(+), 29 deletions(-) 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 0ee12510f8fc6..3656bb2552883 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 @@ -48,7 +48,7 @@ export type ControlWidthOptions = 'small' | 'medium' | 'large'; export interface ESQLControlState { grow?: boolean; width?: ControlWidthOptions; - selectionType?: 'single' | 'multi'; + singleSelect?: boolean; title: string; selectedOptions: string[]; variableName: string; 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..62db4e17506e7 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 @@ -147,11 +150,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 +166,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 +179,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.tsx b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx index 8064a083dc72c..6691c8858e021 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, deselectOption: () => {}, selectAll: () => {}, deselectAll: () => {}, 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/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 cbb484706783e..35007712f02f3 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 @@ -336,11 +336,11 @@ export function ControlWidth({ } export function ControlSelectionType({ - selectionType, + singleSelect, onSelectionTypeChange, }: { - selectionType: 'single' | 'multi'; - onSelectionTypeChange: (type: 'single' | 'multi') => void; + singleSelect: boolean; + onSelectionTypeChange: (isSingleSelect: boolean) => void; }) { return ( <> @@ -354,16 +354,16 @@ export function ControlSelectionType({ { - const newSelectionType = id === 'single' ? 'single' : 'multi'; - onSelectionTypeChange(newSelectionType); + const newSingleSelect = id === 'single'; + onSelectionTypeChange(newSingleSelect); }} name="selectionType" data-test-subj="esqlControlSelectionType" /> - {selectionType === 'multi' ? ( + {!singleSelect ? ( <> ( - initialState?.selectionType ?? 'single' - ); + const [singleSelect, setSingleSelect] = useState(initialState?.singleSelect ?? true); + const [grow, setGrow] = useState(initialState?.grow ?? false); const onValuesChange = useCallback((selectedOptions: EuiComboBoxOptionOption[]) => { @@ -148,10 +147,8 @@ export function ValueControlForm({ } }, []); - const onSelectionTypeChange = useCallback((type: 'single' | 'multi') => { - if (type) { - setSelectionType(type); - } + const onSelectionTypeChange = useCallback((isSingleSelect: boolean) => { + setSingleSelect(isSingleSelect); }, []); const onGrowChange = useCallback((e: EuiSwitchEvent) => { @@ -200,14 +197,12 @@ export function ValueControlForm({ ); useEffect(() => { - if ( - !selectedValues?.length && - controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY && - valuesRetrieval - ) { + if (!selectedValues?.length && controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY) { const queryForValues = variableName !== '' - ? `FROM ${getIndexPatternFromESQLQuery(queryString)} | STATS BY ${valuesRetrieval}` + ? valuesRetrieval + ? `FROM ${getIndexPatternFromESQLQuery(queryString)} | STATS BY ${valuesRetrieval}` + : valuesQuery : ''; onValuesQuerySubmit(queryForValues); } @@ -215,9 +210,10 @@ export function ValueControlForm({ controlFlyoutType, onValuesQuerySubmit, queryString, - selectedValues?.length, + selectedValues, valuesRetrieval, variableName, + valuesQuery, ]); useEffect(() => { @@ -228,7 +224,7 @@ export function ValueControlForm({ availableOptions, selectedOptions: [availableOptions[0]], width: minimumWidth, - selectionType, + singleSelect, title: label || variableNameWithoutQuestionmark, variableName: variableNameWithoutQuestionmark, variableType, @@ -240,7 +236,7 @@ export function ValueControlForm({ setControlState(state); } }, [ - selectionType, + singleSelect, controlFlyoutType, grow, initialState, @@ -384,7 +380,7 @@ export function ValueControlForm({ /> From 150016503af8b6c0b8ac01e34f04967d6d31057c Mon Sep 17 00:00:00 2001 From: Stratoula Date: Thu, 16 Oct 2025 14:35:37 +0200 Subject: [PATCH 04/16] Wire it up --- .../kbn-esql-types/src/variables_types.ts | 2 +- .../esql_control_selections.test.tsx | 52 +++++++++++++++++++ .../esql_control/esql_control_selections.ts | 34 +++++++++--- .../get_esql_control_factory.test.tsx | 1 + .../esql_control/get_esql_control_factory.tsx | 19 +++++-- .../control_flyout/value_control_form.tsx | 10 ++-- 6 files changed, 103 insertions(+), 15 deletions(-) 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 3656bb2552883..fc5a9a0504c0c 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 @@ -35,7 +35,7 @@ export enum EsqlControlType { export interface ESQLControlVariable { key: string; - value: string | number; + value: string | number | (string | number)[]; type: ESQLVariableType; } 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 62db4e17506e7..ff9aa4b31d3b3 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 @@ -126,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: string | number | (string | number)[]; + + 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 { 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 6691c8858e021..6e4377d499cd3 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 @@ -136,14 +136,27 @@ 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/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 f43e4d3082467..0dcb7169045b6 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 @@ -197,12 +197,14 @@ export function ValueControlForm({ ); useEffect(() => { - if (!selectedValues?.length && controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY) { + if ( + !selectedValues?.length && + controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY && + valuesRetrieval + ) { const queryForValues = variableName !== '' - ? valuesRetrieval - ? `FROM ${getIndexPatternFromESQLQuery(queryString)} | STATS BY ${valuesRetrieval}` - : valuesQuery + ? `FROM ${getIndexPatternFromESQLQuery(queryString)} | STATS BY ${valuesRetrieval}` : ''; onValuesQuerySubmit(queryForValues); } From 2743ace1dd41e273a099a3b6fe62bb21e9c4f836 Mon Sep 17 00:00:00 2001 From: Stratoula Date: Fri, 17 Oct 2025 10:21:08 +0200 Subject: [PATCH 05/16] Adds editor support --- .../kbn-esql-editor/src/esql_editor.tsx | 71 +++++----------- .../shared/kbn-es-types/src/search.ts | 7 +- .../shared/kbn-esql-ast/scripts/functions.ts | 6 ++ .../definitions/generated/scalar_functions.ts | 20 +++++ .../kbn-esql-ast/src/definitions/types.ts | 5 ++ .../utils/autocomplete/functions.ts | 6 ++ .../src/definitions/utils/functions.ts | 1 + .../kbn-esql-types/src/variables_types.ts | 1 + .../autocomplete.command.variables.test.ts | 33 ++++++++ .../main/state_management/redux/utils.test.ts | 84 ++++++++++++++++++- .../main/state_management/redux/utils.ts | 19 ++++- .../esql_controls/control_flyout/helpers.ts | 4 +- .../control_flyout/value_control_form.tsx | 5 +- 13 files changed, 204 insertions(+), 58 deletions(-) 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 cfd2c8b111bab..59fc209f41482 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 @@ -309,56 +309,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-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 976c0228c1104..19039ce718436 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 @@ -7435,6 +7435,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'boolean', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7450,6 +7451,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'cartesian_point', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7465,6 +7467,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'cartesian_shape', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7480,6 +7483,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'date', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7495,6 +7499,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'date_nanos', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7510,6 +7515,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'double', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7525,6 +7531,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geo_point', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7540,6 +7547,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geo_shape', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7555,6 +7563,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geohash', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7570,6 +7579,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geohex', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7585,6 +7595,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'geotile', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7600,6 +7611,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'integer', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7615,6 +7627,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'ip', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7630,6 +7643,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'keyword', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7645,6 +7659,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'keyword', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7660,6 +7675,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'long', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7675,6 +7691,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'text', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7690,6 +7707,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'text', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7705,6 +7723,7 @@ const mvContainsDefinition: FunctionDefinition = { name: 'superset', type: 'unsigned_long', optional: false, + supportsMultiValues: true, }, { name: 'subset', @@ -7720,6 +7739,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 c4a416520f0ca..e754ab5579db4 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 @@ -180,6 +180,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/functions.ts b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/functions.ts index 9b19f95039635..a4e1b5551d4a5 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/functions.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/definitions/utils/autocomplete/functions.ts @@ -10,6 +10,7 @@ import type { LicenseType } from '@kbn/licensing-types'; import { uniq } from 'lodash'; import type { PricingProduct } from '@kbn/core-pricing-common/src/types'; +import { ESQLVariableType } from '@kbn/esql-types'; import { getLocationInfo } from '../../../commands_registry/location'; import { isAssignment, @@ -190,6 +191,10 @@ export async function getFunctionArgsSuggestions( // If the type is explicitly a boolean condition typesToSuggestNext.some((t) => t && t.type === 'boolean' && t.name === 'condition'); + const canBeMultiValue = typesToSuggestNext.some( + (t) => t && (t.supportsMultiValues === true || t.name === 'values') + ); + const shouldAddComma = hasMoreMandatoryArgs && fnDefinition.type !== FunctionDefinitionTypes.OPERATOR && @@ -334,6 +339,7 @@ export async function getFunctionArgsSuggestions( addComma: shouldAddComma, advanceCursor: shouldAdvanceCursor, openSuggestions: shouldAdvanceCursor, + ...(canBeMultiValue && { variableType: ESQLVariableType.MULTI_VALUES }), } ), true 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 ae6e1254eeeef..597fb33d2bf13 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 @@ -395,6 +395,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 fc5a9a0504c0c..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', } 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..19918ef87b954 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/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..20f9e59f7e53f 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,81 @@ 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'], + }, + ]); + }); +}); 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/triggers/esql_controls/control_flyout/helpers.ts b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/helpers.ts index 9679662016704..3e89c666f7403 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 @@ -159,7 +159,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 +175,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/value_control_form.tsx b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.tsx index d3a0a8a334645..9693bd5321c4f 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 @@ -102,7 +102,10 @@ export function ValueControlForm({ ); const [label, setLabel] = useState(initialState?.title ?? ''); const [minimumWidth, setMinimumWidth] = useState(initialState?.width ?? 'medium'); - const [singleSelect, setSingleSelect] = useState(initialState?.singleSelect ?? true); + const shouldDefaultToMultiSelect = variableType === ESQLVariableType.MULTI_VALUES; + const [singleSelect, setSingleSelect] = useState( + initialState?.singleSelect ?? !shouldDefaultToMultiSelect + ); const [grow, setGrow] = useState(initialState?.grow ?? false); From a2bd0ef6db90710ca07fe366b175faf93717dc6f Mon Sep 17 00:00:00 2001 From: Stratoula Date: Fri, 17 Oct 2025 10:35:35 +0200 Subject: [PATCH 06/16] Cleanup --- .../esql_controls/control_flyout/value_control_form.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 9693bd5321c4f..2aceca8ca04aa 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 @@ -215,10 +215,9 @@ export function ValueControlForm({ controlFlyoutType, onValuesQuerySubmit, queryString, - selectedValues, + selectedValues?.length, valuesRetrieval, variableName, - valuesQuery, ]); useEffect(() => { From d5b8fc85d0296e8c6974356bfadb23f718b42709 Mon Sep 17 00:00:00 2001 From: Stratoula Date: Fri, 17 Oct 2025 11:24:00 +0200 Subject: [PATCH 07/16] Adds a functional test --- .../apps/discover/tabs/_controls.ts | 56 +++++++++++++++++++ src/platform/test/functional/services/esql.ts | 8 ++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/platform/test/functional/apps/discover/tabs/_controls.ts b/src/platform/test/functional/apps/discover/tabs/_controls.ts index 82660c273711d..03945e5d49d3f 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( ?variable'); + + await discover.waitUntilTabIsLoaded(); + + // Update the query + await esql.typeEsqlEditorQuery( + 'FROM logstash-* | WHERE MV_CONTAINS( ?variable, 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'); From 0c9be98011cb8372a4d5e1b1df973c017de113ea Mon Sep 17 00:00:00 2001 From: Stratoula Date: Fri, 17 Oct 2025 12:01:41 +0200 Subject: [PATCH 08/16] Small enhancements --- .../public/triggers/esql_controls/control_flyout/helpers.ts | 2 ++ .../esql_controls/control_flyout/value_control_form.tsx | 2 +- src/platform/test/functional/apps/discover/tabs/_controls.ts | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) 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 3e89c666f7403..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'; } 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 2aceca8ca04aa..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 @@ -231,7 +231,7 @@ export function ValueControlForm({ singleSelect, title: label || variableNameWithoutQuestionmark, variableName: variableNameWithoutQuestionmark, - variableType, + variableType: singleSelect ? variableType : ESQLVariableType.MULTI_VALUES, esqlQuery: valuesQuery || queryString, controlType: controlFlyoutType, grow, diff --git a/src/platform/test/functional/apps/discover/tabs/_controls.ts b/src/platform/test/functional/apps/discover/tabs/_controls.ts index 03945e5d49d3f..c9ffb7b08183b 100644 --- a/src/platform/test/functional/apps/discover/tabs/_controls.ts +++ b/src/platform/test/functional/apps/discover/tabs/_controls.ts @@ -68,13 +68,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Check Discover editor has been updated accordingly const editorValue = await esql.getEsqlEditorQuery(); - expect(editorValue).to.contain('FROM logstash-* | WHERE MV_CONTAINS( ?variable'); + expect(editorValue).to.contain('FROM logstash-* | WHERE MV_CONTAINS( ?values'); await discover.waitUntilTabIsLoaded(); // Update the query await esql.typeEsqlEditorQuery( - 'FROM logstash-* | WHERE MV_CONTAINS( ?variable, geo.dest ) | KEEP geo.dest' + 'FROM logstash-* | WHERE MV_CONTAINS( ?values, geo.dest ) | KEEP geo.dest' ); await discover.waitUntilTabIsLoaded(); From 4f728847e32cf778988e0b85b8c8b5ee2d072a60 Mon Sep 17 00:00:00 2001 From: Stratoula Date: Fri, 17 Oct 2025 12:37:37 +0200 Subject: [PATCH 09/16] Add a link --- .../control_flyout/shared_form_components.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) 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 35007712f02f3..6f981ac3fe0d9 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 @@ -29,6 +29,7 @@ import { EuiFlexItem, EuiButtonEmpty, EuiButton, + EuiLink, EuiFlyoutHeader, EuiTitle, EuiBetaBadge, @@ -37,6 +38,7 @@ import { EuiTextColor, EuiCode, EuiCallOut, + useEuiTheme, } from '@elastic/eui'; import { checkVariableExistence } from './helpers'; @@ -342,6 +344,9 @@ export function ControlSelectionType({ singleSelect: boolean; onSelectionTypeChange: (isSingleSelect: boolean) => void; }) { + const theme = useEuiTheme(); + const link = + 'https://www.elastic.co/docs/explore-analyze/dashboards/add-controls#add-esql-control'; return ( <> @@ -371,11 +376,26 @@ export function ControlSelectionType({ size="s" color="primary" iconType="info" - title={i18n.translate('esql.flyout.selectionType.callout', { - defaultMessage: - 'You must use MV_CONTAINS in your ES|QL query for multi-select controls to work.', - })} - /> + css={css` + .euiText { + color: ${theme.euiTheme.colors.textPrimary} !important; + } + `} + > + + + MV_CONTAINS + + ), + }} + /> + + ) : null} From c23491c5e81e1970132b3b966fd86c282f1de042 Mon Sep 17 00:00:00 2001 From: Stratoula Date: Fri, 17 Oct 2025 12:48:22 +0200 Subject: [PATCH 10/16] Adds a link --- .../packages/shared/kbn-doc-links/src/get_doc_links.ts | 1 + .../packages/shared/kbn-doc-links/src/types.ts | 1 + .../plugins/shared/esql/public/kibana_services.ts | 6 ++++-- .../control_flyout/shared_form_components.tsx | 10 +++++++--- 4 files changed, 13 insertions(+), 5 deletions(-) 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 ffac6e171d5bc..93d8f1bb767dd 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 @@ -506,6 +506,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/dashboards/add-controls#add-esql-control`, }, 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 8277ae3399370..f6d480e8a6988 100644 --- a/src/platform/packages/shared/kbn-doc-links/src/types.ts +++ b/src/platform/packages/shared/kbn-doc-links/src/types.ts @@ -364,6 +364,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/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/shared_form_components.tsx b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/shared_form_components.tsx index 6f981ac3fe0d9..170a387c6ec8d 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 @@ -40,6 +40,8 @@ import { 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 = [ @@ -345,8 +347,10 @@ export function ControlSelectionType({ onSelectionTypeChange: (isSingleSelect: boolean) => void; }) { const theme = useEuiTheme(); - const link = - 'https://www.elastic.co/docs/explore-analyze/dashboards/add-controls#add-esql-control'; + const { + services: { docLinks }, + } = useKibana(); + const multiValuesGuideLink = docLinks.links.query.queryESQLMultiValueControls; return ( <> @@ -388,7 +392,7 @@ export function ControlSelectionType({ defaultMessage="You must use {mvContainsLink} in your ES|QL query for multi-select controls to work." values={{ mvContainsLink: ( - + MV_CONTAINS ), From 827682e0fa699a4267fa0e68ea9dc9cb2dbdca02 Mon Sep 17 00:00:00 2001 From: Stratoula Date: Fri, 17 Oct 2025 15:43:53 +0200 Subject: [PATCH 11/16] Replace the link --- src/platform/packages/shared/kbn-doc-links/src/get_doc_links.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 93d8f1bb767dd..68e4dcaff149d 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 @@ -506,7 +506,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/dashboards/add-controls#add-esql-control`, + queryESQLMultiValueControls: `${ELASTIC_DOCS}explore-analyze/query-filter/languages/esql-kibana#esql-multi-values-controls`, }, search: { sessions: `${ELASTIC_DOCS}explore-analyze/discover/search-sessions`, From b3573acaea9e71bee23b54732575349cc2d8e60f Mon Sep 17 00:00:00 2001 From: Stratoula Date: Mon, 20 Oct 2025 09:09:08 +0200 Subject: [PATCH 12/16] Fix tests --- .../esql_controls/control_flyout/shared_form_components.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 170a387c6ec8d..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 @@ -350,7 +350,7 @@ export function ControlSelectionType({ const { services: { docLinks }, } = useKibana(); - const multiValuesGuideLink = docLinks.links.query.queryESQLMultiValueControls; + const multiValuesGuideLink = docLinks?.links.query.queryESQLMultiValueControls ?? ''; return ( <> From ea247892d5de19aeaac8cd8b7f875e8e69466bfb Mon Sep 17 00:00:00 2001 From: Stratoula Date: Mon, 20 Oct 2025 09:13:42 +0200 Subject: [PATCH 13/16] Fixes autocomplete --- .../__tests__/autocomplete.command.variables.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 19918ef87b954..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 @@ -179,7 +179,7 @@ describe('autocomplete.suggest', () => { test('suggests `?multiValue` option', async () => { const { suggest } = await setup(); - const suggestions = await suggest('FROM index_a | WHERE MV_CONTAINS(/', { + const suggestions = await suggest('FROM index_a | WHERE MV_CONTAINS( /', { callbacks: { canSuggestVariables: () => true, getVariables: () => [ From ba25799ed98bcc460d5cda40433cb2444bfe2938 Mon Sep 17 00:00:00 2001 From: Stratoula Date: Tue, 21 Oct 2025 10:14:05 +0200 Subject: [PATCH 14/16] Get the value type from the ESQLControlVariable type --- .../public/controls/esql_control/esql_control_selections.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ff9aa4b31d3b3..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 @@ -131,7 +131,7 @@ export function initializeESQLControlSelections( const selectedValues = selectedOptions$.value; // For single select, return the first value; for multi-select, return the array - let value: string | number | (string | number)[]; + let value: ESQLControlVariable['value']; if (isSingleSelect) { // Single select: return the first value or empty string if none selected From 6bf0a3f48320b4213a6c94551715e7419d8e55ff Mon Sep 17 00:00:00 2001 From: Stratoula Date: Thu, 23 Oct 2025 09:42:56 +0200 Subject: [PATCH 15/16] Nit: Add a test with numeric values --- .../main/state_management/redux/utils.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 20f9e59f7e53f..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 @@ -139,4 +139,24 @@ describe('extractEsqlVariables', () => { }, ]); }); + + 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], + }, + ]); + }); }); From 982ee8ce4d8b4d90439093c64d7a5da333f00c3b Mon Sep 17 00:00:00 2001 From: Stratoula Date: Tue, 28 Oct 2025 07:27:00 +0100 Subject: [PATCH 16/16] getting the current value from the reactive subject --- .../public/controls/esql_control/get_esql_control_factory.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6e4377d499cd3..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 @@ -136,7 +136,7 @@ export const getESQLControlFactory = (): ControlFactory