From b84c65c095fea8a69db1656510235849793c82a1 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 27 Jan 2025 11:35:54 +0100 Subject: [PATCH] [ES|QL] Dashboard variables (#202875) ## Summary Closes https://github.com/elastic/kibana/issues/203967 Supports dashboard variables in ES|QL charts. This PR introduces the first phase of ES|QL controls. In this phase: - the flow starts from Lens ES|QL editor (and no vice-versa, this will happen on a later phase after we discuss some technical details with ES) - it is only available for dashboards (we want to include them in other apps as Discover but this is the next phase driven by the presentation team) - it supports variables for intervals, fields and values. I haven't added support for functions. I am going to do it after this PR being merged (there are some business questions I want to answer first) For more info check this [deck](https://docs.google.com/presentation/d/1qSbWLSoC5SseXuLix763vpp8sa7ikp3pQTbHImEHBoc) ![meow](https://github.com/user-attachments/assets/c101a257-fbe4-44e6-9686-18012f39e8c1) ### Implementation details - There is a new service, the ESQLVariables service that is responsible for ES|QL variables. I isolated this to a new plugin owned by the ES|QL team for cleaner code and for avoiding circular dependencies - A new ESQL_CONTROL type got created. It follows the exact same logic as the rest controls. No changes in the architecture here. - The creation of the controls (the control forms) have been added in the esql plugin. - Lens has small changes: - The support of variables in the textBased datasource - Two callbacks needed to be called after the creation / cancellation of an ES|QL control ### Types of ES|QL variables We have 2 types: - Static Values (the user gives a list of values with his own responsibility). As the flow starts from the editor we can identify what they most possibly want to do and we give the user some options but they have the freedom to do as they want. A basic validation has been added too. - Values from an ES|QL query (the user gives an ES|QL query that generates the values). As the flow starts from the editor we can suggest a query for the users but they can always change it as they wish. image ### Example of a control creation from the editor ![meow](https://github.com/user-attachments/assets/09fa0e21-98cd-4160-b271-4f8ed0a91bf7) ### Release note ES|QL charts now allow the creation of controls in dashboards. You can control a part of the query such as a field, an interval or a value. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Andrea Del Rio Co-authored-by: Devon Thomson --- .buildkite/ftr_platform_stateful_configs.yml | 1 + .github/CODEOWNERS | 1 + package.json | 1 + .../kbn-esql-editor/src/esql_editor.tsx | 96 +++- .../private/kbn-esql-editor/src/types.ts | 23 + .../private/kbn-esql-editor/tsconfig.json | 1 + .../kbn-es-query/src/expressions/types.ts | 3 +- .../shared/kbn-es-query/tsconfig.json | 3 +- .../shared/kbn-es-types/src/search.ts | 4 +- .../packages/shared/kbn-esql-utils/index.ts | 2 + .../shared/kbn-esql-utils/src/index.ts | 2 + .../src/utils/query_parsing_helpers.test.ts | 268 +++++++++- .../src/utils/query_parsing_helpers.ts | 33 ++ .../src/utils/run_query.test.ts | 152 +++++- .../kbn-esql-utils/src/utils/run_query.ts | 23 +- .../shared/kbn-esql-utils/tsconfig.json | 3 +- .../kbn-esql-validation-autocomplete/index.ts | 2 + .../autocomplete.command.stats.test.ts | 78 +++ .../autocomplete.command.where.test.ts | 53 ++ .../src/autocomplete/autocomplete.ts | 34 +- .../src/autocomplete/factories.ts | 100 +++- .../src/autocomplete/helper.ts | 5 + .../src/autocomplete/types.ts | 8 +- .../src/shared/helpers.ts | 6 +- .../src/shared/types.ts | 15 +- .../src/validation/validation.test.ts | 2 + .../src/validation/validation.ts | 4 +- .../shared/kbn-esql-variables-types/README.md | 3 + .../shared/kbn-esql-variables-types/index.ts | 37 ++ .../kbn-esql-variables-types/kibana.jsonc | 9 + .../kbn-esql-variables-types/package.json | 7 + .../kbn-esql-variables-types/tsconfig.json | 20 + .../shared/controls/common/constants.ts | 2 + .../plugins/shared/controls/common/index.ts | 1 + .../plugins/shared/controls/kibana.jsonc | 3 +- .../get_control_group_factory.tsx | 21 +- .../controls/public/control_group/types.ts | 2 + .../esql_control/esql_control_selections.ts | 83 +++ .../get_esql_control_factory.test.tsx | 120 +++++ .../esql_control/get_esql_control_factory.tsx | 148 ++++++ .../esql_control/register_esql_control.ts | 22 + .../public/controls/esql_control/types.ts | 16 + .../shared/controls/public/controls_module.ts | 1 + .../plugins/shared/controls/public/index.ts | 1 + .../plugins/shared/controls/public/plugin.ts | 4 + .../esql_control/esql_control_factory.ts | 23 + .../esql_control_persistable_state.ts | 30 ++ .../plugins/shared/controls/server/plugin.ts | 2 + .../plugins/shared/controls/tsconfig.json | 3 + .../plugins/shared/dashboard/kibana.jsonc | 6 +- .../dashboard/public/dashboard_api/types.ts | 2 + .../dashboard_api/unified_search_manager.ts | 47 +- .../public/dashboard_app/dashboard_app.tsx | 1 - .../plugins/shared/dashboard/public/mocks.tsx | 1 + .../plugins/shared/dashboard/tsconfig.json | 4 +- .../__snapshots__/kibana.test.ts.snap | 1 + .../data/common/search/expressions/esql.ts | 23 +- .../data/common/search/expressions/kibana.ts | 1 + .../plugins/shared/data/tsconfig.json | 2 +- src/platform/plugins/shared/esql/kibana.jsonc | 2 +- .../plugins/shared/esql/public/index.ts | 1 + .../shared/esql/public/kibana_services.ts | 4 + .../plugins/shared/esql/public/plugin.ts | 14 + .../choose_column_popover.test.tsx | 58 +++ .../control_flyout/choose_column_popover.tsx | 83 +++ .../field_control_form.test.tsx | 201 ++++++++ .../control_flyout/field_control_form.tsx | 278 ++++++++++ .../control_flyout/helpers.test.ts | 139 +++++ .../esql_controls/control_flyout/helpers.ts | 128 +++++ .../esql_controls/control_flyout/index.tsx | 92 ++++ .../control_flyout/shared_form_components.tsx | 397 ++++++++++++++ .../value_control_form.test.tsx | 260 ++++++++++ .../control_flyout/value_control_form.tsx | 484 ++++++++++++++++++ .../esql_controls/esql_control_action.test.ts | 42 ++ .../esql_controls/esql_control_action.ts | 76 +++ .../esql_controls/esql_control_helpers.tsx | 101 ++++ .../esql_controls/esql_control_trigger.ts | 22 + .../public/triggers/esql_controls/types.ts | 28 + .../shared/esql/public/triggers/index.ts | 10 +- .../update_esql_query_actions.test.ts | 0 .../update_esql_query_actions.ts | 0 .../update_esql_query_helpers.ts | 0 .../update_esql_query_trigger.ts | 0 .../esql/public/variables_service.test.ts | 84 +++ .../shared/esql/public/variables_service.ts | 40 ++ .../plugins/shared/esql/tsconfig.json | 8 + .../expression_types/specs/datatable.ts | 1 + .../common/service/expressions_services.ts | 1 - .../common/expressions/kibana_context.ts | 1 + .../apps/dashboard/esql_controls/config.ts | 19 + .../dashboard/esql_controls/field_control.ts | 99 ++++ .../apps/dashboard/esql_controls/index.ts | 34 ++ .../esql_controls/interval_control.ts | 90 ++++ .../dashboard/esql_controls/value_control.ts | 130 +++++ test/functional/services/esql.ts | 18 + tsconfig.base.json | 2 + .../map_to_columns/map_to_columns.test.ts | 51 ++ .../map_to_columns_fn_textbased.ts | 41 +- .../expressions/map_to_columns/types.ts | 7 +- .../platform/plugins/shared/lens/kibana.jsonc | 17 +- .../get_edit_lens_configuration.tsx | 3 + .../shared/edit_on_the_fly/helpers.ts | 36 +- .../lens_configuration_flyout.tsx | 92 ++-- .../shared/edit_on_the_fly/types.ts | 2 + .../edit_on_the_fly/use_esql_variables.ts | 82 +++ .../components/dimension_editor.tsx | 17 +- .../text_based/dnd/on_drop.test.ts | 72 +++ .../datasources/text_based/dnd/on_drop.ts | 5 +- .../text_based/text_based_languages.tsx | 5 +- .../datasources/text_based/to_expression.ts | 2 + .../public/datasources/text_based/types.ts | 1 + .../datasources/text_based/utils.test.ts | 70 +++ .../public/datasources/text_based/utils.ts | 1 + .../editor_frame/suggestion_panel.tsx | 5 +- .../public/persistence/saved_object_store.ts | 1 + .../public/react_embeddable/data_loader.ts | 17 +- .../expressions/merged_search_context.ts | 6 +- .../initializers/initialize_edit.tsx | 3 +- .../initializers/initialize_internal_api.ts | 6 + .../inline_editing/setup_inline_editing.tsx | 4 +- .../lens/public/react_embeddable/types.ts | 23 + .../plugins/shared/lens/tsconfig.json | 2 + .../.storybook/mock_kibana_services.ts | 12 +- yarn.lock | 4 + 124 files changed, 4931 insertions(+), 172 deletions(-) create mode 100644 src/platform/packages/shared/kbn-esql-variables-types/README.md create mode 100644 src/platform/packages/shared/kbn-esql-variables-types/index.ts create mode 100644 src/platform/packages/shared/kbn-esql-variables-types/kibana.jsonc create mode 100644 src/platform/packages/shared/kbn-esql-variables-types/package.json create mode 100644 src/platform/packages/shared/kbn-esql-variables-types/tsconfig.json create mode 100644 src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts create mode 100644 src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx create mode 100644 src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx create mode 100644 src/platform/plugins/shared/controls/public/controls/esql_control/register_esql_control.ts create mode 100644 src/platform/plugins/shared/controls/public/controls/esql_control/types.ts create mode 100644 src/platform/plugins/shared/controls/server/esql_control/esql_control_factory.ts create mode 100644 src/platform/plugins/shared/controls/server/esql_control/esql_control_persistable_state.ts create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/choose_column_popover.test.tsx create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/choose_column_popover.tsx create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/field_control_form.test.tsx create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/field_control_form.tsx create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/helpers.test.ts create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/helpers.ts create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/index.tsx create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/shared_form_components.tsx create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.test.tsx create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/value_control_form.tsx create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/esql_control_action.test.ts create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/esql_control_action.ts create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/esql_control_helpers.tsx create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/esql_control_trigger.ts create mode 100644 src/platform/plugins/shared/esql/public/triggers/esql_controls/types.ts rename src/platform/plugins/shared/esql/public/triggers/{ => update_esql_query}/update_esql_query_actions.test.ts (100%) rename src/platform/plugins/shared/esql/public/triggers/{ => update_esql_query}/update_esql_query_actions.ts (100%) rename src/platform/plugins/shared/esql/public/triggers/{ => update_esql_query}/update_esql_query_helpers.ts (100%) rename src/platform/plugins/shared/esql/public/triggers/{ => update_esql_query}/update_esql_query_trigger.ts (100%) create mode 100644 src/platform/plugins/shared/esql/public/variables_service.test.ts create mode 100644 src/platform/plugins/shared/esql/public/variables_service.ts create mode 100644 test/functional/apps/dashboard/esql_controls/config.ts create mode 100644 test/functional/apps/dashboard/esql_controls/field_control.ts create mode 100644 test/functional/apps/dashboard/esql_controls/index.ts create mode 100644 test/functional/apps/dashboard/esql_controls/interval_control.ts create mode 100644 test/functional/apps/dashboard/esql_controls/value_control.ts create mode 100644 x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/use_esql_variables.ts diff --git a/.buildkite/ftr_platform_stateful_configs.yml b/.buildkite/ftr_platform_stateful_configs.yml index 09b2feced215b..d3ee086f74d16 100644 --- a/.buildkite/ftr_platform_stateful_configs.yml +++ b/.buildkite/ftr_platform_stateful_configs.yml @@ -66,6 +66,7 @@ enabled: - test/functional/apps/dashboard/group4/config.ts - test/functional/apps/dashboard/group5/config.ts - test/functional/apps/dashboard/group6/config.ts + - test/functional/apps/dashboard/esql_controls/config.ts - test/functional/apps/discover/ccs_compatibility/config.ts - test/functional/apps/discover/embeddable/config.ts - test/functional/apps/discover/esql/config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 08146425bd3a4..9b3c0012222ca 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -463,6 +463,7 @@ src/platform/packages/shared/kbn-es-types @elastic/kibana-core @elastic/obs-know src/platform/packages/shared/kbn-esql-ast @elastic/kibana-esql src/platform/packages/shared/kbn-esql-utils @elastic/kibana-esql src/platform/packages/shared/kbn-esql-validation-autocomplete @elastic/kibana-esql +src/platform/packages/shared/kbn-esql-variables-types @elastic/kibana-esql src/platform/packages/shared/kbn-event-annotation-common @elastic/kibana-visualizations src/platform/packages/shared/kbn-event-annotation-components @elastic/kibana-visualizations src/platform/packages/shared/kbn-field-types @elastic/kibana-data-discovery diff --git a/package.json b/package.json index d13941bda9a76..a4ce8c072347d 100644 --- a/package.json +++ b/package.json @@ -492,6 +492,7 @@ "@kbn/esql-utils": "link:src/platform/packages/shared/kbn-esql-utils", "@kbn/esql-validation-autocomplete": "link:src/platform/packages/shared/kbn-esql-validation-autocomplete", "@kbn/esql-validation-example-plugin": "link:examples/esql_validation_example", + "@kbn/esql-variables-types": "link:src/platform/packages/shared/kbn-esql-variables-types", "@kbn/eui-provider-dev-warning": "link:test/plugin_functional/plugins/eui_provider_dev_warning", "@kbn/event-annotation-common": "link:src/platform/packages/shared/kbn-event-annotation-common", "@kbn/event-annotation-components": "link:src/platform/packages/shared/kbn-event-annotation-components", 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 f94dbb1c378e6..ff9ea679e257d 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 @@ -19,6 +19,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; +import { isEqual } from 'lodash'; import { CodeEditor, CodeEditorProps } from '@kbn/code-editor'; import type { CoreStart } from '@kbn/core/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; @@ -30,8 +31,9 @@ import memoize from 'lodash/memoize'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { css } from '@emotion/react'; -import { ESQLRealField } from '@kbn/esql-validation-autocomplete'; +import { ESQLRealField, ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; import { FieldType } from '@kbn/esql-validation-autocomplete/src/definitions/types'; +import { ESQLVariableType } from '@kbn/esql-validation-autocomplete'; import { EditorFooter } from './editor_footer'; import { fetchFieldsFromESQL } from './fetch_fields_from_esql'; import { @@ -59,6 +61,25 @@ import './overwrite.scss'; // for editor width smaller than this value we want to start hiding some text const BREAKPOINT_WIDTH = 540; +const triggerControl = async ( + queryString: string, + variableType: ESQLVariableType, + position: monaco.Position | null | undefined, + uiActions: ESQLEditorDeps['uiActions'], + esqlVariables?: ESQLControlVariable[], + onSaveControl?: ESQLEditorProps['onSaveControl'], + onCancelControl?: ESQLEditorProps['onCancelControl'] +) => { + await uiActions.getTrigger('ESQL_CONTROL_TRIGGER').exec({ + queryString, + variableType, + cursorPosition: position, + esqlVariables, + onSaveControl, + onCancelControl, + }); +}; + export const ESQLEditor = memo(function ESQLEditor({ query, onTextLangQueryChange, @@ -78,6 +99,10 @@ export const ESQLEditor = memo(function ESQLEditor({ hasOutline, displayDocumentationAsFlyout, disableAutoFocus, + onSaveControl, + onCancelControl, + supportsControls, + esqlVariables, }: ESQLEditorProps) { const popoverRef = useRef(null); const datePickerOpenStatusRef = useRef(false); @@ -91,8 +116,10 @@ export const ESQLEditor = memo(function ESQLEditor({ core, fieldsMetadata, uiSettings, + uiActions, } = kibana.services; + const variablesService = kibana.services?.esql?.variablesService; const histogramBarTarget = uiSettings?.get('histogram:barTarget') ?? 50; const [code, setCode] = useState(query.esql ?? ''); // To make server side errors less "sticky", register the state of the code when submitting @@ -187,6 +214,23 @@ export const ESQLEditor = memo(function ESQLEditor({ } }, [code, query.esql]); + // Enable the variables service if the feature is supported in the consumer app + useEffect(() => { + if (supportsControls) { + variablesService?.enableSuggestions(); + + const variables = variablesService?.esqlVariables; + if (!isEqual(variables, esqlVariables)) { + variablesService?.clearVariables(); + esqlVariables?.forEach((variable) => { + variablesService?.addVariable(variable); + }); + } + } else { + variablesService?.disableSuggestions(); + } + }, [variablesService, supportsControls, esqlVariables]); + const toggleHistory = useCallback((status: boolean) => { setIsHistoryOpen(status); }, []); @@ -230,6 +274,45 @@ export const ESQLEditor = memo(function ESQLEditor({ openTimePickerPopover(); }); + monaco.editor.registerCommand('esql.control.time_literal.create', async (...args) => { + const position = editor1.current?.getPosition(); + await triggerControl( + query.esql, + ESQLVariableType.TIME_LITERAL, + position, + uiActions, + esqlVariables, + onSaveControl, + onCancelControl + ); + }); + + monaco.editor.registerCommand('esql.control.fields.create', async (...args) => { + const position = editor1.current?.getPosition(); + await triggerControl( + query.esql, + ESQLVariableType.FIELDS, + position, + uiActions, + esqlVariables, + onSaveControl, + onCancelControl + ); + }); + + monaco.editor.registerCommand('esql.control.values.create', async (...args) => { + const position = editor1.current?.getPosition(); + await triggerControl( + query.esql, + ESQLVariableType.VALUES, + position, + uiActions, + esqlVariables, + onSaveControl, + onCancelControl + ); + }); + const styles = esqlEditorStyles( euiTheme, editorHeight, @@ -373,13 +456,20 @@ export const ESQLEditor = memo(function ESQLEditor({ }, // @ts-expect-error To prevent circular type import, type defined here is partial of full client getFieldsMetadata: fieldsMetadata?.getClient(), + getVariablesByType: (type: ESQLVariableType) => { + return variablesService?.esqlVariables.filter((variable) => variable.type === type); + }, + canSuggestVariables: () => { + return variablesService?.areSuggestionsEnabled ?? false; + }, getJoinIndices: kibana.services?.esql?.getJoinIndicesAutocomplete, }; return callbacks; }, [ + fieldsMetadata, + dataSourcesCache, query.esql, memoizedSources, - dataSourcesCache, dataViews, core, esqlFieldsCache, @@ -388,7 +478,7 @@ export const ESQLEditor = memo(function ESQLEditor({ abortController, indexManagementApiService, histogramBarTarget, - fieldsMetadata, + variablesService, kibana.services?.esql?.getJoinIndicesAutocomplete, ]); diff --git a/src/platform/packages/private/kbn-esql-editor/src/types.ts b/src/platform/packages/private/kbn-esql-editor/src/types.ts index 177dc2b398873..df5243bb49193 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/types.ts +++ b/src/platform/packages/private/kbn-esql-editor/src/types.ts @@ -15,6 +15,8 @@ import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-ty import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import type { ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; export interface ESQLEditorProps { /** The aggregate type query */ @@ -69,6 +71,16 @@ export interface ESQLEditorProps { /** The component by default focuses on the editor when it is mounted, this flag disables it**/ disableAutoFocus?: boolean; + /** The editor supports the creation of controls, + * This flag should be set to true to display the "Create control" suggestion + **/ + supportsControls?: boolean; + /** Function to be called after the control creation **/ + onSaveControl?: (controlState: Record, updatedQuery: string) => Promise; + /** Function to be called after cancelling the control creation **/ + onCancelControl?: () => void; + /** The available ESQL variables from the page context this editor was opened in */ + esqlVariables?: ESQLControlVariable[]; } export interface JoinIndicesAutocompleteResult { @@ -81,8 +93,18 @@ export interface JoinIndexAutocompleteItem { aliases: string[]; } +interface ESQLVariableService { + areSuggestionsEnabled: boolean; + esqlVariables: ESQLControlVariable[]; + enableSuggestions: () => void; + disableSuggestions: () => void; + clearVariables: () => void; + addVariable: (variable: ESQLControlVariable) => void; +} + export interface EsqlPluginStartBase { getJoinIndicesAutocomplete: () => Promise; + variablesService: ESQLVariableService; } export interface ESQLEditorDeps { @@ -90,6 +112,7 @@ export interface ESQLEditorDeps { dataViews: DataViewsPublicPluginStart; expressions: ExpressionsStart; storage: Storage; + uiActions: UiActionsStart; indexManagementApiService?: IndexManagementPluginSetup['apiService']; fieldsMetadata?: FieldsMetadataPublicStart; usageCollection?: UsageCollectionStart; diff --git a/src/platform/packages/private/kbn-esql-editor/tsconfig.json b/src/platform/packages/private/kbn-esql-editor/tsconfig.json index 96c05ba88d600..85dda7f030af0 100644 --- a/src/platform/packages/private/kbn-esql-editor/tsconfig.json +++ b/src/platform/packages/private/kbn-esql-editor/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/usage-collection-plugin", "@kbn/content-management-favorites-common", "@kbn/kibana-utils-plugin", + "@kbn/ui-actions-plugin", "@kbn/shared-ux-table-persist" ], "exclude": [ diff --git a/src/platform/packages/shared/kbn-es-query/src/expressions/types.ts b/src/platform/packages/shared/kbn-es-query/src/expressions/types.ts index caffc21294ea4..1cb794e8e2c50 100644 --- a/src/platform/packages/shared/kbn-es-query/src/expressions/types.ts +++ b/src/platform/packages/shared/kbn-es-query/src/expressions/types.ts @@ -6,7 +6,7 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ - +import type { ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; import { Filter, Query, TimeRange } from '../filters'; export interface ExecutionContextSearch { @@ -15,4 +15,5 @@ export interface ExecutionContextSearch { query?: Query | Query[]; timeRange?: TimeRange; disableWarningToasts?: boolean; + esqlVariables?: ESQLControlVariable[]; } diff --git a/src/platform/packages/shared/kbn-es-query/tsconfig.json b/src/platform/packages/shared/kbn-es-query/tsconfig.json index c2ea0a5bd7a90..6ad761f90dc1f 100644 --- a/src/platform/packages/shared/kbn-es-query/tsconfig.json +++ b/src/platform/packages/shared/kbn-es-query/tsconfig.json @@ -15,7 +15,8 @@ "kbn_references": [ "@kbn/utility-types", "@kbn/i18n", - "@kbn/safer-lodash-set" + "@kbn/safer-lodash-set", + "@kbn/esql-validation-autocomplete" ], "exclude": [ "target/**/*", 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 e02b8a4a5e843..a4f8d5de7af7d 100644 --- a/src/platform/packages/shared/kbn-es-types/src/search.ts +++ b/src/platform/packages/shared/kbn-es-types/src/search.ts @@ -691,5 +691,7 @@ export interface ESQLSearchParams { locale?: string; include_ccs_metadata?: boolean; dropNullColumns?: boolean; - params?: estypes.ScalarValue[] | Array>; + params?: + | estypes.ScalarValue[] + | Array | undefined>>; } diff --git a/src/platform/packages/shared/kbn-esql-utils/index.ts b/src/platform/packages/shared/kbn-esql-utils/index.ts index 7d75e230389f5..218927f1132d2 100644 --- a/src/platform/packages/shared/kbn-esql-utils/index.ts +++ b/src/platform/packages/shared/kbn-esql-utils/index.ts @@ -25,6 +25,7 @@ export { getTimeFieldFromESQLQuery, getStartEndParams, hasStartEndParams, + getNamedParams, prettifyQuery, isQueryWrappedByPipes, retrieveMetadataColumns, @@ -35,6 +36,7 @@ export { TextBasedLanguages, sanitazeESQLInput, queryCannotBeSampled, + mapVariableToColumn, } from './src'; export { ENABLE_ESQL, FEEDBACK_LINK } from './constants'; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/index.ts b/src/platform/packages/shared/kbn-esql-utils/src/index.ts index a28d9c6244f74..21b91939dcacb 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/index.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/index.ts @@ -21,6 +21,7 @@ export { isQueryWrappedByPipes, retrieveMetadataColumns, getQueryColumnsFromESQLQuery, + mapVariableToColumn, } from './utils/query_parsing_helpers'; export { queryCannotBeSampled } from './utils/query_cannot_be_sampled'; export { appendToESQLQuery, appendWhereClauseToESQLQuery } from './utils/append_to_query'; @@ -31,6 +32,7 @@ export { formatESQLColumns, getStartEndParams, hasStartEndParams, + getNamedParams, } from './utils/run_query'; export { isESQLColumnSortable, diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts index 709d86bbfc924..9f17a98ae62b6 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts @@ -6,7 +6,8 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ - +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; +import { ESQLVariableType, type ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; import { getIndexPatternFromESQLQuery, getLimitFromESQLQuery, @@ -17,6 +18,7 @@ import { isQueryWrappedByPipes, retrieveMetadataColumns, getQueryColumnsFromESQLQuery, + mapVariableToColumn, } from './query_parsing_helpers'; describe('esql query helpers', () => { @@ -273,4 +275,268 @@ describe('esql query helpers', () => { ]); }); }); + + describe('mapVariableToColumn', () => { + it('should return the columns as they are if no variables are defined', () => { + const esql = 'FROM a | EVAL b = 1'; + const variables: ESQLControlVariable[] = []; + const columns = [{ id: 'b', name: 'b', meta: { type: 'number' } }] as DatatableColumn[]; + expect(mapVariableToColumn(esql, variables, columns)).toStrictEqual(columns); + }); + + it('should return the columns as they are if variables do not match', () => { + const esql = 'FROM logstash-* | STATS COUNT(*) BY ?field | LIMIT 10'; + const variables = [ + { + key: 'interval', + value: '5 minutes', + type: ESQLVariableType.TIME_LITERAL, + }, + ]; + const columns = [ + { + id: 'COUNT(*)', + name: 'COUNT(*)', + meta: { + type: 'number', + esType: 'long', + sourceParams: { + indexPattern: 'logstash-*', + }, + }, + isNull: false, + }, + { + id: 'clientip', + name: 'clientip', + meta: { + type: 'ip', + esType: 'ip', + sourceParams: { + indexPattern: 'logstash-*', + }, + }, + isNull: false, + }, + ] as DatatableColumn[]; + expect(mapVariableToColumn(esql, variables, columns)).toStrictEqual(columns); + }); + + it('should return the columns enhanced with the corresponsing variables for a field type variable', () => { + const esql = 'FROM logstash-* | STATS COUNT(*) BY ?field | LIMIT 10 '; + const variables = [ + { + key: 'field', + value: 'clientip', + type: ESQLVariableType.FIELDS, + }, + { + key: 'interval', + value: '5 minutes', + type: ESQLVariableType.TIME_LITERAL, + }, + { + key: 'agent_name', + value: 'go', + type: ESQLVariableType.VALUES, + }, + ]; + const columns = [ + { + id: 'COUNT(*)', + name: 'COUNT(*)', + meta: { + type: 'number', + esType: 'long', + sourceParams: { + indexPattern: 'logstash-*', + }, + }, + isNull: false, + }, + { + id: 'clientip', + name: 'clientip', + meta: { + type: 'ip', + esType: 'ip', + sourceParams: { + indexPattern: 'logstash-*', + }, + }, + isNull: false, + }, + ] as DatatableColumn[]; + const expectedColumns = columns; + expectedColumns[1].variable = 'field'; + expect(mapVariableToColumn(esql, variables, columns)).toStrictEqual(expectedColumns); + }); + + it('should return the columns enhanced with the corresponsing variables for a time_literal type variable', () => { + const esql = 'FROM logs* | STATS COUNT(*) BY BUCKET(@timestamp, ?interval)'; + const variables = [ + { + key: 'field', + value: 'clientip', + type: ESQLVariableType.FIELDS, + }, + { + key: 'interval', + value: '5 minutes', + type: ESQLVariableType.TIME_LITERAL, + }, + { + key: 'agent_name', + value: 'go', + type: ESQLVariableType.VALUES, + }, + ]; + const columns = [ + { + id: 'COUNT(*)', + name: 'COUNT(*)', + meta: { + type: 'number', + esType: 'long', + sourceParams: { + indexPattern: 'logs*', + }, + }, + isNull: false, + }, + { + id: 'BUCKET(@timestamp, ?interval)', + name: 'BUCKET(@timestamp, ?interval)', + meta: { + type: 'date', + esType: 'date', + sourceParams: { + appliedTimeRange: { + from: 'now-30d/d', + to: 'now', + }, + params: {}, + indexPattern: 'logs*', + }, + }, + isNull: false, + }, + ] as DatatableColumn[]; + const expectedColumns = columns; + expectedColumns[1].variable = 'interval'; + expect(mapVariableToColumn(esql, variables, columns)).toStrictEqual(expectedColumns); + }); + + it('should return the columns enhanced with the corresponsing variables for a values type variable', () => { + const esql = 'FROM logs* | WHERE agent.name == ?agent_name'; + const variables = [ + { + key: 'field', + value: 'clientip', + type: ESQLVariableType.FIELDS, + }, + { + key: 'interval', + value: '5 minutes', + type: ESQLVariableType.TIME_LITERAL, + }, + { + key: 'agent_name', + value: 'go', + type: ESQLVariableType.VALUES, + }, + ]; + const columns = [ + { + id: '@timestamp', + isNull: false, + meta: { type: 'date', esType: 'date' }, + name: '@timestamp', + }, + { + id: 'agent.name', + isNull: false, + meta: { type: 'string', esType: 'keyword' }, + name: 'agent.name', + }, + ] as DatatableColumn[]; + const expectedColumns = columns; + expectedColumns[1].variable = 'agent_name'; + expect(mapVariableToColumn(esql, variables, columns)).toStrictEqual(expectedColumns); + }); + + it('should return the columns as they are if the variable field is dropped', () => { + const esql = 'FROM logs* | WHERE agent.name == ?agent_name | DROP agent.name'; + const variables = [ + { + key: 'field', + value: 'clientip', + type: ESQLVariableType.FIELDS, + }, + { + key: 'interval', + value: '5 minutes', + type: ESQLVariableType.TIME_LITERAL, + }, + { + key: 'agent_name', + value: 'go', + type: ESQLVariableType.VALUES, + }, + ]; + const columns = [ + { + id: '@timestamp', + isNull: false, + meta: { type: 'date', esType: 'date' }, + name: '@timestamp', + }, + ] as DatatableColumn[]; + expect(mapVariableToColumn(esql, variables, columns)).toStrictEqual(columns); + }); + + it('should return the columns correctly if variable is used in KEEP', () => { + const esql = 'FROM logstash-* | KEEP bytes, ?field'; + const variables = [ + { + key: 'field', + value: 'clientip', + type: ESQLVariableType.FIELDS, + }, + { + key: 'interval', + value: '5 minutes', + type: ESQLVariableType.TIME_LITERAL, + }, + { + key: 'agent_name', + value: 'go', + type: ESQLVariableType.VALUES, + }, + ]; + const columns = [ + { + id: 'bytes', + isNull: false, + meta: { type: 'number', esType: 'long' }, + name: 'bytes', + }, + { + id: 'clientip', + name: 'clientip', + meta: { + type: 'ip', + esType: 'ip', + sourceParams: { + indexPattern: 'logstash-*', + }, + }, + isNull: false, + }, + ] as DatatableColumn[]; + const expectedColumns = columns; + expectedColumns[1].variable = 'field'; + expect(mapVariableToColumn(esql, variables, columns)).toStrictEqual(expectedColumns); + }); + }); }); diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts index ccad80f064a03..64fc9eabe7836 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts @@ -15,6 +15,8 @@ import type { ESQLSingleAstItem, ESQLCommandOption, } from '@kbn/esql-ast'; +import type { ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; const DEFAULT_ESQL_LIMIT = 1000; @@ -147,3 +149,34 @@ export const getQueryColumnsFromESQLQuery = (esql: string): string[] => { return columns.map((column) => column.name); }; +/** + * This function is used to map the variables to the columns in the datatable + * @param esql:string + * @param variables:ESQLControlVariable[] + * @param columns:DatatableColumn[] + * @returns DatatableColumn[] + */ +export const mapVariableToColumn = ( + esql: string, + variables: ESQLControlVariable[], + columns: DatatableColumn[] +): DatatableColumn[] => { + if (!variables.length) { + return columns; + } + const { root } = parse(esql); + const usedVariablesInQuery = Walker.params(root); + + const uniqueVariablesInQyery = new Set( + usedVariablesInQuery.map((v) => v.text.replace('?', '')) + ); + + columns.map((column) => { + if (variables.some((variable) => variable.value === column.id)) { + const potentialColumnVariables = variables.filter((variable) => variable.value === column.id); + const variable = potentialColumnVariables.find((v) => uniqueVariablesInQyery.has(v.key)); + column.variable = variable?.key ?? ''; + } + }); + return columns; +}; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/run_query.test.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/run_query.test.ts index 8618d078f5e06..9c8c52dae176f 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/run_query.test.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/run_query.test.ts @@ -6,39 +6,135 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ +import { ESQLVariableType, type ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; +import { getStartEndParams, getNamedParams } from './run_query'; -import { getStartEndParams } from './run_query'; +describe('run query helpers', () => { + describe('getStartEndParams', () => { + it('should return an empty array if there are no time params', () => { + const time = { from: 'now-15m', to: 'now' }; + const query = 'FROM foo'; + const params = getStartEndParams(query, time); + expect(params).toEqual([]); + }); -describe('getStartEndParams', () => { - it('should return an empty array if there are no time params', () => { - const time = { from: 'now-15m', to: 'now' }; - const query = 'FROM foo'; - const params = getStartEndParams(query, time); - expect(params).toEqual([]); - }); + it('should return an array with the start param if exists at the query', () => { + const time = { from: 'Jul 5, 2024 @ 08:03:56.849', to: 'Jul 5, 2024 @ 10:03:56.849' }; + const query = 'FROM foo | where time > ?_tstart'; + const params = getStartEndParams(query, time); + expect(params).toHaveLength(1); + expect(params[0]).toHaveProperty('_tstart'); + }); - it('should return an array with the start param if exists at the query', () => { - const time = { from: 'Jul 5, 2024 @ 08:03:56.849', to: 'Jul 5, 2024 @ 10:03:56.849' }; - const query = 'FROM foo | where time > ?_tstart'; - const params = getStartEndParams(query, time); - expect(params).toHaveLength(1); - expect(params[0]).toHaveProperty('_tstart'); - }); + it('should return an array with the end param if exists at the query', () => { + const time = { from: 'Jul 5, 2024 @ 08:03:56.849', to: 'Jul 5, 2024 @ 10:03:56.849' }; + const query = 'FROM foo | where time < ?_tend'; + const params = getStartEndParams(query, time); + expect(params).toHaveLength(1); + expect(params[0]).toHaveProperty('_tend'); + }); - it('should return an array with the end param if exists at the query', () => { - const time = { from: 'Jul 5, 2024 @ 08:03:56.849', to: 'Jul 5, 2024 @ 10:03:56.849' }; - const query = 'FROM foo | where time < ?_tend'; - const params = getStartEndParams(query, time); - expect(params).toHaveLength(1); - expect(params[0]).toHaveProperty('_tend'); + it('should return an array with the end and start params if exist at the query', () => { + const time = { from: 'Jul 5, 2024 @ 08:03:56.849', to: 'Jul 5, 2024 @ 10:03:56.849' }; + const query = 'FROM foo | where time < ?_tend amd time > ?_tstart'; + const params = getStartEndParams(query, time); + expect(params).toHaveLength(2); + expect(params[0]).toHaveProperty('_tstart'); + expect(params[1]).toHaveProperty('_tend'); + }); }); - it('should return an array with the end and start params if exist at the query', () => { - const time = { from: 'Jul 5, 2024 @ 08:03:56.849', to: 'Jul 5, 2024 @ 10:03:56.849' }; - const query = 'FROM foo | where time < ?_tend amd time > ?_tstart'; - const params = getStartEndParams(query, time); - expect(params).toHaveLength(2); - expect(params[0]).toHaveProperty('_tstart'); - expect(params[1]).toHaveProperty('_tend'); + describe('getNamedParams', () => { + it('should return an empty array if there are no params', () => { + const time = { from: 'now-15m', to: 'now' }; + const query = 'FROM foo'; + const variables: ESQLControlVariable[] = []; + const params = getNamedParams(query, time, variables); + expect(params).toEqual([]); + }); + + it('should return the time params if given', () => { + const time = { from: 'Jul 5, 2024 @ 08:03:56.849', to: 'Jul 5, 2024 @ 10:03:56.849' }; + const query = 'FROM foo | where time < ?_tend amd time > ?_tstart'; + const variables: ESQLControlVariable[] = []; + const params = getNamedParams(query, time, variables); + expect(params).toHaveLength(2); + expect(params[0]).toHaveProperty('_tstart'); + expect(params[1]).toHaveProperty('_tend'); + }); + + it('should return the variables if given', () => { + const time = { from: 'Jul 5, 2024 @ 08:03:56.849', to: 'Jul 5, 2024 @ 10:03:56.849' }; + const query = 'FROM foo | KEEP ?field | WHERE agent.name = ?agent_name'; + const variables = [ + { + key: 'field', + value: 'clientip', + type: ESQLVariableType.FIELDS, + }, + { + key: 'interval', + value: '5 minutes', + type: ESQLVariableType.TIME_LITERAL, + }, + { + key: 'agent_name', + value: 'go', + type: ESQLVariableType.VALUES, + }, + ]; + const params = getNamedParams(query, time, variables); + expect(params).toStrictEqual([ + { + field: { + identifier: 'clientip', + }, + }, + { + interval: '5 minutes', + }, + { + agent_name: 'go', + }, + ]); + }); + + it('should return the variables and named params if given', () => { + const time = { from: 'Jul 5, 2024 @ 08:03:56.849', to: 'Jul 5, 2024 @ 10:03:56.849' }; + const query = + 'FROM foo | KEEP ?field | WHERE agent.name = ?agent_name AND time < ?_tend amd time > ?_tstart'; + const variables = [ + { + key: 'field', + value: 'clientip', + type: ESQLVariableType.FIELDS, + }, + { + key: 'interval', + value: '5 minutes', + type: ESQLVariableType.TIME_LITERAL, + }, + { + key: 'agent_name', + value: 'go', + type: ESQLVariableType.VALUES, + }, + ]; + const params = getNamedParams(query, time, variables); + expect(params).toHaveLength(5); + expect(params[0]).toHaveProperty('_tstart'); + expect(params[1]).toHaveProperty('_tend'); + expect(params[2]).toStrictEqual({ + field: { + identifier: 'clientip', + }, + }); + expect(params[3]).toStrictEqual({ + interval: '5 minutes', + }); + expect(params[4]).toStrictEqual({ + agent_name: 'go', + }); + }); }); }); diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/run_query.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/run_query.ts index b9b09336a7c20..3a413034e3ab3 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/run_query.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/run_query.ts @@ -15,6 +15,7 @@ import type { TimeRange } from '@kbn/es-query'; import { esFieldTypeToKibanaFieldType } from '@kbn/field-types'; import type { ESQLColumn, ESQLSearchResponse, ESQLSearchParams } from '@kbn/es-types'; import { lastValueFrom } from 'rxjs'; +import { type ESQLControlVariable, ESQLVariableType } from '@kbn/esql-validation-autocomplete'; export const hasStartEndParams = (query: string) => /\?_tstart|\?_tend/i.test(query); @@ -38,6 +39,24 @@ export const getStartEndParams = (query: string, time?: TimeRange) => { return []; }; +export const getNamedParams = ( + query: string, + timeRange?: TimeRange, + variables?: ESQLControlVariable[] +) => { + const namedParams: ESQLSearchParams['params'] = getStartEndParams(query, timeRange); + if (variables?.length) { + variables?.forEach(({ key, value, type }) => { + if (type === ESQLVariableType.FIELDS) { + namedParams.push({ [key]: { identifier: value } }); + } else { + namedParams.push({ [key]: value }); + } + }); + } + return namedParams; +}; + export function formatESQLColumns(columns: ESQLColumn[]): DatatableColumn[] { return columns.map(({ name, type }) => { const kibanaType = esFieldTypeToKibanaFieldType(type); @@ -126,6 +145,7 @@ export async function getESQLResults({ filter, dropNullColumns, timeRange, + variables, }: { esqlQuery: string; search: ISearchGeneric; @@ -133,11 +153,12 @@ export async function getESQLResults({ filter?: unknown; dropNullColumns?: boolean; timeRange?: TimeRange; + variables?: ESQLControlVariable[]; }): Promise<{ response: ESQLSearchResponse; params: ESQLSearchParams; }> { - const namedParams = getStartEndParams(esqlQuery, timeRange); + const namedParams = getNamedParams(esqlQuery, timeRange, variables); const result = await lastValueFrom( search( { diff --git a/src/platform/packages/shared/kbn-esql-utils/tsconfig.json b/src/platform/packages/shared/kbn-esql-utils/tsconfig.json index c57e474c9a248..3c45073c44be8 100644 --- a/src/platform/packages/shared/kbn-esql-utils/tsconfig.json +++ b/src/platform/packages/shared/kbn-esql-utils/tsconfig.json @@ -26,6 +26,7 @@ "@kbn/es-types", "@kbn/i18n", "@kbn/datemath", - "@kbn/es-query" + "@kbn/es-query", + "@kbn/esql-validation-autocomplete" ] } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/index.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/index.ts index 67fdd72880150..1ce5be2e69ffa 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/index.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/index.ts @@ -8,6 +8,8 @@ */ export type { SuggestionRawDefinition, ItemKind } from './src/autocomplete/types'; +export { ESQLVariableType, type ESQLControlVariable } from './src/shared/types'; +export { inKnownTimeInterval } from './src/shared/helpers'; export type { CodeAction } from './src/code_actions/types'; export type { FunctionDefinition, diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts index 5234e93c159e2..8ae8e5ecb9832 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts @@ -9,6 +9,7 @@ import { FieldType, FunctionReturnType } from '../../definitions/types'; import { ESQL_COMMON_NUMERIC_TYPES, ESQL_NUMBER_TYPES } from '../../shared/esql_types'; +import { ESQLVariableType } from '../../shared/types'; import { getDateHistogramCompletionItem } from '../commands/stats/util'; import { allStarConstant } from '../complete_items'; import { roundParameterTypes } from './constants'; @@ -357,6 +358,83 @@ describe('autocomplete.suggest', () => { expect(suggestions).toContainEqual(expectedCompletionItem); }); }); + + describe('create control suggestion', () => { + test('suggests `Create control` option', async () => { + const { suggest } = await setup(); + + const suggestions = await suggest('FROM a | STATS BY /', { + callbacks: { + canSuggestVariables: () => true, + getVariablesByType: () => [], + getColumnsFor: () => Promise.resolve([{ name: 'clientip', type: 'ip' }]), + }, + }); + + expect(suggestions).toContainEqual({ + label: 'Create control', + text: '', + kind: 'Issue', + detail: 'Click to create', + command: { id: 'esql.control.fields.create', title: 'Click to create' }, + sortText: '11A', + }); + }); + + test('suggests `?field` option', async () => { + const { suggest } = await setup(); + + const suggestions = await suggest('FROM a | STATS BY /', { + callbacks: { + canSuggestVariables: () => true, + getVariablesByType: () => [ + { + key: 'field', + value: 'clientip', + type: ESQLVariableType.FIELDS, + }, + ], + getColumnsFor: () => Promise.resolve([{ name: 'clientip', type: 'ip' }]), + }, + }); + + expect(suggestions).toContainEqual({ + label: '?field', + text: '?field', + kind: 'Constant', + detail: 'Named parameter', + command: undefined, + sortText: '11A', + }); + }); + + test('suggests `?interval` option', async () => { + const { suggest } = await setup(); + + const suggestions = await suggest('FROM a | STATS BY BUCKET(@timestamp, /)', { + callbacks: { + canSuggestVariables: () => true, + getVariablesByType: () => [ + { + key: 'interval', + value: '1 hour', + type: ESQLVariableType.TIME_LITERAL, + }, + ], + getColumnsFor: () => Promise.resolve([{ name: '@timestamp', type: 'date' }]), + }, + }); + + expect(suggestions).toContainEqual({ + label: '?interval', + text: '?interval', + kind: 'Constant', + detail: 'Named parameter', + command: undefined, + sortText: '1A', + }); + }); + }); }); }); }); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts index 3931480d739a4..30a6b0b9498db 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts @@ -8,6 +8,7 @@ */ import { ESQL_COMMON_NUMERIC_TYPES } from '../../shared/esql_types'; +import { ESQLVariableType } from '../../shared/types'; import { pipeCompleteItem } from '../complete_items'; import { getDateLiterals } from '../factories'; import { log10ParameterTypes, powParameterTypes } from './constants'; @@ -330,5 +331,57 @@ describe('WHERE ', () => { }) ); }); + + describe('create control suggestion', () => { + test('suggests `Create control` option', async () => { + const { suggest } = await setup(); + + const suggestions = await suggest('FROM a | WHERE agent.name == /', { + callbacks: { + canSuggestVariables: () => true, + getVariablesByType: () => [], + getColumnsFor: () => Promise.resolve([{ name: 'agent.name', type: 'keyword' }]), + }, + }); + + expect(suggestions).toContainEqual({ + label: 'Create control', + text: '', + kind: 'Issue', + detail: 'Click to create', + command: { id: 'esql.control.values.create', title: 'Click to create' }, + sortText: '11A', + rangeToReplace: { start: 31, end: 31 }, + }); + }); + + test('suggests `?value` option', async () => { + const { suggest } = await setup(); + + const suggestions = await suggest('FROM a | WHERE agent.name == /', { + callbacks: { + canSuggestVariables: () => true, + getVariablesByType: () => [ + { + key: 'value', + value: 'java', + type: ESQLVariableType.VALUES, + }, + ], + getColumnsFor: () => Promise.resolve([{ name: 'agent.name', type: 'keyword' }]), + }, + }); + + expect(suggestions).toContainEqual({ + label: '?value', + text: '?value', + kind: 'Constant', + detail: 'Named parameter', + command: undefined, + sortText: '11A', + rangeToReplace: { start: 31, end: 31 }, + }); + }); + }); }); }); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index 5e4140e407c9a..596efa7fb8677 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -89,7 +89,12 @@ import { getPolicyHelper, getSourcesHelper, } from '../shared/resources_helpers'; -import { ESQLCallbacks, ESQLSourceResult } from '../shared/types'; +import type { + ESQLCallbacks, + ESQLSourceResult, + ESQLControlVariable, + ESQLVariableType, +} from '../shared/types'; import { getFunctionsToIgnoreForStats, getQueryForFields, @@ -176,6 +181,8 @@ export async function suggest( queryForFields.replace(EDITOR_MARKER, ''), resourceRetriever ); + const supportsControls = resourceRetriever?.canSuggestVariables?.() ?? false; + const getVariablesByType = resourceRetriever?.getVariablesByType; const getSources = getSourcesHelper(resourceRetriever); const { getPolicies, getPolicyMetadata } = getPolicyRetriever(resourceRetriever); @@ -258,9 +265,10 @@ export async function suggest( astContext, getFieldsByType, getFieldsMap, - getPolicyMetadata, fullText, - offset + offset, + getVariablesByType, + supportsControls ); } if (astContext.type === 'list') { @@ -281,14 +289,20 @@ export function getFieldsByTypeRetriever( resourceRetriever?: ESQLCallbacks ): { getFieldsByType: GetColumnsByTypeFn; getFieldsMap: GetFieldsMapFn } { const helpers = getFieldsByTypeHelper(queryString, resourceRetriever); + const getVariablesByType = resourceRetriever?.getVariablesByType; + const supportsControls = resourceRetriever?.canSuggestVariables?.() ?? false; return { getFieldsByType: async ( expectedType: string | string[] = 'any', ignored: string[] = [], options ) => { + const updatedOptions = { + ...options, + supportsControls, + }; const fields = await helpers.getFieldsByType(expectedType, ignored); - return buildFieldsDefinitionsWithMetadata(fields, options); + return buildFieldsDefinitionsWithMetadata(fields, updatedOptions, getVariablesByType); }, getFieldsMap: helpers.getFieldsMap, }; @@ -1041,9 +1055,10 @@ async function getFunctionArgsSuggestions( }, getFieldsByType: GetColumnsByTypeFn, getFieldsMap: GetFieldsMapFn, - getPolicyMetadata: GetPolicyMetadataFn, fullText: string, - offset: number + offset: number, + getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined, + supportsControls?: boolean ): Promise { const fnDefinition = getFunctionDefinition(node.name); // early exit on no hit @@ -1165,7 +1180,12 @@ async function getFunctionArgsSuggestions( command.name, getTypesFromParamDefs(constantOnlyParamDefs) as string[], undefined, - { addComma: shouldAddComma, advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs } + { + addComma: shouldAddComma, + advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs, + supportsControls, + }, + getVariablesByType ) ); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts index 030c02ad1b81a..6df06d613d206 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts @@ -26,8 +26,10 @@ import { buildFunctionDocumentation } from './documentation_util'; import { DOUBLE_BACKTICK, SINGLE_TICK_REGEX } from '../shared/constants'; import { ESQLRealField } from '../validation/types'; import { isNumericType } from '../shared/esql_types'; +import type { ESQLControlVariable } from '../shared/types'; import { getTestFunctions } from '../shared/test_functions'; import { builtinFunctions } from '../definitions/builtin'; +import { ESQLVariableType } from '../shared/types'; const techPreviewLabel = i18n.translate( 'kbn-esql-validation-autocomplete.esql.autocomplete.techPreviewLabel', @@ -194,9 +196,16 @@ export const getSuggestionsAfterNot = (): SuggestionRawDefinition[] => { export const buildFieldsDefinitionsWithMetadata = ( fields: ESQLRealField[], - options?: { advanceCursor?: boolean; openSuggestions?: boolean; addComma?: boolean } + options?: { + advanceCursor?: boolean; + openSuggestions?: boolean; + addComma?: boolean; + variableType?: ESQLVariableType; + supportsControls?: boolean; + }, + getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined ): SuggestionRawDefinition[] => { - return fields.map((field) => { + const fieldsSuggestions = fields.map((field) => { const titleCaseType = field.type.charAt(0).toUpperCase() + field.type.slice(1); return { label: field.name, @@ -210,7 +219,23 @@ export const buildFieldsDefinitionsWithMetadata = ( sortText: field.isEcs ? '1D' : 'D', command: options?.openSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined, }; - }); + }) as SuggestionRawDefinition[]; + + const suggestions = [...fieldsSuggestions]; + if (options?.supportsControls) { + const variableType = options?.variableType ?? ESQLVariableType.FIELDS; + const variables = getVariablesByType?.(variableType) ?? []; + + const controlSuggestions = fields.length + ? getControlSuggestion( + variableType, + variables?.map((v) => `?${v.key}`) + ) + : []; + suggestions.push(...controlSuggestions); + } + + return [...suggestions]; }; export const buildFieldsDefinitions = (fields: string[]): SuggestionRawDefinition[] => { @@ -436,7 +461,12 @@ export function getCompatibleLiterals( commandName: string, types: string[], names?: string[], - options?: { advanceCursorAndOpenSuggestions?: boolean; addComma?: boolean } + options?: { + advanceCursorAndOpenSuggestions?: boolean; + addComma?: boolean; + supportsControls?: boolean; + }, + getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined ) { const suggestions: SuggestionRawDefinition[] = []; if (types.some(isNumericType)) { @@ -450,10 +480,20 @@ export function getCompatibleLiterals( } } if (types.includes('time_literal')) { + const timeLiteralSuggestions = [ + ...buildConstantsDefinitions(getUnitDuration(1), undefined, undefined, options), + ]; + if (options?.supportsControls) { + const variables = getVariablesByType?.(ESQLVariableType.TIME_LITERAL) ?? []; + timeLiteralSuggestions.push( + ...getControlSuggestion( + ESQLVariableType.TIME_LITERAL, + variables.map((v) => `?${v.key}`) + ) + ); + } // filter plural for now and suggest only unit + singular - suggestions.push( - ...buildConstantsDefinitions(getUnitDuration(1), undefined, undefined, options) - ); // i.e. 1 year + suggestions.push(...timeLiteralSuggestions); // i.e. 1 year } // this is a special type built from the suggestion system, not inherited from the AST if (types.includes('time_literal_unit')) { @@ -543,3 +583,49 @@ export function getDateLiterals(options?: { } as SuggestionRawDefinition, ]; } + +export function getControlSuggestion( + type: ESQLVariableType, + variables?: string[] +): SuggestionRawDefinition[] { + return [ + { + label: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.autocomplete.createControlLabel', + { + defaultMessage: 'Create control', + } + ), + text: '', + kind: 'Issue', + detail: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.autocomplete.createControlDetailLabel', + { + defaultMessage: 'Click to create', + } + ), + sortText: '1A', + command: { + id: `esql.control.${type}.create`, + title: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.autocomplete.createControlDetailLabel', + { + defaultMessage: 'Click to create', + } + ), + }, + } as SuggestionRawDefinition, + ...(variables?.length + ? buildConstantsDefinitions( + variables, + i18n.translate( + 'kbn-esql-validation-autocomplete.esql.autocomplete.namedParamDefinition', + { + defaultMessage: 'Named parameter', + } + ), + '1A' + ) + : []), + ]; +} diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index 2fa1ce943cd33..716d3d8bb3250 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -48,6 +48,7 @@ import { EDITOR_MARKER } from '../shared/constants'; import { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types'; import { listCompleteItem } from './complete_items'; import { removeMarkerArgFromArgsList } from '../shared/context'; +import { ESQLVariableType } from '../shared/types'; function extractFunctionArgs(args: ESQLAstItem[]): ESQLFunction[] { return args.flatMap((arg) => (isAssignment(arg) ? arg.args[1] : arg)).filter(isFunctionItem); @@ -367,12 +368,14 @@ export async function getFieldsOrFunctionsSuggestions( functions, fields, variables, + values = false, literals = false, }: { functions: boolean; fields: boolean; variables?: Map; literals?: boolean; + values?: boolean; }, { ignoreFn = [], @@ -387,6 +390,7 @@ export async function getFieldsOrFunctionsSuggestions( ? getFieldsByType(types, ignoreColumns, { advanceCursor: commandName === 'sort', openSuggestions: commandName === 'sort', + variableType: values ? ESQLVariableType.VALUES : ESQLVariableType.FIELDS, }) : [])) as SuggestionRawDefinition[], functions @@ -617,6 +621,7 @@ export async function getSuggestionsToRightOfOperatorExpression({ { functions: true, fields: true, + values: Boolean(operator.subtype === 'binary-expression'), } )) ); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/types.ts index cbd6ead535932..c7b8396beaf6e 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/types.ts @@ -6,6 +6,7 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { ESQLVariableType } from '../shared/types'; // This is a subset of the Monaco's editor CompletitionItemKind type export type ItemKind = @@ -84,5 +85,10 @@ export interface EditorContext { export type GetColumnsByTypeFn = ( type: string | string[], ignored?: string[], - options?: { advanceCursor?: boolean; openSuggestions?: boolean; addComma?: boolean } + options?: { + advanceCursor?: boolean; + openSuggestions?: boolean; + addComma?: boolean; + variableType?: ESQLVariableType; + } ) => Promise; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts index 92ac10cb1c456..b877839fab42d 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts @@ -414,8 +414,8 @@ export function getAllArrayTypes( return types; } -export function inKnownTimeInterval(item: ESQLTimeInterval): boolean { - return timeUnits.some((unit) => unit === item.unit.toLowerCase()); +export function inKnownTimeInterval(timeIntervalUnit: string): boolean { + return timeUnits.some((unit) => unit === timeIntervalUnit.toLowerCase()); } /** @@ -464,7 +464,7 @@ export function checkFunctionArgMatchesDefinition( } } if (arg.type === 'timeInterval') { - return argType === 'time_literal' && inKnownTimeInterval(arg); + return argType === 'time_literal' && inKnownTimeInterval(arg.unit); } if (arg.type === 'column') { const hit = getColumnForASTNode(arg, references); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts index 5ff9285517b07..ca91a5df2996a 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts @@ -6,7 +6,6 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ - import type { ESQLRealField, JoinIndexAutocompleteItem } from '../validation/types'; /** @internal **/ @@ -37,6 +36,18 @@ export interface ESQLSourceResult { type?: string; } +export interface ESQLControlVariable { + key: string; + value: string | number; + type: ESQLVariableType; +} + +export enum ESQLVariableType { + TIME_LITERAL = 'time_literal', + FIELDS = 'fields', + VALUES = 'values', +} + export interface ESQLCallbacks { getSources?: CallbackFn<{}, ESQLSourceResult>; getColumnsFor?: CallbackFn<{ query: string }, ESQLRealField>; @@ -46,6 +57,8 @@ export interface ESQLCallbacks { >; getPreferences?: () => Promise<{ histogramBarTarget: number }>; getFieldsMetadata?: Promise; + getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined; + canSuggestVariables?: () => boolean; getJoinIndices?: () => Promise<{ indices: JoinIndexAutocompleteItem[] }>; } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts index 7da2d74a29fc6..c83ae10a8d3e7 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts @@ -1730,6 +1730,8 @@ describe('validation logic', () => { getColumnsFor: /Unknown column|Argument of|it is unsupported or not indexed/, getPreferences: /Unknown/, getFieldsMetadata: /Unknown/, + getVariablesByType: /Unknown/, + canSuggestVariables: /Unknown/, }; return excludedCallback.map((callback) => (contentByCallback as any)[callback]) || []; } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts index 1c3491b308cf7..423e85e297251 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts @@ -138,7 +138,7 @@ function validateFunctionLiteralArg( } if (isTimeIntervalItem(actualArg)) { // check first if it's a valid interval string - if (!inKnownTimeInterval(actualArg)) { + if (!inKnownTimeInterval(actualArg.unit)) { messages.push( getMessageFromId({ messageId: 'unknownInterval', @@ -1327,6 +1327,8 @@ export const ignoreErrorsMap: Record = { getPolicies: ['unknownPolicy'], getPreferences: [], getFieldsMetadata: [], + getVariablesByType: [], + canSuggestVariables: [], getJoinIndices: [], }; diff --git a/src/platform/packages/shared/kbn-esql-variables-types/README.md b/src/platform/packages/shared/kbn-esql-variables-types/README.md new file mode 100644 index 0000000000000..681bab7815a07 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-variables-types/README.md @@ -0,0 +1,3 @@ +# @kbn/esql-variables-types + +This package contains types important for the ES|QL variables. diff --git a/src/platform/packages/shared/kbn-esql-variables-types/index.ts b/src/platform/packages/shared/kbn-esql-variables-types/index.ts new file mode 100644 index 0000000000000..d43582602e0fe --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-variables-types/index.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; +import { PublishingSubject } from '@kbn/presentation-publishing'; + +/** + * This should all be moved into a package and reorganized into separate files etc + */ + +export interface PublishesESQLVariable { + esqlVariable$: PublishingSubject; +} + +export const apiPublishesESQLVariable = ( + unknownApi: unknown +): unknownApi is PublishesESQLVariable => { + return Boolean(unknownApi && (unknownApi as PublishesESQLVariable)?.esqlVariable$ !== undefined); +}; + +export interface PublishesESQLVariables { + esqlVariables$: PublishingSubject; +} + +export const apiPublishesESQLVariables = ( + unknownApi: unknown +): unknownApi is PublishesESQLVariables => { + return Boolean( + unknownApi && (unknownApi as PublishesESQLVariables)?.esqlVariables$ !== undefined + ); +}; diff --git a/src/platform/packages/shared/kbn-esql-variables-types/kibana.jsonc b/src/platform/packages/shared/kbn-esql-variables-types/kibana.jsonc new file mode 100644 index 0000000000000..7bbcf074645c1 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-variables-types/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-browser", + "id": "@kbn/esql-variables-types", + "owner": [ + "@elastic/kibana-esql", + ], + "group": "platform", + "visibility": "shared" +} \ No newline at end of file diff --git a/src/platform/packages/shared/kbn-esql-variables-types/package.json b/src/platform/packages/shared/kbn-esql-variables-types/package.json new file mode 100644 index 0000000000000..743099b750ff3 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-variables-types/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/esql-variables-types", + "private": true, + "version": "1.0.0", + "author": "Kibana ES|QL", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/src/platform/packages/shared/kbn-esql-variables-types/tsconfig.json b/src/platform/packages/shared/kbn-esql-variables-types/tsconfig.json new file mode 100644 index 0000000000000..242fd33180e6a --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-variables-types/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*", + ], + "kbn_references": [ + "@kbn/esql-validation-autocomplete", + "@kbn/presentation-publishing", + ] +} diff --git a/src/platform/plugins/shared/controls/common/constants.ts b/src/platform/plugins/shared/controls/common/constants.ts index afd6fe66f0df1..d1f88567a3c6e 100644 --- a/src/platform/plugins/shared/controls/common/constants.ts +++ b/src/platform/plugins/shared/controls/common/constants.ts @@ -30,3 +30,5 @@ export const DEFAULT_AUTO_APPLY_SELECTIONS = true; export const TIME_SLIDER_CONTROL = 'timeSlider'; export const RANGE_SLIDER_CONTROL = 'rangeSliderControl'; export const OPTIONS_LIST_CONTROL = 'optionsListControl'; + +export const ESQL_CONTROL = 'esqlControl'; diff --git a/src/platform/plugins/shared/controls/common/index.ts b/src/platform/plugins/shared/controls/common/index.ts index 031d3b348272f..1fd51fd92be9b 100644 --- a/src/platform/plugins/shared/controls/common/index.ts +++ b/src/platform/plugins/shared/controls/common/index.ts @@ -29,6 +29,7 @@ export { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, TIME_SLIDER_CONTROL, + ESQL_CONTROL, } from './constants'; export { CONTROL_GROUP_TYPE } from './control_group'; diff --git a/src/platform/plugins/shared/controls/kibana.jsonc b/src/platform/plugins/shared/controls/kibana.jsonc index 76fb9f7960412..17e6e7c6b55a3 100644 --- a/src/platform/plugins/shared/controls/kibana.jsonc +++ b/src/platform/plugins/shared/controls/kibana.jsonc @@ -17,9 +17,8 @@ "dataViews", "data", "unifiedSearch", - "uiActions" + "uiActions", ], - "requiredBundles": [], "extraPublicDirs": [ "common" ] diff --git a/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx b/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx index eea50462ddc11..fa943d2504c44 100644 --- a/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx @@ -7,12 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import fastIsEqual from 'fast-deep-equal'; -import React, { useEffect } from 'react'; -import { BehaviorSubject } from 'rxjs'; - import { DataView } from '@kbn/data-views-plugin/common'; import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; +import { PublishesESQLVariable, apiPublishesESQLVariable } from '@kbn/esql-variables-types'; import { i18n } from '@kbn/i18n'; import { apiHasSaveNotification, @@ -24,7 +22,9 @@ import { useBatchedPublishingSubjects, } from '@kbn/presentation-publishing'; import { apiPublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload'; - +import fastIsEqual from 'fast-deep-equal'; +import React, { useEffect } from 'react'; +import { BehaviorSubject } from 'rxjs'; import type { ControlGroupChainingSystem, ControlGroupRuntimeState, @@ -85,6 +85,7 @@ export const getControlGroupEmbeddableFactory = () => { ...controlsManager.api, autoApplySelections$, }); + const esqlVariables$ = new BehaviorSubject([]); const dataViews$ = new BehaviorSubject(undefined); const chainingSystem$ = new BehaviorSubject( chainingSystem ?? DEFAULT_CONTROL_CHAINING @@ -130,6 +131,7 @@ export const getControlGroupEmbeddableFactory = () => { const api = setApi({ ...controlsManager.api, + esqlVariables$, disabledActionIds$, ...unsavedChanges.api, ...selectionsManager.api, @@ -231,6 +233,14 @@ export const getControlGroupEmbeddableFactory = () => { dataViews$.next(newDataViews) ); + /** Combine ESQL variables from all children that publish them. */ + const childrenESQLVariablesSubscription = combineCompatibleChildrenApis< + PublishesESQLVariable, + ESQLControlVariable[] + >(api, 'esqlVariable$', apiPublishesESQLVariable, []).subscribe((newESQLVariables) => { + esqlVariables$.next(newESQLVariables); + }); + const saveNotificationSubscription = apiHasSaveNotification(parentApi) ? parentApi.saveNotification$.subscribe(() => { lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState()); @@ -275,6 +285,7 @@ export const getControlGroupEmbeddableFactory = () => { return () => { selectionsManager.cleanup(); childrenDataViewsSubscription.unsubscribe(); + childrenESQLVariablesSubscription.unsubscribe(); saveNotificationSubscription?.unsubscribe(); }; }, []); diff --git a/src/platform/plugins/shared/controls/public/control_group/types.ts b/src/platform/plugins/shared/controls/public/control_group/types.ts index 6453e8b6d4a64..d2c7dafc971d7 100644 --- a/src/platform/plugins/shared/controls/public/control_group/types.ts +++ b/src/platform/plugins/shared/controls/public/control_group/types.ts @@ -10,6 +10,7 @@ import type { Observable } from 'rxjs'; import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import { PublishesESQLVariables } from '@kbn/esql-variables-types'; import { Filter } from '@kbn/es-query'; import { HasSaveNotification, @@ -51,6 +52,7 @@ export type ControlGroupApi = PresentationContainer & DefaultEmbeddableApi & PublishesFilters & PublishesDataViews & + PublishesESQLVariables & HasSerializedChildState & HasEditCapabilities & Pick, 'unsavedChanges$'> & 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 new file mode 100644 index 0000000000000..f30ed60492318 --- /dev/null +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/esql_control_selections.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import deepEqual from 'react-fast-compare'; +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import { ESQLControlVariable, ESQLVariableType } from '@kbn/esql-validation-autocomplete'; +import type { ESQLControlState } from '@kbn/esql/public'; + +export function initializeESQLControlSelections(initialState: ESQLControlState) { + 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 variableName$ = new BehaviorSubject(initialState.variableName ?? ''); + const variableType$ = new BehaviorSubject( + initialState.variableType ?? ESQLVariableType.VALUES + ); + const controlType$ = new BehaviorSubject(initialState.controlType ?? ''); + const esqlQuery$ = new BehaviorSubject(initialState.esqlQuery ?? ''); + const title$ = new BehaviorSubject(initialState.title); + + const selectedOptionsComparatorFunction = (a: string[], b: string[]) => + deepEqual(a ?? [], b ?? []); + + function setSelectedOptions(next: string[]) { + if (!selectedOptionsComparatorFunction(selectedOptions$.value, next)) { + selectedOptions$.next(next); + } + } + + // derive ESQL control variable from state. + const getEsqlVariable = () => ({ + key: variableName$.value, + value: selectedOptions$.value[0], + type: variableType$.value, + }); + const esqlVariable$ = new BehaviorSubject(getEsqlVariable()); + const subscriptions = combineLatest([variableName$, variableType$, selectedOptions$]).subscribe( + () => esqlVariable$.next(getEsqlVariable()) + ); + + return { + cleanup: () => subscriptions.unsubscribe(), + api: { + hasSelections$: hasSelections$ as PublishingSubject, + esqlVariable$: esqlVariable$ as PublishingSubject, + }, + comparators: { + selectedOptions: [selectedOptions$, setSelectedOptions, selectedOptionsComparatorFunction], + availableOptions: [availableOptions$, (next) => availableOptions$.next(next)], + variableName: [variableName$, (next) => variableName$.next(next)], + variableType: [variableType$, (next) => variableType$.next(next)], + controlType: [controlType$, (next) => controlType$.next(next)], + esqlQuery: [esqlQuery$, (next) => esqlQuery$.next(next)], + title: [title$, (next) => title$.next(next)], + } as StateComparators< + Pick< + ESQLControlState, + | 'selectedOptions' + | 'availableOptions' + | 'variableName' + | 'variableType' + | 'controlType' + | 'esqlQuery' + | 'title' + > + >, + hasInitialSelections: initialState.selectedOptions?.length, + selectedOptions$: selectedOptions$ as PublishingSubject, + availableOptions$: availableOptions$ as PublishingSubject, + variableName$: variableName$ as PublishingSubject, + variableType$: variableType$ as PublishingSubject, + controlType$: controlType$ as PublishingSubject, + esqlQuery$: esqlQuery$ as PublishingSubject, + title$: title$ as PublishingSubject, + setSelectedOptions, + }; +} 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 new file mode 100644 index 0000000000000..a63ba69c823e4 --- /dev/null +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { StateComparators } from '@kbn/presentation-publishing'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import type { ESQLControlState } from '@kbn/esql/public'; +import { getMockedControlGroupApi } from '../mocks/control_mocks'; +import type { ControlApiRegistration } from '../types'; +import { getESQLControlFactory } from './get_esql_control_factory'; +import type { ESQLControlApi } from './types'; + +describe('ESQLControlApi', () => { + const uuid = 'myESQLControl'; + + const dashboardApi = {}; + const controlGroupApi = getMockedControlGroupApi(dashboardApi); + + const factory = getESQLControlFactory(); + function buildApiMock( + api: ControlApiRegistration, + nextComparators: StateComparators + ) { + return { + ...api, + uuid, + parentApi: controlGroupApi, + unsavedChanges$: new BehaviorSubject | undefined>(undefined), + resetUnsavedChanges: () => { + return true; + }, + type: factory.type, + }; + } + + test('Should publish ES|QL variable', async () => { + const initialState = { + selectedOptions: ['option1'], + availableOptions: ['option1', 'option2'], + variableName: 'variable1', + variableType: 'values', + esqlQuery: 'FROM foo | WHERE column = ?variable1', + controlType: 'STATIC_VALUES', + } as ESQLControlState; + const { api } = await factory.buildControl(initialState, buildApiMock, uuid, controlGroupApi); + expect(api.esqlVariable$.value).toStrictEqual({ + key: 'variable1', + type: 'values', + value: 'option1', + }); + }); + + test('Should serialize state', async () => { + const initialState = { + selectedOptions: ['option1'], + availableOptions: ['option1', 'option2'], + variableName: 'variable1', + variableType: 'values', + esqlQuery: 'FROM foo | WHERE column = ?variable1', + controlType: 'STATIC_VALUES', + } as ESQLControlState; + const { api } = await factory.buildControl(initialState, buildApiMock, uuid, controlGroupApi); + expect(api.serializeState()).toStrictEqual({ + rawState: { + availableOptions: ['option1', 'option2'], + controlType: 'STATIC_VALUES', + esqlQuery: 'FROM foo | WHERE column = ?variable1', + grow: undefined, + selectedOptions: ['option1'], + title: undefined, + variableName: 'variable1', + variableType: 'values', + width: undefined, + }, + references: [], + }); + }); + + test('changing the dropdown should publish new ES|QL variable', async () => { + const initialState = { + selectedOptions: ['option1'], + availableOptions: ['option1', 'option2'], + variableName: 'variable1', + variableType: 'values', + esqlQuery: 'FROM foo | WHERE column = ?variable1', + controlType: 'STATIC_VALUES', + } as ESQLControlState; + const { Component, api } = await factory.buildControl( + initialState, + buildApiMock, + uuid, + controlGroupApi + ); + + expect(api.esqlVariable$.value).toStrictEqual({ + key: 'variable1', + type: 'values', + value: 'option1', + }); + + const { findByTestId, findByTitle } = render(); + fireEvent.click(await findByTestId('comboBoxSearchInput')); + fireEvent.click(await findByTitle('option2')); + + await waitFor(() => { + expect(api.esqlVariable$.value).toStrictEqual({ + key: 'variable1', + type: 'values', + value: 'option2', + }); + }); + }); +}); 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 new file mode 100644 index 0000000000000..22fd8ccdb9c43 --- /dev/null +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/get_esql_control_factory.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { BehaviorSubject } from 'rxjs'; +import { css } from '@emotion/react'; +import { EuiComboBox } from '@elastic/eui'; +import { apiPublishesESQLVariables } from '@kbn/esql-variables-types'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import type { ESQLControlState } from '@kbn/esql/public'; +import { ESQL_CONTROL } from '../../../common'; +import type { ESQLControlApi } from './types'; +import { ControlFactory } from '../types'; +import { uiActionsService } from '../../services/kibana_services'; +import { initializeDefaultControlApi } from '../initialize_default_control_api'; +import { initializeESQLControlSelections } from './esql_control_selections'; + +const displayName = i18n.translate('controls.esqlValuesControl.displayName', { + defaultMessage: 'Static values list', +}); + +export const getESQLControlFactory = (): ControlFactory => { + return { + type: ESQL_CONTROL, + order: 3, + getIconType: () => 'editorChecklist', + getDisplayName: () => displayName, + buildControl: async (initialState, buildApi, uuid, controlGroupApi) => { + const defaultControl = initializeDefaultControlApi(initialState); + const selections = initializeESQLControlSelections(initialState); + + const onSaveControl = (updatedState: ESQLControlState) => { + controlGroupApi?.replacePanel(uuid, { + panelType: 'esqlControl', + initialState: updatedState, + }); + }; + + const api = buildApi( + { + ...defaultControl.api, + ...selections.api, + defaultTitle$: new BehaviorSubject(initialState.title), + isEditingEnabled: () => true, + getTypeDisplayName: () => displayName, + onEdit: async () => { + const state = { + ...initialState, + ...defaultControl.serialize().rawState, + }; + const variablesInParent = apiPublishesESQLVariables(api.parentApi) + ? api.parentApi.esqlVariables$.value + : []; + try { + await uiActionsService.getTrigger('ESQL_CONTROL_TRIGGER').exec({ + queryString: initialState.esqlQuery, + variableType: initialState.variableType, + controlType: initialState.controlType, + esqlVariables: variablesInParent, + onSaveControl, + initialState: state, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error getting ESQL control trigger', e); + } + }, + serializeState: () => { + const { rawState: defaultControlState } = defaultControl.serialize(); + return { + rawState: { + ...defaultControlState, + selectedOptions: selections.selectedOptions$.getValue(), + availableOptions: selections.availableOptions$.getValue(), + variableName: selections.variableName$.getValue(), + variableType: selections.variableType$.getValue(), + controlType: selections.controlType$.getValue(), + esqlQuery: selections.esqlQuery$.getValue(), + title: selections.title$.getValue(), + }, + references: [], + }; + }, + clearSelections: () => { + // do nothing, not allowed for now; + }, + }, + { + ...defaultControl.comparators, + ...selections.comparators, + } + ); + + const inputCss = css` + .euiComboBox__inputWrap { + box-shadow: none; + } + `; + return { + api, + Component: ({ className: controlPanelClassName }) => { + const [availableOptions, selectedOptions] = useBatchedPublishingSubjects( + selections.availableOptions$, + selections.selectedOptions$ + ); + + return ( +
+ ({ label: option }))} + selectedOptions={selectedOptions.map((option) => ({ label: option }))} + compressed + fullWidth + isClearable={false} + onChange={(options) => { + const selectedValues = options.map((option) => option.label); + selections.setSelectedOptions(selectedValues); + }} + /> +
+ ); + }, + }; + }, + }; +}; diff --git a/src/platform/plugins/shared/controls/public/controls/esql_control/register_esql_control.ts b/src/platform/plugins/shared/controls/public/controls/esql_control/register_esql_control.ts new file mode 100644 index 0000000000000..8c1649ba10fae --- /dev/null +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/register_esql_control.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ESQL_CONTROL } from '../../../common'; +import { untilPluginStartServicesReady } from '../../services/kibana_services'; +import { registerControlFactory } from '../../control_factory_registry'; + +export function registerESQLControl() { + registerControlFactory(ESQL_CONTROL, async () => { + const [{ getESQLControlFactory }] = await Promise.all([ + import('../../controls_module'), + untilPluginStartServicesReady(), + ]); + return getESQLControlFactory(); + }); +} 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 new file mode 100644 index 0000000000000..fe79a25f6b81e --- /dev/null +++ b/src/platform/plugins/shared/controls/public/controls/esql_control/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import { PublishesESQLVariable } from '@kbn/esql-variables-types'; +import type { HasEditCapabilities, PublishesTitle } from '@kbn/presentation-publishing'; +import type { DefaultControlApi } from '../types'; + +export type ESQLControlApi = DefaultControlApi & + PublishesESQLVariable & + HasEditCapabilities & + Pick; diff --git a/src/platform/plugins/shared/controls/public/controls_module.ts b/src/platform/plugins/shared/controls/public/controls_module.ts index 3dbcdf910cccb..2fad0f13aad70 100644 --- a/src/platform/plugins/shared/controls/public/controls_module.ts +++ b/src/platform/plugins/shared/controls/public/controls_module.ts @@ -16,5 +16,6 @@ export { getControlGroupEmbeddableFactory } from './control_group/get_control_gr export { getOptionsListControlFactory } from './controls/data_controls/options_list_control/get_options_list_control_factory'; export { getRangesliderControlFactory } from './controls/data_controls/range_slider/get_range_slider_control_factory'; export { getTimesliderControlFactory } from './controls/timeslider_control/get_timeslider_control_factory'; +export { getESQLControlFactory } from './controls/esql_control/get_esql_control_factory'; export { ControlGroupRenderer } from './control_group/control_group_renderer/control_group_renderer'; diff --git a/src/platform/plugins/shared/controls/public/index.ts b/src/platform/plugins/shared/controls/public/index.ts index 6455c71e7b5f6..9b99f81693c6a 100644 --- a/src/platform/plugins/shared/controls/public/index.ts +++ b/src/platform/plugins/shared/controls/public/index.ts @@ -36,6 +36,7 @@ export { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, TIME_SLIDER_CONTROL, + ESQL_CONTROL, } from '../common'; export type { ControlGroupRuntimeState, diff --git a/src/platform/plugins/shared/controls/public/plugin.ts b/src/platform/plugins/shared/controls/public/plugin.ts index 7c6d1ae91ab6a..78356ac7ba157 100644 --- a/src/platform/plugins/shared/controls/public/plugin.ts +++ b/src/platform/plugins/shared/controls/public/plugin.ts @@ -12,7 +12,10 @@ import { registerControlGroupEmbeddable } from './control_group/register_control import { registerOptionsListControl } from './controls/data_controls/options_list_control/register_options_list_control'; import { registerRangeSliderControl } from './controls/data_controls/range_slider/register_range_slider_control'; import { registerTimeSliderControl } from './controls/timeslider_control/register_timeslider_control'; +import { registerESQLControl } from './controls/esql_control/register_esql_control'; + import { setKibanaServices } from './services/kibana_services'; + import type { ControlsPluginSetupDeps, ControlsPluginStartDeps } from './types'; import { registerActions } from './actions/register_actions'; @@ -29,6 +32,7 @@ export class ControlsPlugin registerOptionsListControl(); registerRangeSliderControl(); registerTimeSliderControl(); + registerESQLControl(); } public start(coreStart: CoreStart, startPlugins: ControlsPluginStartDeps) { diff --git a/src/platform/plugins/shared/controls/server/esql_control/esql_control_factory.ts b/src/platform/plugins/shared/controls/server/esql_control/esql_control_factory.ts new file mode 100644 index 0000000000000..aa94cd9b8d6e9 --- /dev/null +++ b/src/platform/plugins/shared/controls/server/esql_control/esql_control_factory.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/server'; +import { ESQL_CONTROL } from '../../common'; +import { + createEsqlControlInject, + createEsqlControlExtract, +} from './esql_control_persistable_state'; + +export const esqlStaticControlPersistableStateServiceFactory = (): EmbeddableRegistryDefinition => { + return { + id: ESQL_CONTROL, + extract: createEsqlControlExtract(), + inject: createEsqlControlInject(), + }; +}; diff --git a/src/platform/plugins/shared/controls/server/esql_control/esql_control_persistable_state.ts b/src/platform/plugins/shared/controls/server/esql_control/esql_control_persistable_state.ts new file mode 100644 index 0000000000000..2124867a75609 --- /dev/null +++ b/src/platform/plugins/shared/controls/server/esql_control/esql_control_persistable_state.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + EmbeddableStateWithType, + EmbeddablePersistableStateService, +} from '@kbn/embeddable-plugin/common'; +import { SavedObjectReference } from '@kbn/core/types'; + +export const createEsqlControlInject = (): EmbeddablePersistableStateService['inject'] => { + return (state: EmbeddableStateWithType, references: SavedObjectReference[]) => { + const workingState = { ...state } as EmbeddableStateWithType; + return workingState as EmbeddableStateWithType; + }; +}; + +export const createEsqlControlExtract = (): EmbeddablePersistableStateService['extract'] => { + return (state: EmbeddableStateWithType) => { + const workingState = { ...state } as EmbeddableStateWithType; + const references: SavedObjectReference[] = []; + + return { state: workingState as EmbeddableStateWithType, references }; + }; +}; diff --git a/src/platform/plugins/shared/controls/server/plugin.ts b/src/platform/plugins/shared/controls/server/plugin.ts index 78617d2a81dbd..3dd7e33fe3cba 100644 --- a/src/platform/plugins/shared/controls/server/plugin.ts +++ b/src/platform/plugins/shared/controls/server/plugin.ts @@ -16,6 +16,7 @@ import { controlGroupContainerPersistableStateServiceFactory } from './control_g import { optionsListPersistableStateServiceFactory } from './options_list/options_list_embeddable_factory'; import { rangeSliderPersistableStateServiceFactory } from './range_slider/range_slider_embeddable_factory'; import { timeSliderPersistableStateServiceFactory } from './time_slider/time_slider_embeddable_factory'; +import { esqlStaticControlPersistableStateServiceFactory } from './esql_control/esql_control_factory'; import { setupOptionsListClusterSettingsRoute } from './options_list/options_list_cluster_settings_route'; interface SetupDeps { @@ -32,6 +33,7 @@ export class ControlsPlugin implements Plugin { embeddable.registerEmbeddableFactory(optionsListPersistableStateServiceFactory()); embeddable.registerEmbeddableFactory(rangeSliderPersistableStateServiceFactory()); embeddable.registerEmbeddableFactory(timeSliderPersistableStateServiceFactory()); + embeddable.registerEmbeddableFactory(esqlStaticControlPersistableStateServiceFactory()); setupOptionsListClusterSettingsRoute(core); setupOptionsListSuggestionsRoute(core, unifiedSearch.autocomplete.getAutocompleteSettings); return {}; diff --git a/src/platform/plugins/shared/controls/tsconfig.json b/src/platform/plugins/shared/controls/tsconfig.json index 6ca631c6886e3..4a3cef45061f8 100644 --- a/src/platform/plugins/shared/controls/tsconfig.json +++ b/src/platform/plugins/shared/controls/tsconfig.json @@ -39,6 +39,9 @@ "@kbn/shared-ux-utility", "@kbn/std", "@kbn/react-hooks", + "@kbn/esql-validation-autocomplete", + "@kbn/esql-variables-types", + "@kbn/esql", ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/dashboard/kibana.jsonc b/src/platform/plugins/shared/dashboard/kibana.jsonc index d5ba75b11db10..2f610dc71dc17 100644 --- a/src/platform/plugins/shared/dashboard/kibana.jsonc +++ b/src/platform/plugins/shared/dashboard/kibana.jsonc @@ -1,9 +1,7 @@ { "type": "plugin", "id": "@kbn/dashboard-plugin", - "owner": [ - "@elastic/kibana-presentation" - ], + "owner": ["@elastic/kibana-presentation"], "group": "platform", "visibility": "shared", "description": "Adds the Dashboard app to Kibana", @@ -29,7 +27,7 @@ "urlForwarding", "presentationUtil", "visualizations", - "unifiedSearch", + "unifiedSearch" ], "optionalPlugins": [ "home", diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts index ac0c477e98595..094ebbe62c73b 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts @@ -16,6 +16,7 @@ import { import { RefreshInterval, SearchSessionInfoProvider } from '@kbn/data-plugin/public'; import type { DefaultEmbeddableApi, EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; import { Filter, Query, TimeRange } from '@kbn/es-query'; +import { PublishesESQLVariables } from '@kbn/esql-variables-types'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { CanExpandPanels, @@ -135,6 +136,7 @@ export type DashboardApi = CanExpandPanels & Pick & PublishesReload & PublishesSavedObjectId & + PublishesESQLVariables & PublishesSearchSession & PublishesSettings & PublishesUnifiedSearch & diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/unified_search_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/unified_search_manager.ts index 5cb7a40dcc52c..e5a7c902cb6d7 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/unified_search_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/unified_search_manager.ts @@ -7,6 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { ControlGroupApi } from '@kbn/controls-plugin/public'; +import type { SavedObjectReference } from '@kbn/core-saved-objects-api-server'; +import { + GlobalQueryStateFromUrl, + RefreshInterval, + connectToQueryState, + extractSearchSourceReferences, + syncGlobalQueryStateWithUrl, +} from '@kbn/data-plugin/public'; import { COMPARE_ALL_OPTIONS, Filter, @@ -15,6 +24,11 @@ import { compareFilters, isFilterPinned, } from '@kbn/es-query'; +import { ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; +import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; +import fastIsEqual from 'fast-deep-equal'; +import { cloneDeep } from 'lodash'; +import moment, { Moment } from 'moment'; import { BehaviorSubject, Observable, @@ -29,24 +43,11 @@ import { switchMap, tap, } from 'rxjs'; -import fastIsEqual from 'fast-deep-equal'; -import { PublishingSubject, StateComparators } from '@kbn/presentation-publishing'; -import { ControlGroupApi } from '@kbn/controls-plugin/public'; -import { cloneDeep } from 'lodash'; -import type { SavedObjectReference } from '@kbn/core-saved-objects-api-server'; -import { - GlobalQueryStateFromUrl, - RefreshInterval, - connectToQueryState, - extractSearchSourceReferences, - syncGlobalQueryStateWithUrl, -} from '@kbn/data-plugin/public'; -import moment, { Moment } from 'moment'; -import { cleanFiltersForSerialize } from '../utils/clean_filters_for_serialize'; import { dataService } from '../services/kibana_services'; -import { DashboardCreationOptions, DashboardState } from './types'; -import { DEFAULT_DASHBOARD_INPUT } from './default_dashboard_input'; +import { cleanFiltersForSerialize } from '../utils/clean_filters_for_serialize'; import { GLOBAL_STATE_STORAGE_KEY } from '../utils/urls'; +import { DEFAULT_DASHBOARD_INPUT } from './default_dashboard_input'; +import { DashboardCreationOptions, DashboardState } from './types'; export function initializeUnifiedSearchManager( initialState: DashboardState, @@ -120,6 +121,19 @@ export function initializeUnifiedSearchManager( const controlGroupTimeslice$ = controlGroupApi$.pipe( switchMap((controlGroupApi) => (controlGroupApi ? controlGroupApi.timeslice$ : of(undefined))) ); + + // forward ESQL variables from the control group. TODO, this is overcomplicated by the fact that + // the control group API is a publishing subject. Instead, the control group API should be a constant + const esqlVariables$ = new BehaviorSubject([]); + const controlGroupEsqlVariables$ = controlGroupApi$.pipe( + switchMap((controlGroupApi) => + controlGroupApi ? controlGroupApi.esqlVariables$ : of([] as ESQLControlVariable[]) + ) + ); + controlGroupSubscriptions.add( + controlGroupEsqlVariables$.subscribe((latestVariables) => esqlVariables$.next(latestVariables)) + ); + controlGroupSubscriptions.add( combineLatest([unifiedSearchFilters$, controlGroupFilters$]).subscribe( ([unifiedSearchFilters, controlGroupFilters]) => { @@ -263,6 +277,7 @@ export function initializeUnifiedSearchManager( return { api: { filters$, + esqlVariables$, forceRefresh: () => { controlGroupReload$.next(); panelsReload$.next(); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/dashboard_app.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/dashboard_app.tsx index dce6b62d88026..21a7b2d5ac881 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/dashboard_app.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/dashboard_app.tsx @@ -16,7 +16,6 @@ import { v4 as uuidv4 } from 'uuid'; import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics'; import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; - import { ViewMode } from '@kbn/presentation-publishing'; import { DashboardApi, DashboardCreationOptions } from '..'; import { SharedDashboardState } from '../../common'; diff --git a/src/platform/plugins/shared/dashboard/public/mocks.tsx b/src/platform/plugins/shared/dashboard/public/mocks.tsx index b10e91d810ab8..1b54ecf8af273 100644 --- a/src/platform/plugins/shared/dashboard/public/mocks.tsx +++ b/src/platform/plugins/shared/dashboard/public/mocks.tsx @@ -73,6 +73,7 @@ export const mockControlGroupApi = { filters$: new BehaviorSubject(undefined), query$: new BehaviorSubject(undefined), timeslice$: new BehaviorSubject(undefined), + esqlVariables$: new BehaviorSubject(undefined), dataViews$: new BehaviorSubject(undefined), unsavedChanges$: new BehaviorSubject(undefined), } as unknown as ControlGroupApi; diff --git a/src/platform/plugins/shared/dashboard/tsconfig.json b/src/platform/plugins/shared/dashboard/tsconfig.json index ed1555fb069c3..e1bcc5fae059f 100644 --- a/src/platform/plugins/shared/dashboard/tsconfig.json +++ b/src/platform/plugins/shared/dashboard/tsconfig.json @@ -83,7 +83,9 @@ "@kbn/visualization-utils", "@kbn/std", "@kbn/core-rendering-browser", - "@kbn/grid-layout" + "@kbn/esql-variables-types", + "@kbn/grid-layout", + "@kbn/esql-validation-autocomplete" ], "exclude": ["target/**/*"] } diff --git a/src/platform/plugins/shared/data/common/search/expressions/__snapshots__/kibana.test.ts.snap b/src/platform/plugins/shared/data/common/search/expressions/__snapshots__/kibana.test.ts.snap index 7bcf782fbbbc2..5774c69819f50 100644 --- a/src/platform/plugins/shared/data/common/search/expressions/__snapshots__/kibana.test.ts.snap +++ b/src/platform/plugins/shared/data/common/search/expressions/__snapshots__/kibana.test.ts.snap @@ -2,6 +2,7 @@ exports[`interpreter/functions#kibana returns an object with the correct structure 1`] = ` Object { + "esqlVariables": undefined, "filters": Array [ Object { "meta": Object { diff --git a/src/platform/plugins/shared/data/common/search/expressions/esql.ts b/src/platform/plugins/shared/data/common/search/expressions/esql.ts index 59f5d2a642fbf..14e4b857556f0 100644 --- a/src/platform/plugins/shared/data/common/search/expressions/esql.ts +++ b/src/platform/plugins/shared/data/common/search/expressions/esql.ts @@ -15,9 +15,14 @@ import type { IKibanaSearchResponse, ISearchGeneric, } from '@kbn/search-types'; -import type { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common'; +import type { + Datatable, + DatatableColumn, + ExpressionFunctionDefinition, +} from '@kbn/expressions-plugin/common'; import { RequestAdapter } from '@kbn/inspector-plugin/common'; -import { getIndexPatternFromESQLQuery, getStartEndParams } from '@kbn/esql-utils'; +import { getNamedParams, mapVariableToColumn } from '@kbn/esql-utils'; +import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; import { zipObject } from 'lodash'; import { catchError, defer, map, Observable, switchMap, tap, throwError } from 'rxjs'; import { buildEsQuery, type Filter } from '@kbn/es-query'; @@ -186,7 +191,7 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => { uiSettings as Parameters[0] ); - const namedParams = getStartEndParams(query, input.timeRange); + const namedParams = getNamedParams(query, input.timeRange, input.esqlVariables); if (namedParams.length) { params.params = namedParams; @@ -349,11 +354,17 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => { isNull: hasEmptyColumns ? !lookup.has(name) : false, })) ?? []; + const updatedWithVariablesColumns = mapVariableToColumn( + query, + input?.esqlVariables ?? [], + allColumns as DatatableColumn[] + ); + // sort only in case of empty columns to correctly align columns to items in values array if (hasEmptyColumns) { - allColumns.sort((a, b) => Number(a.isNull) - Number(b.isNull)); + updatedWithVariablesColumns.sort((a, b) => Number(a.isNull) - Number(b.isNull)); } - const columnNames = allColumns?.map(({ name }) => name); + const columnNames = updatedWithVariablesColumns?.map(({ name }) => name); const rows = body.values.map((row) => zipObject(columnNames, row)); @@ -366,7 +377,7 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => { totalCount: body.values.length, }, }, - columns: allColumns, + columns: updatedWithVariablesColumns, rows, warning, } as Datatable; diff --git a/src/platform/plugins/shared/data/common/search/expressions/kibana.ts b/src/platform/plugins/shared/data/common/search/expressions/kibana.ts index 77580f8f124f5..f6b85a934db06 100644 --- a/src/platform/plugins/shared/data/common/search/expressions/kibana.ts +++ b/src/platform/plugins/shared/data/common/search/expressions/kibana.ts @@ -46,6 +46,7 @@ export const kibana: ExpressionFunctionKibana = { query: [...toArray(getSearchContext().query), ...toArray((input || {}).query)], filters: [...(getSearchContext().filters || []), ...((input || {}).filters || [])], timeRange: getSearchContext().timeRange || (input ? input.timeRange : undefined), + esqlVariables: getSearchContext().esqlVariables || (input ? input.esqlVariables : undefined), }; return output; diff --git a/src/platform/plugins/shared/data/tsconfig.json b/src/platform/plugins/shared/data/tsconfig.json index 1edc83a23e05e..007bc3aecd16a 100644 --- a/src/platform/plugins/shared/data/tsconfig.json +++ b/src/platform/plugins/shared/data/tsconfig.json @@ -53,7 +53,7 @@ "@kbn/search-types", "@kbn/safer-lodash-set", "@kbn/esql-utils", - "@kbn/shared-ux-table-persist" + "@kbn/shared-ux-table-persist", ], "exclude": [ "target/**/*", diff --git a/src/platform/plugins/shared/esql/kibana.jsonc b/src/platform/plugins/shared/esql/kibana.jsonc index 2f2e765f0b774..56fb397f09870 100644 --- a/src/platform/plugins/shared/esql/kibana.jsonc +++ b/src/platform/plugins/shared/esql/kibana.jsonc @@ -18,7 +18,7 @@ "expressions", "dataViews", "uiActions", - "contentManagement" + "contentManagement", ], "requiredBundles": [ "kibanaReact", diff --git a/src/platform/plugins/shared/esql/public/index.ts b/src/platform/plugins/shared/esql/public/index.ts index 90473418e24b5..89bbf477124fd 100644 --- a/src/platform/plugins/shared/esql/public/index.ts +++ b/src/platform/plugins/shared/esql/public/index.ts @@ -10,6 +10,7 @@ import { EsqlPlugin, type EsqlPluginStart } from './plugin'; export { ESQLLangEditor } from './create_editor'; +export { type ESQLControlState, EsqlControlType } from './triggers/esql_controls/types'; export type { ESQLEditorProps } from '@kbn/esql-editor'; export type { EsqlPluginStart }; diff --git a/src/platform/plugins/shared/esql/public/kibana_services.ts b/src/platform/plugins/shared/esql/public/kibana_services.ts index 8c348cf4d287c..c15ab734a6f47 100644 --- a/src/platform/plugins/shared/esql/public/kibana_services.ts +++ b/src/platform/plugins/shared/esql/public/kibana_services.ts @@ -15,6 +15,7 @@ import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { EsqlPluginStart } from './plugin'; export let core: CoreStart; @@ -24,6 +25,7 @@ interface ServiceDeps { dataViews: DataViewsPublicPluginStart; expressions: ExpressionsStart; storage: Storage; + uiActions: UiActionsStart; indexManagementApiService?: IndexManagementPluginSetup['apiService']; fieldsMetadata?: FieldsMetadataPublicStart; usageCollection?: UsageCollectionStart; @@ -49,6 +51,7 @@ export const setKibanaServices = ( dataViews: DataViewsPublicPluginStart, expressions: ExpressionsStart, storage: Storage, + uiActions: UiActionsStart, indexManagement?: IndexManagementPluginSetup, fieldsMetadata?: FieldsMetadataPublicStart, usageCollection?: UsageCollectionStart @@ -59,6 +62,7 @@ export const setKibanaServices = ( dataViews, expressions, storage, + uiActions, indexManagementApiService: indexManagement?.apiService, fieldsMetadata, usageCollection, diff --git a/src/platform/plugins/shared/esql/public/plugin.ts b/src/platform/plugins/shared/esql/public/plugin.ts index a196c4c974e2d..21786e041a8c6 100755 --- a/src/platform/plugins/shared/esql/public/plugin.ts +++ b/src/platform/plugins/shared/esql/public/plugin.ts @@ -20,10 +20,14 @@ import { updateESQLQueryTrigger, UpdateESQLQueryAction, UPDATE_ESQL_QUERY_TRIGGER, + esqlControlTrigger, + CreateESQLControlAction, + ESQL_CONTROL_TRIGGER, } from './triggers'; import { setKibanaServices } from './kibana_services'; import { JoinIndicesAutocompleteResult } from '../common'; import { cacheNonParametrizedAsyncFunction } from './util/cache'; +import { EsqlVariablesService } from './variables_service'; interface EsqlPluginSetupDependencies { indexManagement: IndexManagementPluginSetup; @@ -41,6 +45,7 @@ interface EsqlPluginStartDependencies { export interface EsqlPluginStart { getJoinIndicesAutocomplete: () => Promise; + variablesService: EsqlVariablesService; } export class EsqlPlugin implements Plugin<{}, EsqlPluginStart> { @@ -50,6 +55,7 @@ export class EsqlPlugin implements Plugin<{}, EsqlPluginStart> { this.indexManagement = indexManagement; uiActions.registerTrigger(updateESQLQueryTrigger); + uiActions.registerTrigger(esqlControlTrigger); return {}; } @@ -66,9 +72,15 @@ export class EsqlPlugin implements Plugin<{}, EsqlPluginStart> { }: EsqlPluginStartDependencies ): EsqlPluginStart { const storage = new Storage(localStorage); + + // Register triggers const appendESQLAction = new UpdateESQLQueryAction(data); uiActions.addTriggerAction(UPDATE_ESQL_QUERY_TRIGGER, appendESQLAction); + const createESQLControlAction = new CreateESQLControlAction(core, data.search.search); + uiActions.addTriggerAction(ESQL_CONTROL_TRIGGER, createESQLControlAction); + + const variablesService = new EsqlVariablesService(); const getJoinIndicesAutocomplete = cacheNonParametrizedAsyncFunction( async () => { @@ -84,6 +96,7 @@ export class EsqlPlugin implements Plugin<{}, EsqlPluginStart> { const start = { getJoinIndicesAutocomplete, + variablesService, }; setKibanaServices( @@ -92,6 +105,7 @@ export class EsqlPlugin implements Plugin<{}, EsqlPluginStart> { dataViews, expressions, storage, + uiActions, this.indexManagement, fieldsMetadata, usageCollection diff --git a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/choose_column_popover.test.tsx b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/choose_column_popover.test.tsx new file mode 100644 index 0000000000000..0a1a997a36179 --- /dev/null +++ b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/choose_column_popover.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChooseColumnPopover } from './choose_column_popover'; + +describe('ChooseColumnPopover', () => { + it('should render a search input and a list', () => { + render(); + // open the popover + screen.getByTestId('chooseColumnBtn').click(); + // expect the search input to be rendered + expect(screen.getByTestId('selectableColumnSearch')).toBeInTheDocument(); + expect(screen.getByTestId('selectableColumnList')).toBeInTheDocument(); + }); + + it('should update the list when there is a text in the input', () => { + render(); + // open the popover + screen.getByTestId('chooseColumnBtn').click(); + // expect the search input to be rendered + + // type in the search input + const input = screen.getByTestId('selectableColumnSearch'); + fireEvent.change(input, { target: { value: 'col2' } }); + + // get the list + const list = screen.getByTestId('selectableColumnList'); + const listItems = list.querySelector('li'); + expect(listItems).toHaveTextContent('col2'); + }); + + it('should call the updateQuery prop if a list item is clicked', () => { + const updateQuerySpy = jest.fn(); + render(); + // open the popover + screen.getByTestId('chooseColumnBtn').click(); + // expect the search input to be rendered + + // type in the search input + const input = screen.getByTestId('selectableColumnSearch'); + fireEvent.change(input, { target: { value: 'col2' } }); + + const list = screen.getByTestId('selectableColumnList'); + const listItems = list.querySelector('li'); + + // click the list item + if (listItems) fireEvent.click(listItems); + expect(updateQuerySpy).toHaveBeenCalledWith('col2'); + }); +}); diff --git a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/choose_column_popover.tsx b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/choose_column_popover.tsx new file mode 100644 index 0000000000000..b9f84e4d545d6 --- /dev/null +++ b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/choose_column_popover.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiLink, EuiPopover, EuiSelectable, EuiSelectableOption } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export function ChooseColumnPopover({ + columns, + updateQuery, +}: { + columns: string[]; + updateQuery: (column: string) => void; +}) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [options, setOptions] = useState( + columns.map((column) => ({ label: column })) + ); + + const onButtonClick = () => setIsPopoverOpen((status) => !status); + const closePopover = () => setIsPopoverOpen(false); + + const button = ( + + {i18n.translate('esql.flyout.chooseColumnBtn.label', { + defaultMessage: 'here', + })} + + ); + + const onColumnChange = useCallback( + (newOptions: EuiSelectableOption[]) => { + setOptions(newOptions); + + const selectedColumn = newOptions.find((option) => option.checked === 'on'); + if (selectedColumn) { + updateQuery(selectedColumn.label); + } + }, + [updateQuery] + ); + + return ( + + + {(list, search) => ( + <> + {search} + {list} + + )} + + + ); +} diff --git a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/field_control_form.test.tsx b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/field_control_form.test.tsx new file mode 100644 index 0000000000000..dba47a4618b25 --- /dev/null +++ b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/field_control_form.test.tsx @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render, within, fireEvent } from '@testing-library/react'; +import { monaco } from '@kbn/monaco'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { ESQLVariableType } from '@kbn/esql-validation-autocomplete'; +import { FieldControlForm } from './field_control_form'; +import { ESQLControlState, EsqlControlType } from '../types'; + +jest.mock('@kbn/esql-utils', () => ({ + getESQLQueryColumnsRaw: jest.fn().mockResolvedValue([{ name: 'column1' }, { name: 'column2' }]), +})); + +describe('FieldControlForm', () => { + const dataMock = dataPluginMock.createStartContract(); + const searchMock = dataMock.search.search; + + it('should default correctly if no initial state is given', async () => { + const { findByTestId, findByTitle } = render( + + ); + // control type dropdown should be rendered and default to 'STATIC_VALUES' + // no need to test further as the control type is disabled + expect(await findByTestId('esqlControlTypeDropdown')).toBeInTheDocument(); + const controlTypeInputPopover = await findByTestId('esqlControlTypeInputPopover'); + expect(within(controlTypeInputPopover).getByRole('combobox')).toHaveValue(`Static values`); + + // variable name input should be rendered and with the default value + expect(await findByTestId('esqlVariableName')).toHaveValue('field'); + + // fields dropdown should be rendered with available fields column1 and column2 + const fieldsOptionsDropdown = await findByTestId('esqlFieldsOptions'); + expect(fieldsOptionsDropdown).toBeInTheDocument(); + const fieldsOptionsDropdownSearchInput = within(fieldsOptionsDropdown).getByRole('combobox'); + fireEvent.click(fieldsOptionsDropdownSearchInput); + expect(fieldsOptionsDropdownSearchInput).toHaveValue(''); + expect(await findByTitle('column1')).toBeDefined(); + expect(await findByTitle('column2')).toBeDefined(); + + // variable label input should be rendered and with the default value (empty) + expect(await findByTestId('esqlControlLabel')).toHaveValue(''); + + // control width dropdown should be rendered and default to 'MEDIUM' + expect(await findByTestId('esqlControlMinimumWidth')).toBeInTheDocument(); + const pressedWidth = within(await findByTestId('esqlControlMinimumWidth')).getByTitle('Medium'); + expect(pressedWidth).toHaveAttribute('aria-pressed', 'true'); + + // control grow switch should be rendered and default to 'false' + expect(await findByTestId('esqlControlGrow')).toBeInTheDocument(); + const growSwitch = await findByTestId('esqlControlGrow'); + expect(growSwitch).not.toBeChecked(); + }); + + it('should call the onCreateControl callback, if no initialState is given', async () => { + const onCreateControlSpy = jest.fn(); + const { findByTestId, findByTitle } = render( + + ); + + // select the first field + const fieldsOptionsDropdownSearchInput = within( + await findByTestId('esqlFieldsOptions') + ).getByRole('combobox'); + fireEvent.click(fieldsOptionsDropdownSearchInput); + fireEvent.click(await findByTitle('column1')); + // click on the create button + fireEvent.click(await findByTestId('saveEsqlControlsFlyoutButton')); + expect(onCreateControlSpy).toHaveBeenCalled(); + }); + + it('should call the onCancelControl callback, if Cancel button is clicked', async () => { + const onCancelControlSpy = jest.fn(); + const { findByTestId } = render( + + ); + // click on the cancel button + fireEvent.click(await findByTestId('cancelEsqlControlsFlyoutButton')); + expect(onCancelControlSpy).toHaveBeenCalled(); + }); + + it('should default correctly if initial state is given', async () => { + const initialState = { + grow: true, + width: 'small', + title: 'my control', + availableOptions: ['column2'], + selectedOptions: ['column2'], + variableName: 'myField', + variableType: ESQLVariableType.FIELDS, + esqlQuery: 'FROM foo | STATS BY', + controlType: EsqlControlType.STATIC_VALUES, + } as ESQLControlState; + const { findByTestId } = render( + + ); + // variable name input should be rendered and with the default value + expect(await findByTestId('esqlVariableName')).toHaveValue('myField'); + + // fields dropdown should be rendered with column2 selected + const fieldsOptionsDropdown = await findByTestId('esqlFieldsOptions'); + const fieldsOptionsDropdownBadge = within(fieldsOptionsDropdown).getByTestId('column2'); + expect(fieldsOptionsDropdownBadge).toBeInTheDocument(); + + // variable label input should be rendered and with the default value (my control) + expect(await findByTestId('esqlControlLabel')).toHaveValue('my control'); + + // control width dropdown should be rendered and default to 'MEDIUM' + expect(await findByTestId('esqlControlMinimumWidth')).toBeInTheDocument(); + const pressedWidth = within(await findByTestId('esqlControlMinimumWidth')).getByTitle('Small'); + expect(pressedWidth).toHaveAttribute('aria-pressed', 'true'); + + // control grow switch should be rendered and default to 'false' + expect(await findByTestId('esqlControlGrow')).toBeInTheDocument(); + const growSwitch = await findByTestId('esqlControlGrow'); + expect(growSwitch).toBeChecked(); + }); + + it('should call the onEditControl callback, if initialState is given', async () => { + const initialState = { + grow: true, + width: 'small', + title: 'my control', + availableOptions: ['column2'], + selectedOptions: ['column2'], + variableName: 'myField', + variableType: ESQLVariableType.FIELDS, + esqlQuery: 'FROM foo | STATS BY', + controlType: EsqlControlType.STATIC_VALUES, + } as ESQLControlState; + const onEditControlSpy = jest.fn(); + const { findByTestId, findByTitle } = render( + + ); + + // select the first field + const fieldsOptionsDropdownSearchInput = within( + await findByTestId('esqlFieldsOptions') + ).getByRole('combobox'); + fireEvent.click(fieldsOptionsDropdownSearchInput); + fireEvent.click(await findByTitle('column1')); + // click on the create button + fireEvent.click(await findByTestId('saveEsqlControlsFlyoutButton')); + expect(onEditControlSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/field_control_form.tsx b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/field_control_form.tsx new file mode 100644 index 0000000000000..3fe61b6398245 --- /dev/null +++ b/src/platform/plugins/shared/esql/public/triggers/esql_controls/control_flyout/field_control_form.tsx @@ -0,0 +1,278 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useCallback, useState, useMemo, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiComboBox, + EuiFormRow, + EuiFlyoutBody, + type EuiSwitchEvent, + type EuiComboBoxOptionOption, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { monaco } from '@kbn/monaco'; +import type { ISearchGeneric } from '@kbn/search-types'; +import { ESQLVariableType, ESQLControlVariable } from '@kbn/esql-validation-autocomplete'; +import { getESQLQueryColumnsRaw } from '@kbn/esql-utils'; +import type { ESQLControlState, ControlWidthOptions } from '../types'; +import { + Header, + Footer, + ControlWidth, + ControlType, + VariableName, + ControlLabel, +} from './shared_form_components'; +import { + getRecurrentVariableName, + getFlyoutStyling, + getQueryForFields, + validateVariableName, +} from './helpers'; +import { EsqlControlType } from '../types'; + +interface FieldControlFormProps { + search: ISearchGeneric; + variableType: ESQLVariableType; + queryString: string; + esqlVariables: ESQLControlVariable[]; + closeFlyout: () => void; + onCreateControl: (state: ESQLControlState, variableName: string) => void; + onEditControl: (state: ESQLControlState) => void; + cursorPosition?: monaco.Position; + initialState?: ESQLControlState; + onCancelControl?: () => void; +} + +export function FieldControlForm({ + variableType, + initialState, + queryString, + esqlVariables, + cursorPosition, + onCreateControl, + onEditControl, + onCancelControl, + search, + closeFlyout, +}: FieldControlFormProps) { + const suggestedVariableName = useMemo(() => { + const existingVariables = esqlVariables.filter((variable) => variable.type === variableType); + + return initialState + ? `${initialState.variableName}` + : getRecurrentVariableName( + 'field', + existingVariables.map((variable) => variable.key) + ); + }, [esqlVariables, initialState, variableType]); + + const [availableFieldsOptions, setAvailableFieldsOptions] = useState( + [] + ); + + const [selectedFields, setSelectedFields] = useState( + initialState + ? initialState.availableOptions.map((option) => { + return { + label: option, + key: option, + 'data-test-subj': option, + }; + }) + : [] + ); + const [formIsInvalid, setFormIsInvalid] = useState(false); + const [variableName, setVariableName] = useState(suggestedVariableName); + const [label, setLabel] = useState(initialState?.title ?? ''); + const [minimumWidth, setMinimumWidth] = useState(initialState?.width ?? 'medium'); + const [grow, setGrow] = useState(initialState?.grow ?? false); + + const isControlInEditMode = useMemo(() => !!initialState, [initialState]); + + useEffect(() => { + if (!availableFieldsOptions.length) { + const queryForFields = getQueryForFields(queryString, cursorPosition); + getESQLQueryColumnsRaw({ + esqlQuery: queryForFields, + search, + }).then((columns) => { + setAvailableFieldsOptions( + columns.map((col) => { + return { + label: col.name, + key: col.name, + 'data-test-subj': col.name, + }; + }) + ); + }); + } + }, [availableFieldsOptions.length, variableType, cursorPosition, queryString, search]); + + useEffect(() => { + const variableExists = + esqlVariables.some((variable) => variable.key === variableName.replace('?', '')) && + !isControlInEditMode; + + setFormIsInvalid(!selectedFields.length || !variableName || variableExists); + }, [esqlVariables, isControlInEditMode, selectedFields.length, variableName]); + + const onFieldsChange = useCallback((selectedOptions: EuiComboBoxOptionOption[]) => { + setSelectedFields(selectedOptions); + }, []); + + const onVariableNameChange = useCallback( + (e: { target: { value: React.SetStateAction } }) => { + const text = validateVariableName(String(e.target.value)); + setVariableName(text); + }, + [] + ); + + const onLabelChange = useCallback((e: { target: { value: React.SetStateAction } }) => { + setLabel(e.target.value); + }, []); + + const onMinimumSizeChange = useCallback((optionId: string) => { + if (optionId) { + setMinimumWidth(optionId as ControlWidthOptions); + } + }, []); + + const onGrowChange = useCallback((e: EuiSwitchEvent) => { + setGrow(e.target.checked); + }, []); + + const onCreateOption = useCallback( + (searchValue: string, flattenedOptions: EuiComboBoxOptionOption[] = []) => { + if (!searchValue.trim()) { + return; + } + + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + const newOption = { + label: searchValue, + key: searchValue, + 'data-test-subj': searchValue, + }; + + if ( + flattenedOptions.findIndex( + (option) => option.label.trim().toLowerCase() === normalizedSearchValue + ) === -1 + ) { + setAvailableFieldsOptions([...availableFieldsOptions, newOption]); + } + + setSelectedFields((prevSelected) => [...prevSelected, newOption]); + }, + [availableFieldsOptions] + ); + + const onCreateFieldControl = useCallback(async () => { + const availableOptions = selectedFields.map((field) => field.label); + const state = { + availableOptions, + selectedOptions: [availableOptions[0]], + width: minimumWidth, + title: label || variableName, + variableName, + variableType, + controlType: EsqlControlType.STATIC_VALUES, + esqlQuery: queryString, + grow, + }; + + if (availableOptions.length) { + if (!isControlInEditMode) { + await onCreateControl(state, variableName); + } else { + onEditControl(state); + } + } + closeFlyout(); + }, [ + selectedFields, + minimumWidth, + label, + variableName, + variableType, + queryString, + grow, + isControlInEditMode, + closeFlyout, + onCreateControl, + onEditControl, + ]); + + const styling = useMemo(() => getFlyoutStyling(), []); + + return ( + <> +
+ + + + + + + + + + + + + +