mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
# 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\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\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\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:
parent
3ede0fe0db
commit
e8d3a115f4
124 changed files with 4930 additions and 172 deletions
|
@ -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
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
"@kbn/es-types",
|
||||
"@kbn/i18n",
|
||||
"@kbn/datemath",
|
||||
"@kbn/es-query"
|
||||
"@kbn/es-query",
|
||||
"@kbn/esql-validation-autocomplete"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
}
|
||||
))
|
||||
);
|
||||
|
|
|
@ -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[]>;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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[] }>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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]) || [];
|
||||
}
|
||||
|
|
|
@ -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: [],
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/esql-variables-types
|
||||
|
||||
This package contains types important for the ES|QL variables.
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/esql-variables-types",
|
||||
"owner": [
|
||||
"@elastic/kibana-esql",
|
||||
],
|
||||
"group": "platform",
|
||||
"visibility": "shared"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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",
|
||||
]
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -17,9 +17,8 @@
|
|||
"dataViews",
|
||||
"data",
|
||||
"unifiedSearch",
|
||||
"uiActions"
|
||||
"uiActions",
|
||||
],
|
||||
"requiredBundles": [],
|
||||
"extraPublicDirs": [
|
||||
"common"
|
||||
]
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
|
|
@ -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$'> &
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
}
|
|
@ -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$'>;
|
|
@ -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';
|
||||
|
|
|
@ -36,6 +36,7 @@ export {
|
|||
OPTIONS_LIST_CONTROL,
|
||||
RANGE_SLIDER_CONTROL,
|
||||
TIME_SLIDER_CONTROL,
|
||||
ESQL_CONTROL,
|
||||
} from '../common';
|
||||
export type {
|
||||
ControlGroupRuntimeState,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
};
|
|
@ -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 };
|
||||
};
|
||||
};
|
|
@ -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 {};
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 &
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
exports[`interpreter/functions#kibana returns an object with the correct structure 1`] = `
|
||||
Object {
|
||||
"esqlVariables": undefined,
|
||||
"filters": Array [
|
||||
Object {
|
||||
"meta": Object {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
"expressions",
|
||||
"dataViews",
|
||||
"uiActions",
|
||||
"contentManagement"
|
||||
"contentManagement",
|
||||
],
|
||||
"requiredBundles": [
|
||||
"kibanaReact",
|
||||
|
|
|
@ -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 };
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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 can’t edit a control name after it’s 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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
}),
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
40
src/platform/plugins/shared/esql/public/variables_service.ts
Normal file
40
src/platform/plugins/shared/esql/public/variables_service.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
|
@ -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/**/*",
|
||||
|
|
|
@ -98,6 +98,7 @@ export interface DatatableColumn {
|
|||
name: string;
|
||||
meta: DatatableColumnMeta;
|
||||
isNull?: boolean;
|
||||
variable?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -400,7 +400,6 @@ export class ExpressionsService
|
|||
|
||||
public readonly run: ExpressionsServiceStart['run'] = (ast, input, params) => {
|
||||
this.assertStart();
|
||||
|
||||
return this.executor.run(ast, input, params);
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
19
test/functional/apps/dashboard/esql_controls/config.ts
Normal file
19
test/functional/apps/dashboard/esql_controls/config.ts
Normal 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('.')],
|
||||
};
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
34
test/functional/apps/dashboard/esql_controls/index.ts
Normal file
34
test/functional/apps/dashboard/esql_controls/index.ts
Normal 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'));
|
||||
});
|
||||
}
|
|
@ -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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
130
test/functional/apps/dashboard/esql_controls/value_control.ts
Normal file
130
test/functional/apps/dashboard/esql_controls/value_control.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue