[8.x] [ES|QL] Dashboard variables (#202875) (#208340)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[ES|QL] Dashboard variables
(#202875)](https://github.com/elastic/kibana/pull/202875)

<!--- Backport version: 9.6.4 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Stratoula
Kalafateli","email":"efstratia.kalafateli@elastic.co"},"sourceCommit":{"committedDate":"2025-01-27T10:35:54Z","message":"[ES|QL]
Dashboard variables (#202875)\n\n## Summary\r\n\r\nCloses
https://github.com/elastic/kibana/issues/203967\r\n\r\nSupports
dashboard variables in ES|QL charts.\r\n\r\nThis PR introduces the first
phase of ES|QL controls. In this phase:\r\n- the flow starts from Lens
ES|QL editor (and no vice-versa, this will\r\nhappen on a later phase
after we discuss some technical details with ES)\r\n- it is only
available for dashboards (we want to include them in other\r\napps as
Discover but this is the next phase driven by the
presentation\r\nteam)\r\n- it supports variables for intervals, fields
and values. I haven't\r\nadded support for functions. I am going to do
it after this PR being\r\nmerged (there are some business questions I
want to answer first)\r\n\r\nFor more info check
this\r\n[deck](c101a257-fbe4-44e6-9686-18012f39e8c1)\r\n\r\n###
Implementation details\r\n\r\n- There is a new service, the
ESQLVariables service that is responsible\r\nfor ES|QL variables. I
isolated this to a new plugin owned by the ES|QL\r\nteam for cleaner
code and for avoiding circular dependencies\r\n- A new ESQL_CONTROL type
got created. It follows the exact same logic\r\nas the rest controls. No
changes in the architecture here.\r\n- The creation of the controls (the
control forms) have been added in\r\nthe esql plugin.\r\n- Lens has
small changes:\r\n - The support of variables in the textBased
datasource\r\n- Two callbacks needed to be called after the creation /
cancellation of\r\nan ES|QL control\r\n\r\n\r\n### Types of ES|QL
variables \r\n\r\nWe have 2 types:\r\n\r\n- Static Values (the user
gives a list of values with his own\r\nresponsibility). As the flow
starts from the editor we can identify what\r\nthey most possibly want
to do and we give the user some options but they\r\nhave the freedom to
do as they want. A basic validation has been added\r\ntoo.\r\n- Values
from an ES|QL query (the user gives an ES|QL query that\r\ngenerates the
values). As the flow starts from the editor we can suggest\r\na query
for the users but they can always change it as they wish.\r\n\r\n<img
width=\"1168\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/cc28beb8-111c-43ad-9f26-865bc62ae512\"\r\n/>\r\n\r\n###
Example of a control creation from the
editor\r\n\r\n![meow](https://github.com/user-attachments/assets/09fa0e21-98cd-4160-b271-4f8ed0a91bf7)\r\n\r\n\r\n###
Release note\r\nES|QL charts now allow the creation of controls in
dashboards. You can\r\ncontrol a part of the query such as a field, an
interval or a value.\r\n\r\n### Checklist\r\n- [x] Any text added
follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [x] This was
checked for breaking HTTP API changes, and any breaking\r\nchanges have
been approved by the breaking-change committee.
The\r\n`release_note:breaking` label should be applied in these
situations.\r\n- [x] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n- [x] The PR description includes
the appropriate Release Notes section,\r\nand the correct
`release_note:*` label is applied per
the\r\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Andrea Del Rio <delrio.andre@gmail.com>\r\nCo-authored-by: Devon Thomson
<devon.thomson@elastic.co>","sha":"b84c65c095fea8a69db1656510235849793c82a1","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:Dashboard","Team:Presentation","Feature:ExpressionLanguage","loe:large","impact:high","v9.0.0","release_note:feature","Feature:ES|QL","Team:ESQL","backport:version","v8.18.0"],"title":"[ES|QL]
Dashboard
variables","number":202875,"url":"https://github.com/elastic/kibana/pull/202875","mergeCommit":{"message":"[ES|QL]
Dashboard variables (#202875)\n\n## Summary\r\n\r\nCloses
https://github.com/elastic/kibana/issues/203967\r\n\r\nSupports
dashboard variables in ES|QL charts.\r\n\r\nThis PR introduces the first
phase of ES|QL controls. In this phase:\r\n- the flow starts from Lens
ES|QL editor (and no vice-versa, this will\r\nhappen on a later phase
after we discuss some technical details with ES)\r\n- it is only
available for dashboards (we want to include them in other\r\napps as
Discover but this is the next phase driven by the
presentation\r\nteam)\r\n- it supports variables for intervals, fields
and values. I haven't\r\nadded support for functions. I am going to do
it after this PR being\r\nmerged (there are some business questions I
want to answer first)\r\n\r\nFor more info check
this\r\n[deck](c101a257-fbe4-44e6-9686-18012f39e8c1)\r\n\r\n###
Implementation details\r\n\r\n- There is a new service, the
ESQLVariables service that is responsible\r\nfor ES|QL variables. I
isolated this to a new plugin owned by the ES|QL\r\nteam for cleaner
code and for avoiding circular dependencies\r\n- A new ESQL_CONTROL type
got created. It follows the exact same logic\r\nas the rest controls. No
changes in the architecture here.\r\n- The creation of the controls (the
control forms) have been added in\r\nthe esql plugin.\r\n- Lens has
small changes:\r\n - The support of variables in the textBased
datasource\r\n- Two callbacks needed to be called after the creation /
cancellation of\r\nan ES|QL control\r\n\r\n\r\n### Types of ES|QL
variables \r\n\r\nWe have 2 types:\r\n\r\n- Static Values (the user
gives a list of values with his own\r\nresponsibility). As the flow
starts from the editor we can identify what\r\nthey most possibly want
to do and we give the user some options but they\r\nhave the freedom to
do as they want. A basic validation has been added\r\ntoo.\r\n- Values
from an ES|QL query (the user gives an ES|QL query that\r\ngenerates the
values). As the flow starts from the editor we can suggest\r\na query
for the users but they can always change it as they wish.\r\n\r\n<img
width=\"1168\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/cc28beb8-111c-43ad-9f26-865bc62ae512\"\r\n/>\r\n\r\n###
Example of a control creation from the
editor\r\n\r\n![meow](https://github.com/user-attachments/assets/09fa0e21-98cd-4160-b271-4f8ed0a91bf7)\r\n\r\n\r\n###
Release note\r\nES|QL charts now allow the creation of controls in
dashboards. You can\r\ncontrol a part of the query such as a field, an
interval or a value.\r\n\r\n### Checklist\r\n- [x] Any text added
follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [x] This was
checked for breaking HTTP API changes, and any breaking\r\nchanges have
been approved by the breaking-change committee.
The\r\n`release_note:breaking` label should be applied in these
situations.\r\n- [x] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n- [x] The PR description includes
the appropriate Release Notes section,\r\nand the correct
`release_note:*` label is applied per
the\r\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Andrea Del Rio <delrio.andre@gmail.com>\r\nCo-authored-by: Devon Thomson
<devon.thomson@elastic.co>","sha":"b84c65c095fea8a69db1656510235849793c82a1"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/202875","number":202875,"mergeCommit":{"message":"[ES|QL]
Dashboard variables (#202875)\n\n## Summary\r\n\r\nCloses
https://github.com/elastic/kibana/issues/203967\r\n\r\nSupports
dashboard variables in ES|QL charts.\r\n\r\nThis PR introduces the first
phase of ES|QL controls. In this phase:\r\n- the flow starts from Lens
ES|QL editor (and no vice-versa, this will\r\nhappen on a later phase
after we discuss some technical details with ES)\r\n- it is only
available for dashboards (we want to include them in other\r\napps as
Discover but this is the next phase driven by the
presentation\r\nteam)\r\n- it supports variables for intervals, fields
and values. I haven't\r\nadded support for functions. I am going to do
it after this PR being\r\nmerged (there are some business questions I
want to answer first)\r\n\r\nFor more info check
this\r\n[deck](c101a257-fbe4-44e6-9686-18012f39e8c1)\r\n\r\n###
Implementation details\r\n\r\n- There is a new service, the
ESQLVariables service that is responsible\r\nfor ES|QL variables. I
isolated this to a new plugin owned by the ES|QL\r\nteam for cleaner
code and for avoiding circular dependencies\r\n- A new ESQL_CONTROL type
got created. It follows the exact same logic\r\nas the rest controls. No
changes in the architecture here.\r\n- The creation of the controls (the
control forms) have been added in\r\nthe esql plugin.\r\n- Lens has
small changes:\r\n - The support of variables in the textBased
datasource\r\n- Two callbacks needed to be called after the creation /
cancellation of\r\nan ES|QL control\r\n\r\n\r\n### Types of ES|QL
variables \r\n\r\nWe have 2 types:\r\n\r\n- Static Values (the user
gives a list of values with his own\r\nresponsibility). As the flow
starts from the editor we can identify what\r\nthey most possibly want
to do and we give the user some options but they\r\nhave the freedom to
do as they want. A basic validation has been added\r\ntoo.\r\n- Values
from an ES|QL query (the user gives an ES|QL query that\r\ngenerates the
values). As the flow starts from the editor we can suggest\r\na query
for the users but they can always change it as they wish.\r\n\r\n<img
width=\"1168\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/cc28beb8-111c-43ad-9f26-865bc62ae512\"\r\n/>\r\n\r\n###
Example of a control creation from the
editor\r\n\r\n![meow](https://github.com/user-attachments/assets/09fa0e21-98cd-4160-b271-4f8ed0a91bf7)\r\n\r\n\r\n###
Release note\r\nES|QL charts now allow the creation of controls in
dashboards. You can\r\ncontrol a part of the query such as a field, an
interval or a value.\r\n\r\n### Checklist\r\n- [x] Any text added
follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [x] This was
checked for breaking HTTP API changes, and any breaking\r\nchanges have
been approved by the breaking-change committee.
The\r\n`release_note:breaking` label should be applied in these
situations.\r\n- [x] [Flaky
Test\r\nRunner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1)
was\r\nused on any tests changed\r\n- [x] The PR description includes
the appropriate Release Notes section,\r\nand the correct
`release_note:*` label is applied per
the\r\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Andrea Del Rio <delrio.andre@gmail.com>\r\nCo-authored-by: Devon Thomson
<devon.thomson@elastic.co>","sha":"b84c65c095fea8a69db1656510235849793c82a1"}},{"branch":"8.x","label":"v8.18.0","branchLabelMappingKey":"^v8.18.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2025-01-27 15:42:51 +01:00 committed by GitHub
parent 3ede0fe0db
commit e8d3a115f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
124 changed files with 4930 additions and 172 deletions

View file

@ -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/classic/config.ts
- test/functional/apps/discover/embeddable/config.ts

1
.github/CODEOWNERS vendored
View file

@ -443,6 +443,7 @@ src/platform/packages/private/kbn-esql-editor @elastic/kibana-esql
src/platform/packages/shared/kbn-esql-utils @elastic/kibana-esql
src/platform/packages/shared/kbn-esql-validation-autocomplete @elastic/kibana-esql
examples/esql_validation_example @elastic/kibana-esql
src/platform/packages/shared/kbn-esql-variables-types @elastic/kibana-esql
test/plugin_functional/plugins/eui_provider_dev_warning @elastic/appex-sharedux
src/platform/packages/shared/kbn-event-annotation-common @elastic/kibana-visualizations
src/platform/packages/shared/kbn-event-annotation-components @elastic/kibana-visualizations

View file

@ -494,6 +494,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",

View file

@ -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';
@ -37,8 +38,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 {
@ -66,6 +68,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,
@ -85,6 +106,10 @@ export const ESQLEditor = memo(function ESQLEditor({
hasOutline,
displayDocumentationAsFlyout,
disableAutoFocus,
onSaveControl,
onCancelControl,
supportsControls,
esqlVariables,
}: ESQLEditorProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const datePickerOpenStatusRef = useRef<boolean>(false);
@ -98,9 +123,11 @@ export const ESQLEditor = memo(function ESQLEditor({
core,
fieldsMetadata,
uiSettings,
uiActions,
} = kibana.services;
const darkMode = core.theme?.getTheme().darkMode;
const variablesService = kibana.services?.esql?.variablesService;
const histogramBarTarget = uiSettings?.get('histogram:barTarget') ?? 50;
const [code, setCode] = useState<string>(query.esql ?? '');
// To make server side errors less "sticky", register the state of the code when submitting
@ -195,6 +222,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);
}, []);
@ -238,6 +282,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,
@ -381,13 +464,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,
@ -396,7 +486,7 @@ export const ESQLEditor = memo(function ESQLEditor({
abortController,
indexManagementApiService,
histogramBarTarget,
fieldsMetadata,
variablesService,
kibana.services?.esql?.getJoinIndicesAutocomplete,
]);

View file

@ -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<string, unknown>, updatedQuery: string) => Promise<void>;
/** 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<JoinIndicesAutocompleteResult>;
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;

View file

@ -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": [

View file

@ -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[];
}

View file

@ -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/**/*",

View file

@ -691,5 +691,7 @@ export interface ESQLSearchParams {
locale?: string;
include_ccs_metadata?: boolean;
dropNullColumns?: boolean;
params?: estypes.ScalarValue[] | Array<Record<string, string | undefined>>;
params?:
| estypes.ScalarValue[]
| Array<Record<string, string | number | Record<string, string | number> | undefined>>;
}

View file

@ -25,6 +25,7 @@ export {
getTimeFieldFromESQLQuery,
getStartEndParams,
hasStartEndParams,
getNamedParams,
prettifyQuery,
isQueryWrappedByPipes,
retrieveMetadataColumns,
@ -34,6 +35,7 @@ export {
isESQLFieldGroupable,
TextBasedLanguages,
queryCannotBeSampled,
mapVariableToColumn,
} from './src';
export { ENABLE_ESQL, FEEDBACK_LINK } from './constants';

View file

@ -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,

View file

@ -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);
});
});
});

View file

@ -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<string>(
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;
};

View file

@ -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 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 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');
});
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 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 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 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 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',
});
});
});
});

View file

@ -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(
{

View file

@ -26,6 +26,7 @@
"@kbn/es-types",
"@kbn/i18n",
"@kbn/datemath",
"@kbn/es-query"
"@kbn/es-query",
"@kbn/esql-validation-autocomplete"
]
}

View file

@ -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,

View file

@ -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',
});
});
});
});
});
});

View file

@ -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 <expression>', () => {
})
);
});
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 },
});
});
});
});
});

View file

@ -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<SuggestionRawDefinition[]> {
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
)
);

View file

@ -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'
)
: []),
];
}

View file

@ -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<string, ESQLVariable[]>;
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'),
}
))
);

View file

@ -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<SuggestionRawDefinition[]>;

View file

@ -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);

View file

@ -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<PartialFieldsMetadataClient>;
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined;
canSuggestVariables?: () => boolean;
getJoinIndices?: () => Promise<{ indices: JoinIndexAutocompleteItem[] }>;
}

View file

@ -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]) || [];
}

View file

@ -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<keyof ESQLCallbacks, ErrorTypes[]> = {
getPolicies: ['unknownPolicy'],
getPreferences: [],
getFieldsMetadata: [],
getVariablesByType: [],
canSuggestVariables: [],
getJoinIndices: [],
};

View file

@ -0,0 +1,3 @@
# @kbn/esql-variables-types
This package contains types important for the ES|QL variables.

View file

@ -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<ESQLControlVariable>;
}
export const apiPublishesESQLVariable = (
unknownApi: unknown
): unknownApi is PublishesESQLVariable => {
return Boolean(unknownApi && (unknownApi as PublishesESQLVariable)?.esqlVariable$ !== undefined);
};
export interface PublishesESQLVariables {
esqlVariables$: PublishingSubject<ESQLControlVariable[]>;
}
export const apiPublishesESQLVariables = (
unknownApi: unknown
): unknownApi is PublishesESQLVariables => {
return Boolean(
unknownApi && (unknownApi as PublishesESQLVariables)?.esqlVariables$ !== undefined
);
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-browser",
"id": "@kbn/esql-variables-types",
"owner": [
"@elastic/kibana-esql",
],
"group": "platform",
"visibility": "shared"
}

View file

@ -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"
}

View file

@ -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",
]
}

View file

@ -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';

View file

@ -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';

View file

@ -17,9 +17,8 @@
"dataViews",
"data",
"unifiedSearch",
"uiActions"
"uiActions",
],
"requiredBundles": [],
"extraPublicDirs": [
"common"
]

View file

@ -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<ESQLControlVariable[]>([]);
const dataViews$ = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(
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();
};
}, []);

View file

@ -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<ControlGroupSerializedState, ControlGroupRuntimeState> &
PublishesFilters &
PublishesDataViews &
PublishesESQLVariables &
HasSerializedChildState<ControlPanelState> &
HasEditCapabilities &
Pick<PublishesUnsavedChanges<ControlGroupRuntimeState>, 'unsavedChanges$'> &

View file

@ -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<string[]>(initialState.availableOptions ?? []);
const selectedOptions$ = new BehaviorSubject<string[]>(initialState.selectedOptions ?? []);
const hasSelections$ = new BehaviorSubject<boolean>(false); // hardcoded to false to prevent clear action from appearing.
const variableName$ = new BehaviorSubject<string>(initialState.variableName ?? '');
const variableType$ = new BehaviorSubject<ESQLVariableType>(
initialState.variableType ?? ESQLVariableType.VALUES
);
const controlType$ = new BehaviorSubject<string>(initialState.controlType ?? '');
const esqlQuery$ = new BehaviorSubject<string>(initialState.esqlQuery ?? '');
const title$ = new BehaviorSubject<string | undefined>(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<ESQLControlVariable>(getEsqlVariable());
const subscriptions = combineLatest([variableName$, variableType$, selectedOptions$]).subscribe(
() => esqlVariable$.next(getEsqlVariable())
);
return {
cleanup: () => subscriptions.unsubscribe(),
api: {
hasSelections$: hasSelections$ as PublishingSubject<boolean | undefined>,
esqlVariable$: esqlVariable$ as PublishingSubject<ESQLControlVariable>,
},
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<string[]>,
availableOptions$: availableOptions$ as PublishingSubject<string[]>,
variableName$: variableName$ as PublishingSubject<string>,
variableType$: variableType$ as PublishingSubject<string>,
controlType$: controlType$ as PublishingSubject<string>,
esqlQuery$: esqlQuery$ as PublishingSubject<string>,
title$: title$ as PublishingSubject<string | undefined>,
setSelectedOptions,
};
}

View file

@ -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<ESQLControlApi>,
nextComparators: StateComparators<ESQLControlState>
) {
return {
...api,
uuid,
parentApi: controlGroupApi,
unsavedChanges$: new BehaviorSubject<Partial<ESQLControlState> | 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(<Component className="" />);
fireEvent.click(await findByTestId('comboBoxSearchInput'));
fireEvent.click(await findByTitle('option2'));
await waitFor(() => {
expect(api.esqlVariable$.value).toStrictEqual({
key: 'variable1',
type: 'values',
value: 'option2',
});
});
});
});

View file

@ -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<ESQLControlState, ESQLControlApi> => {
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<string | undefined>(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 (
<div className={controlPanelClassName}>
<EuiComboBox
aria-label={i18n.translate('controls.controlGroup.manageControl.esql.ariaLabel', {
defaultMessage: 'ES|QL variable control',
})}
placeholder={i18n.translate(
'controls.controlGroup.manageControl.esql.placeholder',
{
defaultMessage: 'Select a single value',
}
)}
inputPopoverProps={{
css: inputCss,
className: 'esqlControlValuesCombobox',
}}
data-test-subj="esqlControlValuesDropdown"
singleSelection={{ asPlainText: true }}
options={availableOptions.map((option) => ({ label: option }))}
selectedOptions={selectedOptions.map((option) => ({ label: option }))}
compressed
fullWidth
isClearable={false}
onChange={(options) => {
const selectedValues = options.map((option) => option.label);
selections.setSelectedOptions(selectedValues);
}}
/>
</div>
);
},
};
},
};
};

View file

@ -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();
});
}

View file

@ -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<PublishesTitle, 'defaultTitle$'>;

View file

@ -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';

View file

@ -36,6 +36,7 @@ export {
OPTIONS_LIST_CONTROL,
RANGE_SLIDER_CONTROL,
TIME_SLIDER_CONTROL,
ESQL_CONTROL,
} from '../common';
export type {
ControlGroupRuntimeState,

View file

@ -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) {

View file

@ -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(),
};
};

View file

@ -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 };
};
};

View file

@ -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<object, object, SetupDeps> {
embeddable.registerEmbeddableFactory(optionsListPersistableStateServiceFactory());
embeddable.registerEmbeddableFactory(rangeSliderPersistableStateServiceFactory());
embeddable.registerEmbeddableFactory(timeSliderPersistableStateServiceFactory());
embeddable.registerEmbeddableFactory(esqlStaticControlPersistableStateServiceFactory());
setupOptionsListClusterSettingsRoute(core);
setupOptionsListSuggestionsRoute(core, unifiedSearch.autocomplete.getAutocompleteSettings);
return {};

View file

@ -40,6 +40,9 @@
"@kbn/shared-ux-utility",
"@kbn/std",
"@kbn/react-hooks",
"@kbn/esql-validation-autocomplete",
"@kbn/esql-variables-types",
"@kbn/esql",
],
"exclude": ["target/**/*"]
}

View file

@ -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",

View file

@ -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<PublishesTitle, 'title$'> &
PublishesReload &
PublishesSavedObjectId &
PublishesESQLVariables &
PublishesSearchSession &
PublishesSettings &
PublishesUnifiedSearch &

View file

@ -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<ESQLControlVariable[]>([]);
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();

View file

@ -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';

View file

@ -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;

View file

@ -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/**/*"]
}

View file

@ -2,6 +2,7 @@
exports[`interpreter/functions#kibana returns an object with the correct structure 1`] = `
Object {
"esqlVariables": undefined,
"filters": Array [
Object {
"meta": Object {

View file

@ -15,9 +15,13 @@ 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 { getStartEndParams } from '@kbn/esql-utils';
import { getNamedParams, mapVariableToColumn } 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';
@ -171,7 +175,7 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => {
uiSettings as Parameters<typeof getEsQueryConfig>[0]
);
const namedParams = getStartEndParams(query, input.timeRange);
const namedParams = getNamedParams(query, input.timeRange, input.esqlVariables);
if (namedParams.length) {
params.params = namedParams;
@ -329,11 +333,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));
@ -342,7 +352,7 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => {
meta: {
type: ESQL_TABLE_TYPE,
},
columns: allColumns,
columns: updatedWithVariablesColumns,
rows,
warning,
} as Datatable;

View file

@ -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;

View file

@ -55,7 +55,7 @@
"@kbn/search-types",
"@kbn/safer-lodash-set",
"@kbn/esql-utils",
"@kbn/shared-ux-table-persist"
"@kbn/shared-ux-table-persist",
],
"exclude": [
"target/**/*",

View file

@ -18,7 +18,7 @@
"expressions",
"dataViews",
"uiActions",
"contentManagement"
"contentManagement",
],
"requiredBundles": [
"kibanaReact",

View file

@ -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 };

View file

@ -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,

View file

@ -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<JoinIndicesAutocompleteResult>;
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

View file

@ -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(<ChooseColumnPopover columns={['col1', 'col2']} updateQuery={jest.fn()} />);
// 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(<ChooseColumnPopover columns={['col1', 'col2']} updateQuery={jest.fn()} />);
// 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(<ChooseColumnPopover columns={['col1', 'col2']} updateQuery={updateQuerySpy} />);
// 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');
});
});

View file

@ -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<EuiSelectableOption[]>(
columns.map((column) => ({ label: column }))
);
const onButtonClick = () => setIsPopoverOpen((status) => !status);
const closePopover = () => setIsPopoverOpen(false);
const button = (
<EuiLink
css={css`
vertical-align: top;
`}
onClick={onButtonClick}
data-test-subj="chooseColumnBtn"
>
{i18n.translate('esql.flyout.chooseColumnBtn.label', {
defaultMessage: 'here',
})}
</EuiLink>
);
const onColumnChange = useCallback(
(newOptions: EuiSelectableOption[]) => {
setOptions(newOptions);
const selectedColumn = newOptions.find((option) => option.checked === 'on');
if (selectedColumn) {
updateQuery(selectedColumn.label);
}
},
[updateQuery]
);
return (
<EuiPopover button={button} isOpen={isPopoverOpen} closePopover={closePopover}>
<EuiSelectable
aria-label={i18n.translate('esql.flyout.chooseColumnList.label', {
defaultMessage: 'Select a column',
})}
searchable
searchProps={{
'data-test-subj': 'selectableColumnSearch',
}}
listProps={{
'data-test-subj': 'selectableColumnList',
}}
singleSelection="always"
options={options}
onChange={onColumnChange}
data-test-subj="selectableColumnContainer"
>
{(list, search) => (
<>
{search}
{list}
</>
)}
</EuiSelectable>
</EuiPopover>
);
}

View file

@ -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(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// control type dropdown should be rendered and default to 'STATIC_VALUES'
// no need to test further as the control type is disabled
expect(await findByTestId('esqlControlTypeDropdown')).toBeInTheDocument();
const controlTypeInputPopover = await findByTestId('esqlControlTypeInputPopover');
expect(within(controlTypeInputPopover).getByRole('combobox')).toHaveValue(`Static values`);
// variable name input should be rendered and with the default value
expect(await findByTestId('esqlVariableName')).toHaveValue('field');
// fields dropdown should be rendered with available fields column1 and column2
const fieldsOptionsDropdown = await findByTestId('esqlFieldsOptions');
expect(fieldsOptionsDropdown).toBeInTheDocument();
const fieldsOptionsDropdownSearchInput = within(fieldsOptionsDropdown).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
expect(fieldsOptionsDropdownSearchInput).toHaveValue('');
expect(await findByTitle('column1')).toBeDefined();
expect(await findByTitle('column2')).toBeDefined();
// variable label input should be rendered and with the default value (empty)
expect(await findByTestId('esqlControlLabel')).toHaveValue('');
// control width dropdown should be rendered and default to 'MEDIUM'
expect(await findByTestId('esqlControlMinimumWidth')).toBeInTheDocument();
const pressedWidth = within(await findByTestId('esqlControlMinimumWidth')).getByTitle('Medium');
expect(pressedWidth).toHaveAttribute('aria-pressed', 'true');
// control grow switch should be rendered and default to 'false'
expect(await findByTestId('esqlControlGrow')).toBeInTheDocument();
const growSwitch = await findByTestId('esqlControlGrow');
expect(growSwitch).not.toBeChecked();
});
it('should call the onCreateControl callback, if no initialState is given', async () => {
const onCreateControlSpy = jest.fn();
const { findByTestId, findByTitle } = render(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={onCreateControlSpy}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// select the first field
const fieldsOptionsDropdownSearchInput = within(
await findByTestId('esqlFieldsOptions')
).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
fireEvent.click(await findByTitle('column1'));
// click on the create button
fireEvent.click(await findByTestId('saveEsqlControlsFlyoutButton'));
expect(onCreateControlSpy).toHaveBeenCalled();
});
it('should call the onCancelControl callback, if Cancel button is clicked', async () => {
const onCancelControlSpy = jest.fn();
const { findByTestId } = render(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
onCancelControl={onCancelControlSpy}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
esqlVariables={[]}
/>
);
// click on the cancel button
fireEvent.click(await findByTestId('cancelEsqlControlsFlyoutButton'));
expect(onCancelControlSpy).toHaveBeenCalled();
});
it('should default correctly if initial state is given', async () => {
const initialState = {
grow: true,
width: 'small',
title: 'my control',
availableOptions: ['column2'],
selectedOptions: ['column2'],
variableName: 'myField',
variableType: ESQLVariableType.FIELDS,
esqlQuery: 'FROM foo | STATS BY',
controlType: EsqlControlType.STATIC_VALUES,
} as ESQLControlState;
const { findByTestId } = render(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
initialState={initialState}
esqlVariables={[]}
/>
);
// variable name input should be rendered and with the default value
expect(await findByTestId('esqlVariableName')).toHaveValue('myField');
// fields dropdown should be rendered with column2 selected
const fieldsOptionsDropdown = await findByTestId('esqlFieldsOptions');
const fieldsOptionsDropdownBadge = within(fieldsOptionsDropdown).getByTestId('column2');
expect(fieldsOptionsDropdownBadge).toBeInTheDocument();
// variable label input should be rendered and with the default value (my control)
expect(await findByTestId('esqlControlLabel')).toHaveValue('my control');
// control width dropdown should be rendered and default to 'MEDIUM'
expect(await findByTestId('esqlControlMinimumWidth')).toBeInTheDocument();
const pressedWidth = within(await findByTestId('esqlControlMinimumWidth')).getByTitle('Small');
expect(pressedWidth).toHaveAttribute('aria-pressed', 'true');
// control grow switch should be rendered and default to 'false'
expect(await findByTestId('esqlControlGrow')).toBeInTheDocument();
const growSwitch = await findByTestId('esqlControlGrow');
expect(growSwitch).toBeChecked();
});
it('should call the onEditControl callback, if initialState is given', async () => {
const initialState = {
grow: true,
width: 'small',
title: 'my control',
availableOptions: ['column2'],
selectedOptions: ['column2'],
variableName: 'myField',
variableType: ESQLVariableType.FIELDS,
esqlQuery: 'FROM foo | STATS BY',
controlType: EsqlControlType.STATIC_VALUES,
} as ESQLControlState;
const onEditControlSpy = jest.fn();
const { findByTestId, findByTitle } = render(
<FieldControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={onEditControlSpy}
search={searchMock}
cursorPosition={{ column: 19, lineNumber: 1 } as monaco.Position}
initialState={initialState}
esqlVariables={[]}
/>
);
// select the first field
const fieldsOptionsDropdownSearchInput = within(
await findByTestId('esqlFieldsOptions')
).getByRole('combobox');
fireEvent.click(fieldsOptionsDropdownSearchInput);
fireEvent.click(await findByTitle('column1'));
// click on the create button
fireEvent.click(await findByTestId('saveEsqlControlsFlyoutButton'));
expect(onEditControlSpy).toHaveBeenCalled();
});
});

View file

@ -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<EuiComboBoxOptionOption[]>(
[]
);
const [selectedFields, setSelectedFields] = useState<EuiComboBoxOptionOption[]>(
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<string> } }) => {
const text = validateVariableName(String(e.target.value));
setVariableName(text);
},
[]
);
const onLabelChange = useCallback((e: { target: { value: React.SetStateAction<string> } }) => {
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 (
<>
<Header isInEditMode={isControlInEditMode} />
<EuiFlyoutBody
css={css`
${styling}
`}
>
<ControlType isDisabled initialControlFlyoutType={EsqlControlType.STATIC_VALUES} />
<VariableName
variableName={variableName}
isControlInEditMode={isControlInEditMode}
onVariableNameChange={onVariableNameChange}
esqlVariables={esqlVariables}
/>
<EuiFormRow
label={i18n.translate('esql.flyout.values.label', {
defaultMessage: 'Values',
})}
fullWidth
>
<EuiComboBox
aria-label={i18n.translate('esql.flyout.fieldsOptions.placeholder', {
defaultMessage: 'Select or add values',
})}
placeholder={i18n.translate('esql.flyout.fieldsOptions.placeholder', {
defaultMessage: 'Select or add values',
})}
options={availableFieldsOptions}
selectedOptions={selectedFields}
onChange={onFieldsChange}
onCreateOption={onCreateOption}
data-test-subj="esqlFieldsOptions"
fullWidth
compressed
/>
</EuiFormRow>
<ControlLabel label={label} onLabelChange={onLabelChange} />
<ControlWidth
minimumWidth={minimumWidth}
grow={grow}
onMinimumSizeChange={onMinimumSizeChange}
onGrowChange={onGrowChange}
/>
</EuiFlyoutBody>
<Footer
isControlInEditMode={isControlInEditMode}
variableName={variableName}
onCancelControl={onCancelControl}
isSaveDisabled={formIsInvalid}
closeFlyout={closeFlyout}
onCreateControl={onCreateFieldControl}
/>
</>
);
}

View file

@ -0,0 +1,139 @@
/*
* 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 { monaco } from '@kbn/monaco';
import {
updateQueryStringWithVariable,
getQueryForFields,
areValuesIntervalsValid,
getRecurrentVariableName,
getValuesFromQueryField,
appendStatsByToQuery,
validateVariableName,
} from './helpers';
describe('helpers', () => {
describe('updateQueryStringWithVariable', () => {
it('should update the query string with the variable for an one line query string', () => {
const queryString = 'FROM my_index | STATS BY ';
const variable = 'my_variable';
const cursorPosition = { column: 26, lineNumber: 1 } as monaco.Position;
const updatedQueryString = updateQueryStringWithVariable(
queryString,
variable,
cursorPosition
);
expect(updatedQueryString).toBe('FROM my_index | STATS BY ?my_variable');
});
it('should update the query string with the variable for multiline query string', () => {
const queryString = 'FROM my_index \n| STATS BY ';
const variable = 'my_variable';
const cursorPosition = { column: 12, lineNumber: 2 } as monaco.Position;
const updatedQueryString = updateQueryStringWithVariable(
queryString,
variable,
cursorPosition
);
expect(updatedQueryString).toBe('FROM my_index \n| STATS BY ?my_variable');
});
});
describe('getQueryForFields', () => {
it('should return the query to retrieve the fields for an one liner base query string', () => {
const queryString = 'FROM my_index | LIMIT 10 | WHERE a ==';
const cursorPosition = { column: 37, lineNumber: 1 } as monaco.Position;
const queryForFields = getQueryForFields(queryString, cursorPosition);
expect(queryForFields).toBe('FROM my_index | LIMIT 10 ');
});
it('should return the query to retrieve the fields for a multi liner base query string', () => {
const queryString = 'FROM my_index \n| LIMIT 10 \n| WHERE a ==';
const cursorPosition = { column: 12, lineNumber: 3 } as monaco.Position;
const queryForFields = getQueryForFields(queryString, cursorPosition);
expect(queryForFields).toBe('FROM my_index \n| LIMIT 10 ');
});
});
describe('areValuesIntervalsValid', () => {
it('should return true if all values are valid intervals', () => {
const values = ['1d', '2h', '3m', '4 seconds'];
const isValid = areValuesIntervalsValid(values);
expect(isValid).toBe(true);
});
it('should return false if any value is not a valid interval', () => {
const values = ['1d', '2h', '3m', 'invalid'];
const isValid = areValuesIntervalsValid(values);
expect(isValid).toBe(false);
});
});
describe('getRecurrentVariableName', () => {
it('should return a new name if the name already exists', () => {
const name = 'field';
const existingNames = ['field', 'field1', 'field2'];
const newName = getRecurrentVariableName(name, existingNames);
expect(newName).toBe('field3');
});
it('should return the same name if the name does not exist', () => {
const name = 'field';
const existingNames = ['field1', 'field2'];
const newName = getRecurrentVariableName(name, existingNames);
expect(newName).toBe('field');
});
});
describe('getValuesFromQueryField', () => {
it('should return the values from the query field', () => {
const queryString = 'FROM my_index | WHERE my_field ==';
const values = getValuesFromQueryField(queryString);
expect(values).toEqual('my_field');
});
it('should return the values from the query field with new lines', () => {
const queryString = 'FROM my_index \n| WHERE my_field >=';
const values = getValuesFromQueryField(queryString);
expect(values).toEqual('my_field');
});
});
describe('appendStatsByToQuery', () => {
it('should append the stats by clause to the query', () => {
const queryString = 'FROM my_index';
const statsBy = 'my_field';
const updatedQueryString = appendStatsByToQuery(queryString, statsBy);
expect(updatedQueryString).toBe('FROM my_index\n| STATS BY my_field');
});
it('should append the stats by clause to the query with existing clauses', () => {
const queryString = 'FROM my_index | LIMIT 10 | STATS BY meow';
const statsBy = 'my_field';
const updatedQueryString = appendStatsByToQuery(queryString, statsBy);
expect(updatedQueryString).toBe('FROM my_index | LIMIT 10\n| STATS BY my_field');
});
});
describe('validateVariableName', () => {
it('should return the variable without special characters', () => {
const variable = validateVariableName('my_variable/123');
expect(variable).toBe('my_variable123');
});
it('should remove the questionarks', () => {
const variable = validateVariableName('?my_variable');
expect(variable).toBe('my_variable');
});
it('should remove the _ in the first char', () => {
const variable = validateVariableName('?_my_variable');
expect(variable).toBe('my_variable');
});
});
});

View file

@ -0,0 +1,128 @@
/*
* 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 { monaco } from '@kbn/monaco';
import { inKnownTimeInterval } from '@kbn/esql-validation-autocomplete';
import { type ESQLColumn, parse, walk, mutate, BasicPrettyPrinter, Walker } from '@kbn/esql-ast';
export const updateQueryStringWithVariable = (
queryString: string,
variable: string,
cursorPosition: monaco.Position
) => {
const variableName = `?${variable}`;
const cursorColumn = cursorPosition?.column ?? 0;
const cursorLine = cursorPosition?.lineNumber ?? 0;
const lines = queryString.split('\n');
if (lines.length > 1) {
const queryArray = queryString.split('\n');
const queryPartToBeUpdated = queryArray[cursorLine - 1];
const queryWithVariable = [
queryPartToBeUpdated.slice(0, cursorColumn - 1),
variableName,
queryPartToBeUpdated.slice(cursorColumn - 1),
].join('');
queryArray[cursorLine - 1] = queryWithVariable;
return queryArray.join('\n');
}
return [
queryString.slice(0, cursorColumn - 1),
variableName,
queryString.slice(cursorColumn - 1),
].join('');
};
export const getQueryForFields = (queryString: string, cursorPosition?: monaco.Position) => {
const cursorColumn = cursorPosition?.column ?? 0;
const cursorLine = cursorPosition?.lineNumber ?? 0;
const lines = queryString.split('\n');
if (lines.length > 1) {
const queryArray = queryString.split('\n');
const lineToBeUpdated = cursorLine - 1;
return queryArray.slice(0, lineToBeUpdated).join('\n');
}
const queryBefore = queryString.slice(0, cursorColumn - 1);
const pipes = queryBefore.split('|');
return pipes.slice(0, pipes.length - 1).join('|');
};
export const areValuesIntervalsValid = (values: string[]) => {
return values.every((value) => {
// remove digits and empty spaces from the string to get the unit
const unit = value.replace(/[0-9]/g, '').replace(/\s/g, '');
return inKnownTimeInterval(unit);
});
};
export const getRecurrentVariableName = (name: string, existingNames: string[]) => {
let newName = name;
let i = 1;
while (existingNames.includes(newName)) {
newName = `${name}${i}`;
i++;
}
return newName;
};
export const getValuesFromQueryField = (queryString: string) => {
const validQuery = `${queryString} ""`;
const { root } = parse(validQuery);
const lastCommand = root.commands[root.commands.length - 1];
const columns: ESQLColumn[] = [];
walk(lastCommand, {
visitColumn: (node) => columns.push(node),
});
const column = Walker.match(lastCommand, { type: 'column' });
if (column) {
return `${column.name}`;
}
};
export const getFlyoutStyling = () => {
return `
.euiFlyoutBody__overflow {
-webkit-mask-image: none;
padding-left: inherit;
margin-left: inherit;
transform: initial;
}
.euiFlyoutBody__overflowContent {
block-size: 100%;
}
`;
};
export const appendStatsByToQuery = (queryString: string, column: string) => {
const { root } = parse(queryString);
const lastCommand = root.commands[root.commands.length - 1];
if (lastCommand.name === 'stats') {
const statsCommand = lastCommand;
mutate.generic.commands.remove(root, statsCommand);
const queryWithoutStats = BasicPrettyPrinter.print(root);
return `${queryWithoutStats}\n| STATS BY ${column}`;
} else {
return `${queryString}\n| STATS BY ${column}`;
}
};
export const validateVariableName = (variableName: string) => {
let text = variableName
// variable name can only contain letters, numbers and underscores
.replace(/[^a-zA-Z0-9_]/g, '');
if (text.charAt(0) === '_') {
text = text.substring(1);
}
return text;
};

View file

@ -0,0 +1,92 @@
/*
* 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 } from 'react';
import { ESQLVariableType, ESQLControlVariable } from '@kbn/esql-validation-autocomplete';
import type { ISearchGeneric } from '@kbn/search-types';
import { monaco } from '@kbn/monaco';
import type { ESQLControlState } from '../types';
import { ValueControlForm } from './value_control_form';
import { FieldControlForm } from './field_control_form';
import { updateQueryStringWithVariable } from './helpers';
interface ESQLControlsFlyoutProps {
search: ISearchGeneric;
variableType: ESQLVariableType;
queryString: string;
esqlVariables: ESQLControlVariable[];
onSaveControl?: (controlState: ESQLControlState, updatedQuery: string) => Promise<void>;
onCancelControl?: () => void;
cursorPosition?: monaco.Position;
initialState?: ESQLControlState;
closeFlyout: () => void;
}
export function ESQLControlsFlyout({
search,
variableType,
queryString,
esqlVariables,
onSaveControl,
onCancelControl,
cursorPosition,
initialState,
closeFlyout,
}: ESQLControlsFlyoutProps) {
const onCreateControl = useCallback(
async (state: ESQLControlState, variableName: string) => {
if (cursorPosition) {
const query = updateQueryStringWithVariable(queryString, variableName, cursorPosition);
await onSaveControl?.(state, query);
}
},
[cursorPosition, onSaveControl, queryString]
);
const onEditControl = useCallback(
async (state: ESQLControlState) => {
await onSaveControl?.(state, '');
},
[onSaveControl]
);
if (variableType === ESQLVariableType.VALUES || variableType === ESQLVariableType.TIME_LITERAL) {
return (
<ValueControlForm
queryString={queryString}
esqlVariables={esqlVariables}
variableType={variableType}
closeFlyout={closeFlyout}
onCancelControl={onCancelControl}
initialState={initialState}
onCreateControl={onCreateControl}
onEditControl={onEditControl}
search={search}
/>
);
} else if (variableType === ESQLVariableType.FIELDS) {
return (
<FieldControlForm
variableType={variableType}
esqlVariables={esqlVariables}
queryString={queryString}
onCancelControl={onCancelControl}
onCreateControl={onCreateControl}
onEditControl={onEditControl}
initialState={initialState}
closeFlyout={closeFlyout}
search={search}
cursorPosition={cursorPosition}
/>
);
}
return null;
}

View file

@ -0,0 +1,397 @@
/*
* 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 } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { ESQLControlVariable } from '@kbn/esql-validation-autocomplete';
import { TooltipWrapper } from '@kbn/visualization-utils';
import {
EuiFieldText,
EuiFormRow,
EuiComboBox,
type EuiComboBoxOptionOption,
EuiButtonGroup,
EuiSpacer,
EuiSwitch,
type EuiSwitchEvent,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
EuiFlyoutHeader,
EuiTitle,
EuiBetaBadge,
EuiToolTip,
EuiText,
EuiTextColor,
} from '@elastic/eui';
import { EsqlControlType } from '../types';
const controlTypeOptions = [
{
label: i18n.translate('esql.flyout.controlTypeOptions.staticValuesLabel', {
defaultMessage: 'Static values',
}),
'data-test-subj': 'staticValues',
key: EsqlControlType.STATIC_VALUES,
},
{
label: i18n.translate('esql.flyout.controlTypeOptions.valuesFromQueryLabel', {
defaultMessage: 'Values from a query',
}),
'data-test-subj': 'valuesFromQuery',
key: EsqlControlType.VALUES_FROM_QUERY,
},
];
const minimumWidthButtonGroup = [
{
id: `small`,
label: i18n.translate('esql.flyout.minimumWidth.small', {
defaultMessage: 'Small',
}),
},
{
id: `medium`,
label: i18n.translate('esql.flyout.minimumWidth.medium', {
defaultMessage: 'Medium',
}),
},
{
id: `large`,
label: i18n.translate('esql.flyout.minimumWidth.large', {
defaultMessage: 'Large',
}),
},
];
export function ControlType({
isDisabled,
initialControlFlyoutType,
onFlyoutTypeChange,
}: {
isDisabled: boolean;
initialControlFlyoutType: EsqlControlType;
onFlyoutTypeChange?: (flyoutType: EsqlControlType) => void;
}) {
const controlFlyoutType = controlTypeOptions.find(
(option) => option.key === initialControlFlyoutType
)!;
const onTypeChange = useCallback(
(selectedOptions: EuiComboBoxOptionOption[]) => {
const flyoutType = controlTypeOptions.find(
(option) => option.key === selectedOptions[0].key
)!;
onFlyoutTypeChange?.(flyoutType.key);
},
[onFlyoutTypeChange]
);
return (
<>
<TooltipWrapper
tooltipContent={i18n.translate('esql.flyout.controlTypeOptionsOptions.disabledTooltip', {
defaultMessage:
'Currently, only the [Static values] type is available to replace functions or field names.',
})}
condition={isDisabled}
anchorProps={{
css: { width: '100%' },
}}
>
<EuiFormRow
label={i18n.translate('esql.flyout.controlTypeOptionsOptions.label', {
defaultMessage: 'Type',
})}
fullWidth
>
<EuiComboBox
aria-label={i18n.translate('esql.flyout.controlTypeOptionsOptions.placeholder', {
defaultMessage: 'Select a control type',
})}
placeholder={i18n.translate('esql.flyout.controlTypeOptionsOptions.placeholder', {
defaultMessage: 'Select a control type',
})}
singleSelection={{ asPlainText: true }}
options={controlTypeOptions}
selectedOptions={[controlFlyoutType]}
onChange={onTypeChange}
fullWidth
isDisabled={isDisabled}
compressed
data-test-subj="esqlControlTypeDropdown"
inputPopoverProps={{
'data-test-subj': 'esqlControlTypeInputPopover',
}}
/>
</EuiFormRow>
</TooltipWrapper>
<EuiSpacer size="m" />
</>
);
}
export function VariableName({
variableName,
isControlInEditMode,
esqlVariables = [],
onVariableNameChange,
}: {
variableName: string;
isControlInEditMode: boolean;
esqlVariables?: ESQLControlVariable[];
onVariableNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
const genericContent = i18n.translate('esql.flyout.variableName.helpText', {
defaultMessage: 'This name will be prefaced with a "?" in the editor',
});
const isDisabledTooltipText = i18n.translate('esql.flyout.variableName.disabledTooltip', {
defaultMessage: 'You cant edit a control name after its been created.',
});
const variableExists =
esqlVariables.some((variable) => variable.key === variableName.replace('?', '')) &&
!isControlInEditMode;
return (
<EuiFormRow
label={i18n.translate('esql.flyout.variableName.label', {
defaultMessage: 'Name',
})}
helpText={i18n.translate('esql.flyout.variableName.helpText', {
defaultMessage: 'This name will be prefaced with a "?" in the editor',
})}
fullWidth
autoFocus
isInvalid={!variableName || variableExists}
error={
!variableName
? i18n.translate('esql.flyout.variableName.error', {
defaultMessage: 'Variable name is required',
})
: variableExists
? i18n.translate('esql.flyout.variableNameExists.error', {
defaultMessage: 'Variable name already exists',
})
: undefined
}
>
<EuiToolTip
content={isControlInEditMode ? isDisabledTooltipText : genericContent}
css={css`
width: 100%;
`}
display="block"
>
<EuiFieldText
placeholder={i18n.translate('esql.flyout.variableName.placeholder', {
defaultMessage: 'Set a variable name',
})}
value={variableName}
onChange={onVariableNameChange}
aria-label={i18n.translate('esql.flyout.variableName.placeholder', {
defaultMessage: 'Set a variable name',
})}
data-test-subj="esqlVariableName"
fullWidth
disabled={isControlInEditMode}
compressed
/>
</EuiToolTip>
</EuiFormRow>
);
}
export function ControlLabel({
label,
onLabelChange,
}: {
label: string;
onLabelChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<EuiFormRow
label={i18n.translate('esql.flyout.label.label', {
defaultMessage: 'Label',
})}
labelAppend={
<EuiText size="xs">
<EuiTextColor color="subdued">
{i18n.translate('esql.flyout.label.extraLabel', {
defaultMessage: 'Optional',
})}
</EuiTextColor>
</EuiText>
}
fullWidth
>
<EuiFieldText
placeholder={i18n.translate('esql.flyout.label.placeholder', {
defaultMessage: 'Set a label',
})}
value={label}
onChange={onLabelChange}
aria-label={i18n.translate('esql.flyout.label.placeholder', {
defaultMessage: 'Set a label',
})}
data-test-subj="esqlControlLabel"
fullWidth
compressed
/>
</EuiFormRow>
);
}
export function ControlWidth({
minimumWidth,
grow,
onMinimumSizeChange,
onGrowChange,
}: {
minimumWidth: string;
grow: boolean;
onMinimumSizeChange: (id: string) => void;
onGrowChange: (e: EuiSwitchEvent) => void;
}) {
return (
<>
<EuiFormRow
label={i18n.translate('esql.flyout.minimumWidth.label', {
defaultMessage: 'Minimum width',
})}
fullWidth
>
<EuiButtonGroup
legend={i18n.translate('esql.flyout.minimumWidth.label', {
defaultMessage: 'Minimum width',
})}
options={minimumWidthButtonGroup}
idSelected={minimumWidth}
onChange={(id) => onMinimumSizeChange(id)}
type="single"
isFullWidth
data-test-subj="esqlControlMinimumWidth"
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiSwitch
compressed
label={i18n.translate('esql.flyout.grow.label', {
defaultMessage: 'Expand width to fit available space',
})}
color="primary"
checked={grow ?? false}
onChange={(e) => onGrowChange(e)}
data-test-subj="esqlControlGrow"
/>
</>
);
}
export function Header({ isInEditMode }: { isInEditMode: boolean }) {
return (
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h2>
{isInEditMode
? i18n.translate('esql.flyout.editTitle', {
defaultMessage: 'Edit ES|QL control',
})
: i18n.translate('esql.flyout.title', {
defaultMessage: 'Create ES|QL control',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
title={i18n.translate('esql.flyout.experimentalLabel.title', {
defaultMessage: 'Technical preview',
})}
content={i18n.translate('esql.flyout.experimentalLabel.content', {
defaultMessage: 'ES|QL variables are currently on Technical preview.',
})}
>
<EuiBetaBadge
label=""
iconType="beaker"
size="s"
css={css`
vertical-align: middle;
`}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
);
}
export function Footer({
isControlInEditMode,
variableName,
onCancelControl,
isSaveDisabled,
closeFlyout,
onCreateControl,
}: {
isControlInEditMode: boolean;
variableName: string;
isSaveDisabled: boolean;
closeFlyout: () => void;
onCreateControl: () => void;
onCancelControl?: () => void;
}) {
const onCancel = useCallback(() => {
closeFlyout();
onCancelControl?.();
}, [closeFlyout, onCancelControl]);
return (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
id="lnsCancelEditOnFlyFlyout"
onClick={onCancel}
flush="left"
aria-label={i18n.translate('esql.flyout..cancelFlyoutAriaLabel', {
defaultMessage: 'Cancel applied changes',
})}
data-test-subj="cancelEsqlControlsFlyoutButton"
>
{i18n.translate('esql.flyout.cancelLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={onCreateControl}
fill
aria-label={i18n.translate('esql.flyout..applyFlyoutAriaLabel', {
defaultMessage: 'Apply changes',
})}
disabled={isSaveDisabled}
color="primary"
iconType="check"
data-test-subj="saveEsqlControlsFlyoutButton"
>
{i18n.translate('esql.flyout.saveLabel', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
);
}

View file

@ -0,0 +1,260 @@
/*
* 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 { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { IUiSettingsClient } from '@kbn/core/public';
import { coreMock } from '@kbn/core/server/mocks';
import { ESQLVariableType } from '@kbn/esql-validation-autocomplete';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { ValueControlForm } from './value_control_form';
import { EsqlControlType, ESQLControlState } from '../types';
jest.mock('@kbn/esql-utils', () => {
return {
getESQLResults: jest.fn().mockResolvedValue({
response: {
columns: [
{
name: 'field',
id: 'field',
meta: {
type: 'keyword',
},
},
],
values: [],
},
}),
getIndexPatternFromESQLQuery: jest.fn().mockReturnValue('index1'),
getLimitFromESQLQuery: jest.fn().mockReturnValue(1000),
isQueryWrappedByPipes: jest.fn().mockReturnValue(false),
};
});
describe('ValueControlForm', () => {
const dataMock = dataPluginMock.createStartContract();
const searchMock = dataMock.search.search;
const uiConfig: Record<string, any> = {};
const uiSettings = {
get: (key: string) => uiConfig[key],
} as IUiSettingsClient;
const services = {
uiSettings,
settings: {
client: uiSettings,
},
core: coreMock.createStart(),
};
describe('Interval type', () => {
it('should default correctly if no initial state is given for an interval variable type', async () => {
const { findByTestId, findByTitle } = render(
<ValueControlForm
variableType={ESQLVariableType.TIME_LITERAL}
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
esqlVariables={[]}
/>
);
// control type dropdown should be rendered and default to 'STATIC_VALUES'
expect(await findByTestId('esqlControlTypeDropdown')).toBeInTheDocument();
const controlTypeInputPopover = await findByTestId('esqlControlTypeInputPopover');
expect(within(controlTypeInputPopover).getByRole('combobox')).toHaveValue(`Static values`);
// variable name input should be rendered and with the default value
expect(await findByTestId('esqlVariableName')).toHaveValue('interval');
// values dropdown should be rendered
const valuesOptionsDropdown = await findByTestId('esqlValuesOptions');
expect(valuesOptionsDropdown).toBeInTheDocument();
const valuesOptionsDropdownSearchInput = within(valuesOptionsDropdown).getByRole('combobox');
fireEvent.click(valuesOptionsDropdownSearchInput);
expect(valuesOptionsDropdownSearchInput).toHaveValue('');
expect(await findByTitle('5 minutes')).toBeDefined();
expect(await findByTitle('1 hour')).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(
<ValueControlForm
variableType={ESQLVariableType.TIME_LITERAL}
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
onCreateControl={onCreateControlSpy}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
esqlVariables={[]}
/>
);
// select the first interval
const valuesOptionsDropdownSearchInput = within(
await findByTestId('esqlValuesOptions')
).getByRole('combobox');
fireEvent.click(valuesOptionsDropdownSearchInput);
fireEvent.click(await findByTitle('5 minutes'));
// 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(
<ValueControlForm
variableType={ESQLVariableType.TIME_LITERAL}
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
onCreateControl={jest.fn()}
onCancelControl={onCancelControlSpy}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
esqlVariables={[]}
/>
);
// click on the cancel button
fireEvent.click(await findByTestId('cancelEsqlControlsFlyoutButton'));
expect(onCancelControlSpy).toHaveBeenCalled();
});
it('should default correctly if initial state is given', async () => {
const initialState = {
grow: true,
width: 'small',
title: 'my control',
availableOptions: ['5 minutes'],
selectedOptions: ['5 minutes'],
variableName: 'myInterval',
variableType: ESQLVariableType.TIME_LITERAL,
esqlQuery: 'FROM foo | STATS BY BUCKET(@timestamp,)"',
controlType: EsqlControlType.STATIC_VALUES,
} as ESQLControlState;
const { findByTestId } = render(
<ValueControlForm
variableType={ESQLVariableType.TIME_LITERAL}
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
initialState={initialState}
esqlVariables={[]}
/>
);
// variable name input should be rendered and with the default value
expect(await findByTestId('esqlVariableName')).toHaveValue('myInterval');
// values dropdown should be rendered with column2 selected
const valuesOptionsDropdown = await findByTestId('esqlValuesOptions');
const valuesOptionsDropdownBadge = within(valuesOptionsDropdown).getByTestId('5 minutes');
expect(valuesOptionsDropdownBadge).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: ['5 minutes'],
selectedOptions: ['5 minutes'],
variableName: 'myInterval',
variableType: ESQLVariableType.TIME_LITERAL,
esqlQuery: 'FROM foo | STATS BY BUCKET(@timestamp,)"',
controlType: EsqlControlType.STATIC_VALUES,
} as ESQLControlState;
const onEditControlSpy = jest.fn();
const { findByTestId } = render(
<ValueControlForm
variableType={ESQLVariableType.FIELDS}
queryString="FROM foo | STATS BY BUCKET(@timestamp,)"
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={onEditControlSpy}
search={searchMock}
initialState={initialState}
esqlVariables={[]}
/>
);
// click on the create button
fireEvent.click(await findByTestId('saveEsqlControlsFlyoutButton'));
expect(onEditControlSpy).toHaveBeenCalled();
});
describe('Values type', () => {
it('should default correctly if no initial state is given for a values variable type', async () => {
const { findByTestId } = render(
<KibanaContextProvider services={services}>
<IntlProvider locale="en">
<ValueControlForm
variableType={ESQLVariableType.VALUES}
queryString="FROM foo | WHERE field =="
onCreateControl={jest.fn()}
closeFlyout={jest.fn()}
onEditControl={jest.fn()}
search={searchMock}
esqlVariables={[]}
/>
</IntlProvider>
</KibanaContextProvider>
);
// control type dropdown should be rendered and default to 'Values from a query'
expect(await findByTestId('esqlControlTypeDropdown')).toBeInTheDocument();
const controlTypeInputPopover = await findByTestId('esqlControlTypeInputPopover');
expect(within(controlTypeInputPopover).getByRole('combobox')).toHaveValue(
`Values from a query`
);
// code editor should be rendered
expect(await findByTestId('ESQLEditor')).toBeInTheDocument();
// values preview panel should be rendered
expect(await findByTestId('esqlValuesPreview')).toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,484 @@
/*
* 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,
EuiComboBoxOptionOption,
EuiFormRow,
EuiFlyoutBody,
EuiCallOut,
type EuiSwitchEvent,
EuiPanel,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { ISearchGeneric } from '@kbn/search-types';
import ESQLEditor from '@kbn/esql-editor';
import { ESQLVariableType, ESQLControlVariable } from '@kbn/esql-validation-autocomplete';
import { getIndexPatternFromESQLQuery, getESQLResults } from '@kbn/esql-utils';
import type { ESQLControlState, ControlWidthOptions } from '../types';
import {
Header,
Footer,
ControlWidth,
ControlType,
VariableName,
ControlLabel,
} from './shared_form_components';
import {
getRecurrentVariableName,
getValuesFromQueryField,
getFlyoutStyling,
appendStatsByToQuery,
areValuesIntervalsValid,
validateVariableName,
} from './helpers';
import { EsqlControlType } from '../types';
import { ChooseColumnPopover } from './choose_column_popover';
interface ValueControlFormProps {
search: ISearchGeneric;
variableType: ESQLVariableType;
queryString: string;
esqlVariables: ESQLControlVariable[];
closeFlyout: () => void;
onCreateControl: (state: ESQLControlState, variableName: string) => void;
onEditControl: (state: ESQLControlState) => void;
onCancelControl?: () => void;
initialState?: ESQLControlState;
}
const SUGGESTED_INTERVAL_VALUES = ['5 minutes', '1 hour', '1 day', '1 week', '1 month'];
export function ValueControlForm({
variableType,
initialState,
onCancelControl,
queryString,
esqlVariables,
search,
closeFlyout,
onCreateControl,
onEditControl,
}: ValueControlFormProps) {
const valuesField = useMemo(() => {
if (variableType === ESQLVariableType.VALUES) {
return getValuesFromQueryField(queryString);
}
return null;
}, [variableType, queryString]);
const suggestedVariableName = useMemo(() => {
const existingVariables = esqlVariables.filter((variable) => variable.type === variableType);
if (initialState) {
return initialState.variableName;
}
if (valuesField && variableType === ESQLVariableType.VALUES) {
// variables names can't have special characters, only underscore
const fieldVariableName = valuesField.replace(/[^a-zA-Z0-9]/g, '_');
return getRecurrentVariableName(
fieldVariableName,
existingVariables.map((variable) => variable.key)
);
}
if (variableType === ESQLVariableType.TIME_LITERAL) {
return getRecurrentVariableName(
'interval',
existingVariables.map((variable) => variable.key)
);
}
return getRecurrentVariableName(
'variable',
existingVariables.map((variable) => variable.key)
);
}, [esqlVariables, initialState, valuesField, variableType]);
const [controlFlyoutType, setControlFlyoutType] = useState<EsqlControlType>(
initialState?.controlType ??
(variableType === ESQLVariableType.TIME_LITERAL
? EsqlControlType.STATIC_VALUES
: EsqlControlType.VALUES_FROM_QUERY)
);
const [availableValuesOptions, setAvailableValuesOptions] = useState<EuiComboBoxOptionOption[]>(
variableType === ESQLVariableType.TIME_LITERAL
? SUGGESTED_INTERVAL_VALUES.map((option) => {
return {
label: option,
key: option,
'data-test-subj': option,
};
})
: []
);
const [selectedValues, setSelectedValues] = useState<EuiComboBoxOptionOption[]>(
initialState
? initialState.availableOptions.map((option) => {
return {
label: option,
key: option,
'data-test-subj': option,
};
})
: []
);
const [valuesQuery, setValuesQuery] = useState<string>(
variableType === ESQLVariableType.VALUES ? initialState?.esqlQuery ?? '' : ''
);
const [esqlQueryErrors, setEsqlQueryErrors] = useState<Error[] | undefined>();
const [formIsInvalid, setFormIsInvalid] = useState(false);
const [queryColumns, setQueryColumns] = useState<string[]>(valuesField ? [valuesField] : []);
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]);
const areValuesValid = useMemo(() => {
return variableType === ESQLVariableType.TIME_LITERAL
? areValuesIntervalsValid(selectedValues.map((option) => option.label))
: true;
}, [variableType, selectedValues]);
useEffect(() => {
const variableExists =
esqlVariables.some((variable) => variable.key === variableName.replace('?', '')) &&
!isControlInEditMode;
setFormIsInvalid(!variableName || variableExists || !areValuesValid || !selectedValues.length);
}, [
areValuesValid,
esqlVariables,
isControlInEditMode,
selectedValues.length,
valuesQuery,
variableName,
]);
const onValuesChange = useCallback((selectedOptions: EuiComboBoxOptionOption[]) => {
setSelectedValues(selectedOptions);
}, []);
const onFlyoutTypeChange = useCallback(
(type: EsqlControlType) => {
setControlFlyoutType(type);
if (type !== controlFlyoutType && variableType === ESQLVariableType.TIME_LITERAL) {
setSelectedValues([]);
}
},
[controlFlyoutType, variableType]
);
const onCreateOption = useCallback(
(searchValue: string, flattenedOptions: EuiComboBoxOptionOption[] = []) => {
if (!searchValue) {
return;
}
const normalizedSearchValue = searchValue.trim().toLowerCase();
const newOption = {
'data-test-subj': searchValue,
label: searchValue,
key: searchValue,
};
if (
flattenedOptions.findIndex(
(option) => option.label.trim().toLowerCase() === normalizedSearchValue
) === -1
) {
setAvailableValuesOptions([...availableValuesOptions, newOption]);
}
setSelectedValues((prevSelected) => [...prevSelected, newOption]);
},
[availableValuesOptions]
);
const onVariableNameChange = useCallback(
(e: { target: { value: React.SetStateAction<string> } }) => {
const text = validateVariableName(String(e.target.value));
setVariableName(text);
},
[]
);
const onLabelChange = useCallback((e: { target: { value: React.SetStateAction<string> } }) => {
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 onValuesQuerySubmit = useCallback(
async (query: string) => {
try {
getESQLResults({
esqlQuery: query,
search,
signal: undefined,
filter: undefined,
dropNullColumns: true,
}).then((results) => {
const columns = results.response.columns.map((col) => col.name);
setQueryColumns(columns);
if (columns.length === 1) {
const valuesArray = results.response.values.map((value) => value[0]);
const options = valuesArray
.filter((v) => v)
.map((option) => {
return {
label: String(option),
key: String(option),
'data-test-subj': String(option),
};
});
setSelectedValues(options);
setAvailableValuesOptions(options);
setEsqlQueryErrors([]);
}
});
setValuesQuery(query);
} catch (e) {
setEsqlQueryErrors([e]);
}
},
[search]
);
useEffect(() => {
if (
!selectedValues?.length &&
controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY &&
valuesField
) {
const queryForValues =
suggestedVariableName !== ''
? `FROM ${getIndexPatternFromESQLQuery(queryString)} | STATS BY ${valuesField}`
: '';
onValuesQuerySubmit(queryForValues);
}
}, [
controlFlyoutType,
onValuesQuerySubmit,
queryString,
selectedValues?.length,
suggestedVariableName,
valuesField,
variableName,
]);
const onCreateValueControl = useCallback(async () => {
const availableOptions = selectedValues.map((value) => value.label);
const state = {
availableOptions,
selectedOptions: [availableOptions[0]],
width: minimumWidth,
title: label || variableName,
variableName,
variableType,
esqlQuery: valuesQuery || queryString,
controlType: controlFlyoutType,
grow,
};
if (availableOptions.length) {
if (!isControlInEditMode) {
await onCreateControl(state, variableName);
} else {
onEditControl(state);
}
}
closeFlyout();
}, [
selectedValues,
controlFlyoutType,
minimumWidth,
label,
variableName,
variableType,
valuesQuery,
queryString,
grow,
closeFlyout,
isControlInEditMode,
onCreateControl,
onEditControl,
]);
const updateQuery = useCallback(
(column: string) => {
const updatedQuery = appendStatsByToQuery(valuesQuery, column);
onValuesQuerySubmit(updatedQuery);
},
[onValuesQuerySubmit, valuesQuery]
);
const styling = useMemo(() => getFlyoutStyling(), []);
return (
<>
<Header isInEditMode={isControlInEditMode} />
<EuiFlyoutBody
css={css`
${styling}
`}
>
<ControlType
isDisabled={false}
initialControlFlyoutType={controlFlyoutType}
onFlyoutTypeChange={onFlyoutTypeChange}
/>
<VariableName
variableName={variableName}
isControlInEditMode={isControlInEditMode}
onVariableNameChange={onVariableNameChange}
esqlVariables={esqlVariables}
/>
{controlFlyoutType === EsqlControlType.VALUES_FROM_QUERY && (
<>
<EuiFormRow
label={i18n.translate('esql.flyout.valuesQueryEditor.label', {
defaultMessage: 'Values query',
})}
fullWidth
>
<ESQLEditor
query={{ esql: valuesQuery }}
onTextLangQueryChange={(q) => {
setValuesQuery(q.esql);
}}
hideTimeFilterInfo={true}
disableAutoFocus={true}
errors={esqlQueryErrors}
editorIsInline
hideRunQueryText
onTextLangQuerySubmit={async (q, a) => {
if (q) {
await onValuesQuerySubmit(q.esql);
}
}}
isDisabled={false}
isLoading={false}
/>
</EuiFormRow>
{queryColumns.length > 0 && (
<EuiFormRow
label={i18n.translate('esql.flyout.previewValues.placeholder', {
defaultMessage: 'Values preview',
})}
fullWidth
>
{queryColumns.length === 1 ? (
<EuiPanel
paddingSize="s"
color="primary"
css={css`
white-space: wrap;
overflow-y: auto;
max-height: 200px;
`}
data-test-subj="esqlValuesPreview"
>
{selectedValues.map((value) => value.label).join(', ')}
</EuiPanel>
) : (
<EuiCallOut
title={i18n.translate('esql.flyout.displayMultipleColsCallout.title', {
defaultMessage: 'Your query must return a single column',
})}
color="warning"
iconType="warning"
size="s"
data-test-subj="esqlMoreThanOneColumnCallout"
>
<p>
<FormattedMessage
id="esql.flyout.displayMultipleColsCallout.description"
defaultMessage="Your query is currently returning {totalColumns} columns. Choose column {chooseColumnPopover} or use {boldText}."
values={{
totalColumns: queryColumns.length,
boldText: <strong>STATS BY</strong>,
chooseColumnPopover: (
<ChooseColumnPopover columns={queryColumns} updateQuery={updateQuery} />
),
}}
/>
</p>
</EuiCallOut>
)}
</EuiFormRow>
)}
</>
)}
{controlFlyoutType === EsqlControlType.STATIC_VALUES && (
<EuiFormRow
label={i18n.translate('esql.flyout.values.label', {
defaultMessage: 'Values',
})}
fullWidth
>
<EuiComboBox
aria-label={i18n.translate('esql.flyout.values.placeholder', {
defaultMessage: 'Select or add values',
})}
placeholder={i18n.translate('esql.flyout.values.placeholder', {
defaultMessage: 'Select or add values',
})}
data-test-subj="esqlValuesOptions"
options={availableValuesOptions}
selectedOptions={selectedValues}
onChange={onValuesChange}
onCreateOption={onCreateOption}
fullWidth
compressed
css={css`
max-height: 200px;
overflow-y: auto;
`}
/>
</EuiFormRow>
)}
<ControlLabel label={label} onLabelChange={onLabelChange} />
<ControlWidth
minimumWidth={minimumWidth}
grow={grow}
onMinimumSizeChange={onMinimumSizeChange}
onGrowChange={onGrowChange}
/>
</EuiFlyoutBody>
<Footer
isControlInEditMode={isControlInEditMode}
variableName={variableName}
onCancelControl={onCancelControl}
isSaveDisabled={formIsInvalid}
closeFlyout={closeFlyout}
onCreateControl={onCreateValueControl}
/>
</>
);
}

View file

@ -0,0 +1,42 @@
/*
* 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 { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { coreMock } from '@kbn/core/public/mocks';
import { ESQLVariableType } from '@kbn/esql-validation-autocomplete';
import { CreateESQLControlAction } from './esql_control_action';
describe('update ES|QL query action', () => {
const dataMock = dataPluginMock.createStartContract();
const searchMock = dataMock.search.search;
const coreStart = coreMock.createStart();
describe('compatibility check', () => {
it('is incompatible if no query is applied', async () => {
const createControlAction = new CreateESQLControlAction(coreStart, searchMock);
const isCompatible = await createControlAction.isCompatible({
queryString: '',
variableType: ESQLVariableType.FIELDS,
esqlVariables: [],
});
expect(isCompatible).toBeFalsy();
});
it('is compatible if queryString is given', async () => {
const createControlAction = new CreateESQLControlAction(coreStart, searchMock);
const isCompatible = await createControlAction.isCompatible({
queryString: 'FROM meow',
variableType: ESQLVariableType.FIELDS,
esqlVariables: [],
});
expect(isCompatible).toBeTruthy();
});
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 { i18n } from '@kbn/i18n';
import type { Action } from '@kbn/ui-actions-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import type { ISearchGeneric } from '@kbn/search-types';
import type { ESQLVariableType, ESQLControlVariable } from '@kbn/esql-validation-autocomplete';
import { monaco } from '@kbn/monaco';
import type { ESQLControlState } from './types';
const ACTION_CREATE_ESQL_CONTROL = 'ACTION_CREATE_ESQL_CONTROL';
interface Context {
queryString: string;
variableType: ESQLVariableType;
esqlVariables: ESQLControlVariable[];
onSaveControl?: (controlState: ESQLControlState, updatedQuery: string) => Promise<void>;
onCancelControl?: () => void;
cursorPosition?: monaco.Position;
initialState?: ESQLControlState;
}
export const getHelpersAsync = async () => await import('./esql_control_helpers');
export class CreateESQLControlAction implements Action<Context> {
public type = ACTION_CREATE_ESQL_CONTROL;
public id = ACTION_CREATE_ESQL_CONTROL;
public order = 50;
constructor(protected readonly core: CoreStart, protected readonly search: ISearchGeneric) {}
public getDisplayName(): string {
return i18n.translate('esql.createESQLControlLabel', {
defaultMessage: 'Creates an ES|QL control',
});
}
public getIconType() {
return 'pencil';
}
public async isCompatible({ queryString }: Context) {
const { isActionCompatible } = await getHelpersAsync();
return isActionCompatible(queryString);
}
public async execute({
queryString,
variableType,
esqlVariables,
onSaveControl,
onCancelControl,
cursorPosition,
initialState,
}: Context) {
const { executeAction } = await getHelpersAsync();
return executeAction({
queryString,
core: this.core,
search: this.search,
variableType,
esqlVariables,
onSaveControl,
onCancelControl,
cursorPosition,
initialState,
});
}
}

View file

@ -0,0 +1,101 @@
/*
* 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 { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { CoreStart } from '@kbn/core/public';
import type { ISearchGeneric } from '@kbn/search-types';
import type { ESQLVariableType, ESQLControlVariable } from '@kbn/esql-validation-autocomplete';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { monaco } from '@kbn/monaco';
import { ESQLControlsFlyout } from './control_flyout';
import { untilPluginStartServicesReady } from '../../kibana_services';
import type { ESQLControlState } from './types';
interface Context {
queryString: string;
core: CoreStart;
search: ISearchGeneric;
variableType: ESQLVariableType;
esqlVariables: ESQLControlVariable[];
onSaveControl?: (controlState: ESQLControlState, updatedQuery: string) => Promise<void>;
onCancelControl?: () => void;
cursorPosition?: monaco.Position;
initialState?: ESQLControlState;
}
export async function isActionCompatible(queryString: string) {
return Boolean(queryString && queryString.trim().length > 0);
}
export async function executeAction({
queryString,
core,
search,
variableType,
esqlVariables,
onSaveControl,
onCancelControl,
cursorPosition,
initialState,
}: Context) {
const isCompatibleAction = await isActionCompatible(queryString);
if (!isCompatibleAction) {
throw new IncompatibleActionError();
}
const deps = await untilPluginStartServicesReady();
const handle = core.overlays.openFlyout(
toMountPoint(
React.cloneElement(
<KibanaRenderContextProvider {...core}>
<KibanaContextProvider
services={{
...deps,
}}
>
<ESQLControlsFlyout
queryString={queryString}
search={search}
variableType={variableType}
closeFlyout={() => {
handle.close();
}}
onSaveControl={onSaveControl}
onCancelControl={onCancelControl}
cursorPosition={cursorPosition}
initialState={initialState}
esqlVariables={esqlVariables}
/>
</KibanaContextProvider>
</KibanaRenderContextProvider>,
{
closeFlyout: () => {
handle.close();
},
}
),
core
),
{
size: 's',
'data-test-subj': 'create_esql_control_flyout',
isResizable: true,
type: 'push',
paddingSize: 'm',
hideCloseButton: true,
onClose: (overlayRef) => {
overlayRef.close();
},
outsideClickCloses: true,
maxWidth: 800,
}
);
}

View file

@ -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 { i18n } from '@kbn/i18n';
import type { Trigger } from '@kbn/ui-actions-plugin/public';
export const ESQL_CONTROL_TRIGGER = 'ESQL_CONTROL_TRIGGER';
export const esqlControlTrigger: Trigger = {
id: ESQL_CONTROL_TRIGGER,
title: i18n.translate('esql.triggers.esqlControlTigger', {
defaultMessage: 'Create an ES|QL control',
}),
description: i18n.translate('esql.triggers.esqlControlTiggerDescription', {
defaultMessage: 'Create an ES|QL control to interact with the charts',
}),
};

View file

@ -0,0 +1,28 @@
/*
* 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 type { ESQLVariableType } from '@kbn/esql-validation-autocomplete';
export enum EsqlControlType {
STATIC_VALUES = 'STATIC_VALUES',
VALUES_FROM_QUERY = 'VALUES_FROM_QUERY',
}
export type ControlWidthOptions = 'small' | 'medium' | 'large';
export interface ESQLControlState {
grow?: boolean;
width?: ControlWidthOptions;
title: string;
availableOptions: string[];
selectedOptions: string[];
variableName: string;
variableType: ESQLVariableType;
esqlQuery: string;
controlType: EsqlControlType;
}

View file

@ -7,5 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { updateESQLQueryTrigger, UPDATE_ESQL_QUERY_TRIGGER } from './update_esql_query_trigger';
export { UpdateESQLQueryAction } from './update_esql_query_actions';
export {
updateESQLQueryTrigger,
UPDATE_ESQL_QUERY_TRIGGER,
} from './update_esql_query/update_esql_query_trigger';
export { UpdateESQLQueryAction } from './update_esql_query/update_esql_query_actions';
export { esqlControlTrigger, ESQL_CONTROL_TRIGGER } from './esql_controls/esql_control_trigger';
export { CreateESQLControlAction } from './esql_controls/esql_control_action';

View file

@ -0,0 +1,84 @@
/*
* 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 { ESQLVariableType } from '@kbn/esql-validation-autocomplete';
import { EsqlVariablesService } from './variables_service';
describe('EsqlVariablesService', () => {
let esqlVariablesService: EsqlVariablesService;
beforeEach(() => {
esqlVariablesService = new EsqlVariablesService();
});
describe('enableSuggestions', () => {
it('should enable suggestions', () => {
esqlVariablesService.enableSuggestions();
expect(esqlVariablesService.areSuggestionsEnabled).toBe(true);
});
});
describe('disableSuggestions', () => {
it('should disable suggestions', () => {
esqlVariablesService.disableSuggestions();
expect(esqlVariablesService.areSuggestionsEnabled).toBe(false);
});
});
describe('addVariable', () => {
it('should add a variable', () => {
const variable = {
key: 'my_variable',
value: 'my_value',
type: ESQLVariableType.VALUES,
};
esqlVariablesService.addVariable(variable);
expect(esqlVariablesService.esqlVariables).toEqual([variable]);
});
it('should not add a variable if it already exists', () => {
const variable = {
key: 'my_variable',
value: 'my_value',
type: ESQLVariableType.VALUES,
};
esqlVariablesService.addVariable(variable);
esqlVariablesService.addVariable(variable);
expect(esqlVariablesService.esqlVariables).toEqual([variable]);
});
it('should add a variable with a number value', () => {
const variable = {
key: 'my_variable',
value: 10,
type: ESQLVariableType.VALUES,
};
esqlVariablesService.addVariable(variable);
expect(esqlVariablesService.esqlVariables).toEqual([variable]);
});
});
describe('clearVariables', () => {
it('should clear all variables', () => {
const variable1 = {
key: 'my_variable1',
value: 'my_value1',
type: ESQLVariableType.VALUES,
};
const variable2 = {
key: 'my_variable2',
value: 'my_value2',
type: ESQLVariableType.FIELDS,
};
esqlVariablesService.addVariable(variable1);
esqlVariablesService.addVariable(variable2);
esqlVariablesService.clearVariables();
expect(esqlVariablesService.esqlVariables).toEqual([]);
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 type { ESQLControlVariable } from '@kbn/esql-validation-autocomplete';
export class EsqlVariablesService {
esqlVariables: ESQLControlVariable[] = [];
areSuggestionsEnabled: boolean = false;
enableSuggestions() {
this.areSuggestionsEnabled = true;
}
disableSuggestions() {
this.areSuggestionsEnabled = false;
}
addVariable(variable: ESQLControlVariable): void {
const variables = [...this.esqlVariables];
const variableExists = variables.find((v) => v.key === variable.key);
if (variableExists) {
return;
}
variables.push({
...variable,
value: Number.isNaN(Number(variable.value)) ? variable.value : Number(variable.value),
});
this.esqlVariables = variables;
}
clearVariables() {
this.esqlVariables = [];
}
}

View file

@ -26,7 +26,15 @@
"@kbn/usage-collection-plugin",
"@kbn/content-management-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/esql-validation-autocomplete",
"@kbn/monaco",
"@kbn/esql-ast",
"@kbn/search-types",
"@kbn/react-kibana-context-render",
"@kbn/react-kibana-mount",
"@kbn/core-test-helpers-kbn-server",
"@kbn/i18n-react",
"@kbn/visualization-utils",
],
"exclude": [
"target/**/*",

View file

@ -98,6 +98,7 @@ export interface DatatableColumn {
name: string;
meta: DatatableColumnMeta;
isNull?: boolean;
variable?: string;
}
/**

View file

@ -400,7 +400,6 @@ export class ExpressionsService
public readonly run: ExpressionsServiceStart['run'] = (ast, input, params) => {
this.assertStart();
return this.executor.run(ast, input, params);
};

View file

@ -131,6 +131,7 @@ export const getKibanaContextFn = (
return {
type: 'kibana_context',
query: queries,
esqlVariables: input?.esqlVariables,
filters: uniqFilters(filters.filter((f: Filter) => !f.meta?.disabled)),
timeRange,
};

View file

@ -0,0 +1,19 @@
/*
* 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 { FtrConfigProviderContext } from '@kbn/test';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js'));
return {
...functionalConfig.getAll(),
testFiles: [require.resolve('.')],
};
}

View file

@ -0,0 +1,99 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const kibanaServer = getService('kibanaServer');
const { dashboard, timePicker, common } = getPageObjects(['dashboard', 'timePicker', 'common']);
const testSubjects = getService('testSubjects');
const esql = getService('esql');
const dashboardAddPanel = getService('dashboardAddPanel');
const browser = getService('browser');
const comboBox = getService('comboBox');
const elasticChart = getService('elasticChart');
describe('dashboard - add a field type ES|QL control', function () {
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'
);
await kibanaServer.uiSettings.replace({
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
});
});
after(async () => {
await dashboard.navigateToApp();
await testSubjects.click('discard-unsaved-New-Dashboard');
});
it('should add an ES|QL field control', async () => {
await dashboard.navigateToApp();
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
await dashboard.switchToEditMode();
await dashboardAddPanel.clickEditorMenuButton();
await dashboardAddPanel.clickAddNewPanelFromUIActionLink('ES|QL');
await dashboard.waitForRenderComplete();
await retry.try(async () => {
const panelCount = await dashboard.getPanelCount();
expect(panelCount).to.eql(1);
});
await esql.waitESQLEditorLoaded('InlineEditingESQLEditor');
await retry.waitFor('control flyout to open', async () => {
await esql.typeEsqlEditorQuery(
'FROM logstash* | STATS COUNT(*) BY ',
'InlineEditingESQLEditor'
);
// Wait until suggestions are loaded
await common.sleep(1000);
// Create control is the third suggestion
await browser.pressKeys(browser.keys.ARROW_DOWN);
await browser.pressKeys(browser.keys.ARROW_DOWN);
await browser.pressKeys(browser.keys.ENTER);
return await testSubjects.exists('create_esql_control_flyout');
});
await comboBox.set('esqlFieldsOptions', 'geo.dest');
await comboBox.set('esqlFieldsOptions', 'clientip');
// create the control
await testSubjects.click('saveEsqlControlsFlyoutButton');
await dashboard.waitForRenderComplete();
await retry.try(async () => {
const controlGroupVisible = await testSubjects.exists('controls-group-wrapper');
expect(controlGroupVisible).to.be(true);
});
// Check Lens editor has been updated accordingly
const editorValue = await esql.getEsqlEditorQuery();
expect(editorValue).to.contain('FROM logstash* | STATS COUNT(*) BY ?field');
});
it('should update the Lens chart accordingly', async () => {
await elasticChart.setNewChartUiDebugFlag(true);
// change the control value
await comboBox.set('esqlControlValuesDropdown', 'clientip');
await dashboard.waitForRenderComplete();
const data = await elasticChart.getChartDebugData('xyVisChart');
expect(data?.axes?.x[0]?.title).to.be('clientip');
});
});
}

View file

@ -0,0 +1,34 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, loadTestFile }: FtrProviderContext) {
const browser = getService('browser');
const esArchiver = getService('esArchiver');
async function loadCurrentData() {
await browser.setWindowSize(1300, 900);
await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data');
}
async function unloadCurrentData() {
await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data');
}
describe('dashboard app - esql controls', function () {
before(loadCurrentData);
after(unloadCurrentData);
loadTestFile(require.resolve('./field_control'));
loadTestFile(require.resolve('./interval_control'));
loadTestFile(require.resolve('./value_control'));
});
}

View file

@ -0,0 +1,90 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const kibanaServer = getService('kibanaServer');
const { dashboard, timePicker, common } = getPageObjects(['dashboard', 'timePicker', 'common']);
const testSubjects = getService('testSubjects');
const esql = getService('esql');
const dashboardAddPanel = getService('dashboardAddPanel');
const browser = getService('browser');
const comboBox = getService('comboBox');
describe('dashboard - add an interval type ES|QL control', function () {
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'
);
await kibanaServer.uiSettings.replace({
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
});
});
after(async () => {
await dashboard.navigateToApp();
await testSubjects.click('discard-unsaved-New-Dashboard');
});
it('should add an ES|QL interval control', async () => {
await dashboard.navigateToApp();
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
await dashboard.switchToEditMode();
await dashboardAddPanel.clickEditorMenuButton();
await dashboardAddPanel.clickAddNewPanelFromUIActionLink('ES|QL');
await dashboard.waitForRenderComplete();
await retry.try(async () => {
const panelCount = await dashboard.getPanelCount();
expect(panelCount).to.eql(1);
});
await esql.waitESQLEditorLoaded('InlineEditingESQLEditor');
await retry.waitFor('control flyout to open', async () => {
await esql.typeEsqlEditorQuery(
'FROM logstash* | STATS COUNT(*) BY BUCKET(@timestamp, )',
'InlineEditingESQLEditor'
);
await browser.pressKeys(browser.keys.ARROW_LEFT);
await browser.pressKeys(browser.keys.SPACE);
// Wait until suggestions are loaded
await common.sleep(1000);
// Create control is the first suggestion
await browser.pressKeys(browser.keys.ENTER);
return await testSubjects.exists('create_esql_control_flyout');
});
await comboBox.set('esqlValuesOptions', '1 hour');
await comboBox.set('esqlValuesOptions', '1 day');
// create the control
await testSubjects.click('saveEsqlControlsFlyoutButton');
await dashboard.waitForRenderComplete();
await retry.try(async () => {
const controlGroupVisible = await testSubjects.exists('controls-group-wrapper');
expect(controlGroupVisible).to.be(true);
});
// Check Lens editor has been updated accordingly
const editorValue = await esql.getEsqlEditorQuery();
expect(editorValue).to.contain(
'FROM logstash* | STATS COUNT(*) BY BUCKET(@timestamp, ?interval)'
);
});
});
}

View file

@ -0,0 +1,130 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const kibanaServer = getService('kibanaServer');
const { dashboard, timePicker, common, dashboardControls } = getPageObjects([
'dashboard',
'timePicker',
'common',
'dashboardControls',
]);
const find = getService('find');
const testSubjects = getService('testSubjects');
const esql = getService('esql');
const dashboardAddPanel = getService('dashboardAddPanel');
const browser = getService('browser');
const comboBox = getService('comboBox');
describe('dashboard - add an value type ES|QL control', function () {
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'
);
await kibanaServer.uiSettings.replace({
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
});
});
after(async () => {
await dashboard.navigateToApp();
await testSubjects.click('discard-unsaved-New-Dashboard');
});
it('should add an ES|QL value control', async () => {
await dashboard.navigateToApp();
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
await dashboard.switchToEditMode();
await dashboardAddPanel.clickEditorMenuButton();
await dashboardAddPanel.clickAddNewPanelFromUIActionLink('ES|QL');
await dashboard.waitForRenderComplete();
await retry.try(async () => {
const panelCount = await dashboard.getPanelCount();
expect(panelCount).to.eql(1);
});
await esql.waitESQLEditorLoaded('InlineEditingESQLEditor');
await retry.waitFor('control flyout to open', async () => {
await esql.typeEsqlEditorQuery(
'FROM logstash-* | WHERE geo.dest == ',
'InlineEditingESQLEditor'
);
// Wait until suggestions are loaded
await common.sleep(1000);
// Create control is the first suggestion
await browser.pressKeys(browser.keys.ENTER);
return await testSubjects.exists('create_esql_control_flyout');
});
const valuesQueryEditorValue = await esql.getEsqlEditorQuery();
expect(valuesQueryEditorValue).to.contain('FROM logstash-* | STATS BY geo.dest');
// create the control
await testSubjects.click('saveEsqlControlsFlyoutButton');
await dashboard.waitForRenderComplete();
await retry.try(async () => {
const controlGroupVisible = await testSubjects.exists('controls-group-wrapper');
expect(controlGroupVisible).to.be(true);
});
// Check Lens editor has been updated accordingly
const editorValue = await esql.getEsqlEditorQuery();
expect(editorValue).to.contain('FROM logstash-* | WHERE geo.dest == ?geo_dest');
// change the table to keep only the column with the control
await esql.setEsqlEditorQuery(
'FROM logstash-* | WHERE geo.dest == ?geo_dest | KEEP geo.dest'
);
// run the query
await testSubjects.click('ESQLEditor-run-query-button');
// save the changes
await testSubjects.click('applyFlyoutButton');
});
it('should update the Lens chart accordingly', async () => {
// change the control value
await comboBox.set('esqlControlValuesDropdown', 'AO');
await dashboard.waitForRenderComplete();
const tableContent = await testSubjects.getVisibleText('lnsTableCellContent');
expect(tableContent).to.contain('AO');
});
it('should handle properly a query to retrieve the values that returns more than one column', async () => {
const firstId = (await dashboardControls.getAllControlIds())[0];
await dashboardControls.editExistingControl(firstId);
await esql.setEsqlEditorQuery('FROM logstash-*');
// run the query
await testSubjects.click('ESQLEditor-run-query-button');
expect(await testSubjects.exists('esqlMoreThanOneColumnCallout')).to.be(true);
await testSubjects.click('chooseColumnBtn');
const searchInput = await testSubjects.find('selectableColumnSearch');
await searchInput.type('geo.dest');
const option = await find.byCssSelector('.euiSelectableListItem');
await option.click();
await common.sleep(1000);
const editorValue = await esql.getEsqlEditorQuery();
expect(editorValue).to.contain('FROM logstash-*\n| STATS BY geo.dest');
});
});
}

View file

@ -14,6 +14,7 @@ import { FtrService } from '../ftr_provider_context';
export class ESQLService extends FtrService {
private readonly retry = this.ctx.getService('retry');
private readonly testSubjects = this.ctx.getService('testSubjects');
private readonly monacoEditor = this.ctx.getService('monacoEditor');
/** Ensures that the ES|QL code editor is loaded with a given statement */
public async expectEsqlStatement(statement: string) {
@ -110,4 +111,21 @@ export class ESQLService extends FtrService {
return await this.isOpenQuickReferenceFlyout();
});
}
public async waitESQLEditorLoaded(editorSubjId = 'ESQLEditor') {
await this.monacoEditor.waitCodeEditorReady(editorSubjId);
}
public async getEsqlEditorQuery() {
return await this.monacoEditor.getCodeEditorValue();
}
public async setEsqlEditorQuery(query: string) {
await this.monacoEditor.setCodeEditorValue(query);
}
public async typeEsqlEditorQuery(query: string, editorSubjId = 'ESQLEditor') {
await this.setEsqlEditorQuery(''); // clear the default query
await this.monacoEditor.typeCodeEditorValue(query, editorSubjId);
}
}

View file

@ -880,6 +880,8 @@
"@kbn/esql-validation-autocomplete/*": ["src/platform/packages/shared/kbn-esql-validation-autocomplete/*"],
"@kbn/esql-validation-example-plugin": ["examples/esql_validation_example"],
"@kbn/esql-validation-example-plugin/*": ["examples/esql_validation_example/*"],
"@kbn/esql-variables-types": ["src/platform/packages/shared/kbn-esql-variables-types"],
"@kbn/esql-variables-types/*": ["src/platform/packages/shared/kbn-esql-variables-types/*"],
"@kbn/eui-provider-dev-warning": ["test/plugin_functional/plugins/eui_provider_dev_warning"],
"@kbn/eui-provider-dev-warning/*": ["test/plugin_functional/plugins/eui_provider_dev_warning/*"],
"@kbn/event-annotation-common": ["src/platform/packages/shared/kbn-event-annotation-common"],

View file

@ -330,5 +330,56 @@ describe('map_to_columns', () => {
{ a: 7, b: 8 },
]);
});
it('should handle correctly columns controlled by variables', async () => {
const input: Datatable = {
type: 'datatable',
columns: [
{ id: 'a', name: 'A', meta: { type: 'number' } },
{ id: 'b', name: 'B', meta: { type: 'number' } },
{ id: 'c', name: 'C', meta: { type: 'string' }, variable: 'field' },
],
rows: [
{ a: 1, b: 2, c: '3' },
{ a: 3, b: 4, c: '5' },
{ a: 5, b: 6, c: '7' },
{ a: 7, b: 8, c: '9' },
],
};
const idMap = {
a: [
{
id: 'a',
label: 'A',
},
],
'?field': [
{
id: 'field',
label: '?field',
variable: 'field',
},
],
};
const result = await mapToColumns.fn(
input,
{ idMap: JSON.stringify(idMap), isTextBased: true },
createMockExecutionContext()
);
expect(result.columns).toStrictEqual([
{ id: 'a', name: 'A', meta: { type: 'number', field: undefined, params: undefined } },
{ id: 'field', name: 'C', meta: { type: 'string' }, variable: 'field' },
]);
expect(result.rows).toStrictEqual([
{ a: 1, field: '3' },
{ a: 3, field: '5' },
{ a: 5, field: '7' },
{ a: 7, field: '9' },
]);
});
});
});

View file

@ -5,14 +5,30 @@
* 2.0.
*/
import { DatatableColumn } from '@kbn/expressions-plugin/common';
import type { OriginalColumn, MapToColumnsExpressionFunction } from './types';
export const mapToOriginalColumnsTextBased: MapToColumnsExpressionFunction['fn'] = (
data,
{ idMap: encodedIdMap }
) => {
const isOriginalColumn = (item: OriginalColumn | undefined): item is OriginalColumn => {
return !!item;
};
const idMap = JSON.parse(encodedIdMap) as Record<string, OriginalColumn[]>;
// extract all the entries once
const idMapColEntries = Object.entries(idMap);
// create a lookup id => column
const colLookups = new Map<string, DatatableColumn>(data.columns.map((c) => [c.id, c]));
// now create a lookup to get the original columns for each variable
const colVariableLookups = new Map<string, OriginalColumn[]>(
idMapColEntries.flatMap(([id, columns]) =>
columns.filter(({ variable }) => variable).map(({ variable }) => [`${variable}`, columns])
)
);
return {
...data,
rows: data.rows.map((row) => {
@ -21,7 +37,17 @@ export const mapToOriginalColumnsTextBased: MapToColumnsExpressionFunction['fn']
for (const id in row) {
if (id in idMap) {
for (const cachedEntry of idMap[id]) {
mappedRow[cachedEntry.id] = row[id]; // <= I wrote idMap rather than mappedRow
mappedRow[cachedEntry.id] = row[id];
}
} else {
const col = colLookups.get(id);
if (col?.variable) {
const originalColumn = colVariableLookups.get(col.variable);
if (originalColumn) {
for (const cachedEntry of originalColumn) {
mappedRow[cachedEntry.id] = row[id];
}
}
}
}
}
@ -29,9 +55,20 @@ export const mapToOriginalColumnsTextBased: MapToColumnsExpressionFunction['fn']
return mappedRow;
}),
columns: data.columns.flatMap((column) => {
if (!(column.id in idMap)) {
if (!(column.id in idMap) && !column.variable) {
return [];
}
if (column.variable) {
const originalColumn = idMapColEntries
.map(([_id, columns]) => columns.find((c) => c.variable === column.variable))
.filter(isOriginalColumn);
if (!originalColumn) {
return [];
}
return originalColumn.map((c) => ({ ...column, id: c.id }));
}
return idMap[column.id].map((originalColumn) => ({
...column,
id: originalColumn.id,

View file

@ -8,7 +8,12 @@
import { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common';
export type OriginalColumn = { id: string; label: string; format?: SerializedFieldFormat } & (
export type OriginalColumn = {
id: string;
label: string;
variable?: string;
format?: SerializedFieldFormat;
} & (
| { operationType: 'date_histogram'; sourceField: string }
| { operationType: string; sourceField: never }
);

View file

@ -1,9 +1,7 @@
{
"type": "plugin",
"id": "@kbn/lens-plugin",
"owner": [
"@elastic/kibana-visualizations"
],
"owner": ["@elastic/kibana-visualizations"],
"group": "platform",
"visibility": "shared",
"description": "Visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. Exposes components to embed visualizations and link into the Lens editor from within other apps in Kibana.",
@ -11,10 +9,7 @@
"id": "lens",
"browser": true,
"server": true,
"configPath": [
"xpack",
"lens"
],
"configPath": ["xpack", "lens"],
"requiredPlugins": [
"data",
"dataViews",
@ -62,10 +57,8 @@
"fieldFormats",
"charts",
"esqlDataGrid",
"esql"
"esql",
],
"extraPublicDirs": [
"common/constants"
]
"extraPublicDirs": ["common/constants"]
}
}
}

Some files were not shown because too many files have changed in this diff Show more