From 273fa23e18cb935304751814e00ea463c3a474c3 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 16 Nov 2021 11:30:17 +0100 Subject: [PATCH 01/15] make it work somehow --- .../rename_columns/rename_columns.ts | 4 + .../rename_columns/rename_columns_fn.ts | 6 +- .../editor_frame/data_panel_wrapper.tsx | 9 +- .../editor_frame/editor_frame.tsx | 15 + .../editor_frame/frame_layout.tsx | 110 +- .../workspace_panel/chart_switch.tsx | 18 +- .../public/esdsl_datasource/_datapanel.scss | 67 + .../public/esdsl_datasource/_field_item.scss | 87 + .../lens/public/esdsl_datasource/_index.scss | 4 + .../public/esdsl_datasource/datapanel.tsx | 320 ++++ .../dimension_panel/_field_select.scss | 7 + .../dimension_panel/_index.scss | 2 + .../dimension_panel/_popover.scss | 38 + .../bucket_nesting_editor.test.tsx | 262 +++ .../dimension_panel/bucket_nesting_editor.tsx | 138 ++ .../dimension_panel/dimension_panel.test.tsx | 1422 +++++++++++++++++ .../dimension_panel/dimension_panel.tsx | 234 +++ .../dimension_panel/field_select.tsx | 183 +++ .../dimension_panel/format_selector.tsx | 136 ++ .../esdsl_datasource/dimension_panel/index.ts | 7 + .../dimension_panel/popover_editor.tsx | 374 +++++ .../lens/public/esdsl_datasource/esdsl.tsx | 358 +++++ .../esdsl_datasource/field_item.test.tsx | 237 +++ .../public/esdsl_datasource/field_item.tsx | 538 +++++++ .../lens/public/esdsl_datasource/index.ts | 46 + .../esdsl_datasource/indexpattern.test.ts | 557 +++++++ .../esdsl_datasource/layerpanel.test.tsx | 228 +++ .../public/esdsl_datasource/layerpanel.tsx | 39 + .../esdsl_datasource/lens_field_icon.test.tsx | 24 + .../esdsl_datasource/lens_field_icon.tsx | 20 + .../public/esdsl_datasource/loader.test.ts | 585 +++++++ .../lens/public/esdsl_datasource/loader.ts | 315 ++++ .../lens/public/esdsl_datasource/mocks.ts | 131 ++ .../esdsl_datasource/pure_helpers.test.ts | 15 + .../public/esdsl_datasource/pure_helpers.ts | 15 + .../esdsl_datasource/rename_columns.test.ts | 221 +++ .../public/esdsl_datasource/rename_columns.ts | 101 ++ .../esdsl_datasource/state_helpers.test.ts | 732 +++++++++ .../public/esdsl_datasource/state_helpers.ts | 176 ++ .../public/esdsl_datasource/to_expression.ts | 60 + .../lens/public/esdsl_datasource/types.ts | 28 + .../lens/public/esdsl_datasource/utils.ts | 43 + x-pack/plugins/lens/public/plugin.ts | 4 + .../public/state_management/lens_slice.ts | 21 +- x-pack/plugins/lens/public/types.ts | 4 + 45 files changed, 7871 insertions(+), 70 deletions(-) create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/_datapanel.scss create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/_field_item.scss create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/_index.scss create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_field_select.scss create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_index.scss create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_popover.scss create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/bucket_nesting_editor.test.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/bucket_nesting_editor.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/dimension_panel.test.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/dimension_panel.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/field_select.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/format_selector.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/index.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/popover_editor.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/field_item.test.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/field_item.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/index.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/indexpattern.test.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/layerpanel.test.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/layerpanel.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/lens_field_icon.test.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/lens_field_icon.tsx create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/loader.test.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/loader.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/mocks.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/pure_helpers.test.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/pure_helpers.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/rename_columns.test.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/rename_columns.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/state_helpers.test.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/state_helpers.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/to_expression.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/types.ts create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/utils.ts diff --git a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns.ts b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns.ts index d425d5c80d18d..b9853b78d359c 100644 --- a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns.ts +++ b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns.ts @@ -22,6 +22,10 @@ export const renameColumns: RenameColumnsExpressionFunction = { 'A JSON encoded object in which keys are the old column ids and values are the corresponding new ones. All other columns ids are kept.', }), }, + overwriteTypes: { + types: ['string'], + help: '', + }, }, inputTypes: ['datatable'], async fn(...args) { diff --git a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts index ee0c7ed1eebec..9129bac763142 100644 --- a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts +++ b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts @@ -23,9 +23,10 @@ function getColumnName(originalColumn: OriginalColumn, newColumn: DatatableColum export const renameColumnFn: RenameColumnsExpressionFunction['fn'] = ( data, - { idMap: encodedIdMap } + { idMap: encodedIdMap, overwriteTypes: encodedOverwriteTypes } ) => { const idMap = JSON.parse(encodedIdMap) as Record; + const overwrittenFieldTypes = encodedOverwriteTypes ? JSON.parse(encodedOverwriteTypes) : {}; return { type: 'datatable', @@ -56,6 +57,9 @@ export const renameColumnFn: RenameColumnsExpressionFunction['fn'] = ( ...column, id: mappedItem.id, name: getColumnName(mappedItem, column), + type: overwrittenFieldTypes[column.id] || column.type, + serializedParams: + overwrittenFieldTypes[column.id] === 'date' ? { id: 'date' } : column.serializedParams, }; }), }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index b77d313973432..d1732ce6eba99 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -33,6 +33,7 @@ interface DataPanelWrapperProps { dropOntoWorkspace: (field: DragDropIdentifier) => void; hasSuggestionForField: (field: DragDropIdentifier) => boolean; plugins: { uiActions: UiActionsStart }; + horizontal: boolean; } export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { @@ -93,7 +94,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { return ( <> - {Object.keys(props.datasourceMap).length > 1 && ( + {!props.horizontal && Object.keys(props.datasourceMap).length > 1 && ( { {activeDatasourceId && !datasourceIsLoading && ( )} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index c68c04b4b3e21..e65dead222670 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -93,8 +93,23 @@ export function EditorFrame(props: EditorFrameProps) { showNoDataPopover={props.showNoDataPopover} dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} + horizontal={false} /> } + horizontalDataPanel={ + activeDatasourceId && + datasourceMap[activeDatasourceId].renderHorizontalDataPanel && ( + + ) + } configPanel={ areDatasourcesLoaded && ( - -
- -

- {i18n.translate('xpack.lens.section.dataPanelLabel', { - defaultMessage: 'Data panel', + + {props.horizontalDataPanel && ( + {props.horizontalDataPanel} + )} + + +
+ +

+ {i18n.translate('xpack.lens.section.dataPanelLabel', { + defaultMessage: 'Data panel', + })} +

+
+ {props.dataPanel} +
+
- - {props.dataPanel} -
-
- -

- {i18n.translate('xpack.lens.section.workspaceLabel', { - defaultMessage: 'Visualization workspace', + aria-labelledby="workspaceId" + > + +

+ {i18n.translate('xpack.lens.section.workspaceLabel', { + defaultMessage: 'Visualization workspace', + })} +

+
+ {props.workspacePanel} +
{props.suggestionsPanel}
+

+
- - {props.workspacePanel} -
{props.suggestionsPanel}
-
-
- -

- {i18n.translate('xpack.lens.section.configPanelLabel', { - defaultMessage: 'Config panel', - })} -

-
- {props.configPanel} -
-
+ aria-labelledby="configPanel" + > + +

+ {i18n.translate('xpack.lens.section.configPanelLabel', { + defaultMessage: 'Config panel', + })} +

+
+ {props.configPanel} +

+
+ + ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 250359822e068..01f55c6ff88cd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -125,15 +125,6 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { trackUiEvent(`chart_switch`); - switchToSuggestion( - dispatchLens, - { - ...selection, - visualizationState: selection.getVisualizationState(), - }, - true - ); - if ( (!selection.datasourceId && !selection.sameDatasources) || selection.dataLoss === 'everything' @@ -145,6 +136,15 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { }) ); } + + switchToSuggestion( + dispatchLens, + { + ...selection, + visualizationState: selection.getVisualizationState(), + }, + true + ); }; function getSelection( diff --git a/x-pack/plugins/lens/public/esdsl_datasource/_datapanel.scss b/x-pack/plugins/lens/public/esdsl_datasource/_datapanel.scss new file mode 100644 index 0000000000000..77d4b41a0413c --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/_datapanel.scss @@ -0,0 +1,67 @@ +.lnsInnerIndexPatternDataPanel { + width: 100%; + height: 100%; + padding: $euiSize $euiSize 0; +} + +.lnsInnerIndexPatternDataPanel__header { + display: flex; + align-items: center; + height: $euiSize * 3; + margin-top: -$euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__triggerButton { + @include euiTitle('xs'); + line-height: $euiSizeXXL; +} + +.lnsInnerIndexPatternDataPanel__filterWrapper { + flex-grow: 0; +} + +/** + * 1. Don't cut off the shadow of the field items + */ + +.lnsInnerIndexPatternDataPanel__listWrapper { + @include euiOverflowShadow; + @include euiScrollBar; + margin-left: -$euiSize; /* 1 */ + position: relative; + flex-grow: 1; + overflow: auto; +} + +.lnsInnerIndexPatternDataPanel__list { + padding-top: $euiSizeS; + position: absolute; + top: 0; + left: $euiSize; /* 1 */ + right: $euiSizeXS; /* 1 */ +} + +.lnsInnerIndexPatternDataPanel__filterButton { + width: 100%; + color: $euiColorPrimary; + padding-left: $euiSizeS; + padding-right: $euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__textField { + @include euiFormControlLayoutPadding(1, 'right'); + @include euiFormControlLayoutPadding(1, 'left'); +} + +.lnsInnerIndexPatternDataPanel__filterType { + padding: $euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__filterTypeInner { + display: flex; + align-items: center; + + .lnsFieldListPanel__fieldIcon { + margin-right: $euiSizeS; + } +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/_field_item.scss b/x-pack/plugins/lens/public/esdsl_datasource/_field_item.scss new file mode 100644 index 0000000000000..41919b900c71f --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/_field_item.scss @@ -0,0 +1,87 @@ +.lnsFieldItem { + @include euiFontSizeS; + background: lightOrDarkTheme($euiColorEmptyShade, $euiColorLightestShade); + border-radius: $euiBorderRadius; + margin-bottom: $euiSizeXS; +} + +.lnsFieldItem__popoverAnchor:hover, +.lnsFieldItem__popoverAnchor:focus, +.lnsFieldItem__popoverAnchor:focus-within { + @include euiBottomShadowMedium; + border-radius: $euiBorderRadius; + z-index: 2; +} + +.lnsFieldItem--missing { + background: lightOrDarkTheme(transparentize($euiColorMediumShade, .9), $euiColorEmptyShade); + color: $euiColorDarkShade; +} + +.lnsFieldItem__info { + border-radius: $euiBorderRadius - 1px; + padding: $euiSizeS; + display: flex; + align-items: flex-start; + transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance, + background-color $euiAnimSpeedFast $euiAnimSlightResistance; // sass-lint:disable-line indentation + + .lnsFieldItem__name { + margin-left: $euiSizeS; + flex-grow: 1; + } + + .lnsFieldListPanel__fieldIcon, + .lnsFieldItem__infoIcon { + flex-shrink: 0; + } + + .lnsFieldListPanel__fieldIcon { + margin-top: $euiSizeXS / 2; + margin-right: $euiSizeXS / 2; + } + + .lnsFieldItem__infoIcon { + visibility: hidden; + } + + &:hover, + &:focus { + cursor: grab; + + .lnsFieldItem__infoIcon { + visibility: visible; + } + } +} + +.lnsFieldItem__info-isOpen { + @include euiFocusRing; +} + +.lnsFieldItem__topValue { + margin-bottom: $euiSizeS; + + &:last-of-type { + margin-bottom: 0; + } +} + +.lnsFieldItem__topValueProgress { + background-color: $euiColorLightestShade; + + // sass-lint:disable-block no-vendor-prefixes + &::-webkit-progress-bar { + background-color: $euiColorLightestShade; + } +} + +.lnsFieldItem__fieldPopoverPanel { + min-width: 260px; + max-width: 300px; +} + +.lnsFieldItem__popoverButtonGroup { + // Enforce lowercase for buttons or else some browsers inherit all caps from popover title + text-transform: none; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/_index.scss b/x-pack/plugins/lens/public/esdsl_datasource/_index.scss new file mode 100644 index 0000000000000..e5d8b408e33e5 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/_index.scss @@ -0,0 +1,4 @@ +@import 'datapanel'; +@import 'field_item'; + +@import 'dimension_panel/index'; diff --git a/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx new file mode 100644 index 0000000000000..fd125cc4bb00b --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx @@ -0,0 +1,320 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniq, indexBy } from 'lodash'; +import React, { useState, useEffect, memo, useCallback } from 'react'; +import { + // @ts-ignore + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiContextMenuPanelProps, + EuiPopover, + EuiPopoverTitle, + EuiPopoverFooter, + EuiCallOut, + EuiFormControlLayout, + EuiSwitch, + EuiFacetButton, + EuiIcon, + EuiSpacer, + EuiFormLabel, + EuiButton, + EuiCodeEditor, + EuiAccordion, + EuiPanel, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; +import { IndexPattern, EsDSLPrivateState, IndexPatternField, IndexPatternRef } from './types'; +import { esRawResponse } from '../../../../../src/plugins/data/common'; + +export type Props = DatasourceDataPanelProps & { + data: DataPublicPluginStart; +}; +import { EuiFieldText, EuiSelect } from '@elastic/eui'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { flatten } from './flatten'; + +export function EsDSLDataPanel({ + setState, + state, + dragDropContext, + core, + data, + query, + filters, + dateRange, +}: Props) { + const [localState, setLocalState] = useState(state); + + useEffect(() => { + setLocalState(state); + }, [state]); + + const { layers, removedLayers } = localState; + + return ( + + + + {Object.entries(layers).map(([id, layer]) => ( + + + {localState.cachedFieldList[id]?.fields.length > 0 && + localState.cachedFieldList[id].fields.map((field) => ( +
+ + {field.name} ({field.type}){' '} + + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + overwrittenFieldTypes: { + ...(layer.overwrittenFieldTypes || {}), + [field.name]: e.target.value, + }, + }, + }, + }); + }} + /> +
+ ))} + + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + timeField: e.target.value, + }, + }, + }); + }} + /> + +
+
+ ))} + {state !== localState && ( + + { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + return data.search + .search({ + params: { + size: 0, + index: layer.index, + body: JSON.parse(layer.query), + }, + }) + .toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = esRawResponse.to!.datatable({ + body: response.rawResponse, + }); + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; + }); + setState({ + ...localState, + cachedFieldList, + }); + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }} + > + Apply changes + + + )} +
+
+ ); +} + +export function EsDSLHorizontalDataPanel({ + setState, + state, + dragDropContext, + core, + data, + query, + filters, + dateRange, +}: Props) { + const [localState, setLocalState] = useState(state); + + useEffect(() => { + setLocalState(state); + }, [state]); + + const { layers, removedLayers } = localState; + + return ( + + + + {Object.entries(layers).map(([id, layer]) => ( + + + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + index: e.target.value, + }, + }, + }); + }} + /> + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + query: val, + }, + }, + }); + }} + /> + + + ))} + {Object.entries(removedLayers).map(([id, { layer }]) => ( + + + Currently detached. Add new layers to your visualization to use. + + + + + ))} + {state !== localState && ( + + { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + return data.search + .search({ + params: { + size: 0, + index: layer.index, + body: JSON.parse(layer.query), + }, + }) + .toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = esRawResponse.to!.datatable({ + body: response.rawResponse, + }); + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; + }); + setState({ + ...localState, + cachedFieldList, + }); + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }} + > + Apply changes + + + )} + + + ); +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_field_select.scss b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_field_select.scss new file mode 100644 index 0000000000000..993174f3e6223 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_field_select.scss @@ -0,0 +1,7 @@ +.lnFieldSelect__option--incompatible { + color: $euiColorLightShade; +} + +.lnFieldSelect__option--nonExistant { + background-color: $euiColorLightestShade; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_index.scss b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_index.scss new file mode 100644 index 0000000000000..085a00a2c33c5 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_index.scss @@ -0,0 +1,2 @@ +@import 'field_select'; +@import 'popover'; diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_popover.scss b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_popover.scss new file mode 100644 index 0000000000000..07a72ee1f66fc --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/_popover.scss @@ -0,0 +1,38 @@ +.lnsIndexPatternDimensionEditor { + flex-grow: 1; + line-height: 0; + overflow: hidden; +} + +.lnsIndexPatternDimensionEditor__left, +.lnsIndexPatternDimensionEditor__right { + padding: $euiSizeS; +} + +.lnsIndexPatternDimensionEditor__left { + padding-top: 0; + background-color: $euiPageBackgroundColor; +} + +.lnsIndexPatternDimensionEditor__right { + width: $euiSize * 20; +} + +.lnsIndexPatternDimensionEditor__operation { + @include euiFontSizeS; + color: $euiColorPrimary; + + // TODO: Fix in EUI or don't use EuiSideNav + .euiSideNavItemButton__label { + color: inherit; + } +} + +.lnsIndexPatternDimensionEditor__operation--selected { + font-weight: bold; + color: $euiTextColor; +} + +.lnsIndexPatternDimensionEditor__operation--incompatible { + color: $euiColorMediumShade; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/bucket_nesting_editor.test.tsx b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/bucket_nesting_editor.test.tsx new file mode 100644 index 0000000000000..c6dbb6f617acf --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/bucket_nesting_editor.test.tsx @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { BucketNestingEditor } from './bucket_nesting_editor'; +import { IndexPatternColumn } from '../indexpattern'; + +describe('BucketNestingEditor', () => { + function mockCol(col: Partial = {}): IndexPatternColumn { + const result = { + dataType: 'string', + isBucketed: true, + label: 'a', + operationType: 'terms', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + sourceField: 'a', + suggestedPriority: 0, + ...col, + }; + + return result as IndexPatternColumn; + } + + it('should display the top level grouping when at the root', () => { + const component = mount( + + ); + const control1 = component.find('[data-test-subj="indexPattern-nesting-topLevel"]').first(); + const control2 = component.find('[data-test-subj="indexPattern-nesting-bottomLevel"]').first(); + + expect(control1.prop('checked')).toBeTruthy(); + expect(control2.prop('checked')).toBeFalsy(); + }); + + it('should display the bottom level grouping when appropriate', () => { + const component = mount( + + ); + + const control1 = component.find('[data-test-subj="indexPattern-nesting-topLevel"]').first(); + const control2 = component.find('[data-test-subj="indexPattern-nesting-bottomLevel"]').first(); + + expect(control1.prop('checked')).toBeFalsy(); + expect(control2.prop('checked')).toBeTruthy(); + }); + + it('should reorder the columns when toggled', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + const control1 = component.find('[data-test-subj="indexPattern-nesting-topLevel"]').first(); + + (control1.prop('onChange') as () => {})(); + + expect(setColumns).toHaveBeenCalledTimes(1); + expect(setColumns).toHaveBeenCalledWith(['a', 'b', 'c']); + + component.setProps({ + layer: { + columnOrder: ['a', 'b', 'c'], + columns: { + a: mockCol({ suggestedPriority: 0 }), + b: mockCol({ suggestedPriority: 1 }), + c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: false }), + }, + indexPatternId: 'foo', + }, + }); + + const control2 = component.find('[data-test-subj="indexPattern-nesting-bottomLevel"]').first(); + + (control2.prop('onChange') as () => {})(); + + expect(setColumns).toHaveBeenCalledTimes(2); + expect(setColumns).toHaveBeenLastCalledWith(['b', 'a', 'c']); + }); + + it('should display nothing if there are no buckets', () => { + const component = mount( + + ); + + expect(component.children().length).toBe(0); + }); + + it('should display nothing if there is one bucket', () => { + const component = mount( + + ); + + expect(component.children().length).toBe(0); + }); + + it('should display a dropdown with the parent column selected if 3+ buckets', () => { + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + + expect(control.prop('value')).toEqual('c'); + }); + + it('should reorder the columns when a column is selected in the dropdown', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: 'b' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['c', 'b', 'a']); + }); + + it('should move to root if the first dropdown item is selected', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: '' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['a', 'c', 'b']); + }); + + it('should allow the last bucket to be moved', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: '' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['b', 'c', 'a']); + }); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/bucket_nesting_editor.tsx new file mode 100644 index 0000000000000..82efebb5f2b15 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/bucket_nesting_editor.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiHorizontalRule, EuiRadio, EuiSelect, htmlIdGenerator } from '@elastic/eui'; +import { EsDSLLayer } from '../types'; +import { hasField } from '../utils'; + +const generator = htmlIdGenerator('lens-nesting'); + +function nestColumn(columnOrder: string[], outer: string, inner: string) { + const result = columnOrder.filter(c => c !== inner); + const outerPosition = result.indexOf(outer); + + result.splice(outerPosition + 1, 0, inner); + + return result; +} + +export function BucketNestingEditor({ + columnId, + layer, + setColumns, +}: { + columnId: string; + layer: EsDSLLayer; + setColumns: (columns: string[]) => void; +}) { + const column = layer.columns[columnId]; + const columns = Object.entries(layer.columns); + const aggColumns = columns + .filter(([id, c]) => id !== columnId && c.isBucketed) + .map(([value, c]) => ({ + value, + text: c.label, + fieldName: hasField(c) ? c.sourceField : '', + })); + + if (!column || !column.isBucketed || !aggColumns.length) { + return null; + } + + const fieldName = hasField(column) ? column.sourceField : ''; + + const prevColumn = layer.columnOrder[layer.columnOrder.indexOf(columnId) - 1]; + + if (aggColumns.length === 1) { + const [target] = aggColumns; + + function toggleNesting() { + if (prevColumn) { + setColumns(nestColumn(layer.columnOrder, columnId, target.value)); + } else { + setColumns(nestColumn(layer.columnOrder, target.value, columnId)); + } + } + + return ( + <> + + + <> + + + + + + ); + } + + return ( + <> + + + ({ value, text })), + ]} + value={prevColumn} + onChange={e => setColumns(nestColumn(layer.columnOrder, e.target.value, columnId))} + /> + + + ); +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/dimension_panel.test.tsx new file mode 100644 index 0000000000000..760797ea53020 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/dimension_panel.test.tsx @@ -0,0 +1,1422 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper, ShallowWrapper } from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { EuiComboBox, EuiSideNav, EuiSideNavItemType, EuiFieldNumber } from '@elastic/eui'; +import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { changeColumn } from '../state_helpers'; +import { + IndexPatternDimensionEditorComponent, + IndexPatternDimensionEditorProps, + onDrop, + canHandleDrop, +} from './dimension_panel'; +import { DragContextState } from '../../drag_drop'; +import { createMockedDragDropContext } from '../mocks'; +import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { EsDSLPrivateState } from '../types'; +import { documentField } from '../document_field'; +import { OperationMetadata } from '../../types'; + +jest.mock('../loader'); +jest.mock('../state_helpers'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + hasExistence: true, + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + documentField, + ], + }, +}; + +describe('IndexPatternDimensionEditorPanel', () => { + let state: EsDSLPrivateState; + let setState: jest.Mock; + let defaultProps: IndexPatternDimensionEditorProps; + let dragDropContext: DragContextState; + + beforeEach(() => { + state = { + indexPatternRefs: [], + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: '1', + showEmptyFields: false, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + + setState = jest.fn(); + + dragDropContext = createMockedDragDropContext(); + + defaultProps = { + state, + setState, + dateRange: { fromDate: 'now-1d', toDate: 'now' }, + columnId: 'col1', + layerId: 'first', + uniqueLabel: 'stuff', + filterOperations: () => true, + storage: {} as IStorageWrapper, + uiSettings: {} as IUiSettingsClient, + savedObjectsClient: {} as SavedObjectsClientContract, + http: {} as HttpSetup, + data: ({ + fieldFormats: ({ + getType: jest.fn().mockReturnValue({ + id: 'number', + title: 'Number', + }), + getDefaultType: jest.fn().mockReturnValue({ + id: 'bytes', + title: 'Bytes', + }), + } as unknown) as DataPublicPluginStart['fieldFormats'], + } as unknown) as DataPublicPluginStart, + core: {} as CoreSetup, + }; + + jest.clearAllMocks(); + }); + + describe('Editor component', () => { + let wrapper: ReactWrapper | ShallowWrapper; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + it('should call the filterOperations function', () => { + const filterOperations = jest.fn().mockReturnValue(true); + + wrapper = shallow( + + ); + + expect(filterOperations).toBeCalled(); + }); + + it('should show field select combo box on click', () => { + wrapper = mount(); + + expect( + wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]') + ).toHaveLength(1); + }); + + it('should not show any choices if the filter returns false', () => { + wrapper = mount( + false} + /> + ); + + expect( + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')! + .prop('options')! + ).toHaveLength(0); + }); + + it('should list all field names and document as a whole in prioritized order', () => { + wrapper = mount(); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options).toHaveLength(2); + + expect(options![0].label).toEqual('Records'); + + expect(options![1].options!.map(({ label }) => label)).toEqual([ + 'timestamp', + 'bytes', + 'memory', + 'source', + ]); + }); + + it('should hide fields that have no data', () => { + const props = { + ...defaultProps, + state: { + ...defaultProps.state, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + source: true, + }, + }, + }, + }; + wrapper = mount(); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![1].options!.map(({ label }) => label)).toEqual(['timestamp', 'source']); + }); + + it('should indicate fields which are incompatible for the operation of the current column', () => { + wrapper = mount( + + ); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-Records'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate operations which are incompatible for the field of the current column', () => { + wrapper = mount( + + ); + + interface ItemType { + name: string; + 'data-test-subj': string; + } + const items: Array> = wrapper.find(EuiSideNav).prop('items'); + const options = (items[0].items as unknown) as ItemType[]; + + expect(options.find(({ name }) => name === 'Minimum')!['data-test-subj']).not.toContain( + 'Incompatible' + ); + + expect(options.find(({ name }) => name === 'Date histogram')!['data-test-subj']).toContain( + 'Incompatible' + ); + }); + + it('should keep the operation when switching to another field compatible with this operation', () => { + const initialState: EsDSLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + params: { format: { id: 'bytes' } }, + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'max', + sourceField: 'memory', + params: { format: { id: 'bytes' } }, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should switch operations when selecting a field that requires another operation', () => { + wrapper = mount(); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should keep the field when switching to another operation compatible for this field', () => { + wrapper = mount( + + ); + + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'min', + sourceField: 'bytes', + params: { format: { id: 'bytes' } }, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should not set the state if selecting the currently active operation', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should update label on label input changes', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('input[data-test-subj="indexPattern-label-edit"]') + .simulate('change', { target: { value: 'New Label' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + label: 'New Label', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + describe('transient invalid state', () => { + it('should not set the state if selecting an operation incompatible with the current field', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should show error message in invalid state', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).not.toHaveLength( + 0 + ); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should leave error state if a compatible operation is selected', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); + }); + + it('should indicate fields compatible with selected operation', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should select compatible operation if field not compatible with selected operation', () => { + wrapper = mount( + + ); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]'); + const options = comboBox.prop('options'); + + // options[1][2] is a `source` field of type `string` which doesn't support `avg` operation + act(() => { + comboBox.prop('onChange')!([options![1].options![2]]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'source', + operationType: 'terms', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select the Records field when count is selected', () => { + const initialState: EsDSLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'bytes', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-count"]') + .simulate('click'); + + const newColumnState = setState.mock.calls[0][0].layers.first.columns.col2; + expect(newColumnState.operationType).toEqual('count'); + expect(newColumnState.sourceField).toEqual('Records'); + }); + + it('should indicate document and field compatibility with selected document operation', () => { + const initialState: EsDSLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'count', + sourceField: 'Records', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should set datasource state if compatible field is selected for operation', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + }); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox + .prop('options')![1] + .options!.find(({ label }) => label === 'source')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + sourceField: 'source', + operationType: 'terms', + }), + }, + }, + }, + }); + }); + }); + + it('should support selecting the operation before the field', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]'); + const options = comboBox.prop('options'); + + act(() => { + comboBox.prop('onChange')!([options![1].options![0]]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select operation directly if only one field is possible', () => { + const initialState = { + ...state, + indexPatterns: { + 1: { + ...state.indexPatterns['1'], + fields: state.indexPatterns['1'].fields.filter(field => field.name !== 'memory'), + }, + }, + }; + + wrapper = mount( + + ); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...initialState.layers.first, + columns: { + ...initialState.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select operation directly if only document is possible', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-count"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + operationType: 'count', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should indicate compatible fields when selecting the operation first', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'bytes')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate document compatibility when document operation is selected', () => { + const initialState: EsDSLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'count', + sourceField: 'Records', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).not.toContain('Incompatible'); + + options![1].options!.map(operation => + expect(operation['data-test-subj']).toContain('Incompatible') + ); + }); + + it('should show all operations that are not filtered out', () => { + wrapper = mount( + !op.isBucketed && op.dataType === 'number'} + /> + ); + + interface ItemType { + name: React.ReactNode; + } + const items: Array> = wrapper.find(EuiSideNav).prop('items'); + const options = (items[0].items as unknown) as ItemType[]; + + expect(options.map(({ name }: { name: React.ReactNode }) => name)).toEqual([ + 'Unique count', + 'Average', + 'Count', + 'Maximum', + 'Minimum', + 'Sum', + ]); + }); + + it('should add a column on selection of a field', () => { + wrapper = mount(); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options![0]; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should use helper function when changing the function', () => { + const initialState: EsDSLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + act(() => { + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); + }); + + expect(changeColumn).toHaveBeenCalledWith({ + state: initialState, + columnId: 'col1', + layerId: 'first', + newColumn: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'min', + }), + }); + }); + + it('should clear the dimension when removing the selection in field combobox', () => { + wrapper = mount(); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('onChange')!([]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + it('allows custom format', () => { + const stateWithNumberCol: EsDSLPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: 'bytes', label: 'Bytes' }]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }), + }, + }, + }, + }); + }); + + it('keeps decimal places while switching', () => { + const stateWithNumberCol: EsDSLPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + params: { + format: { id: 'bytes', params: { decimals: 0 } }, + }, + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: '', label: 'Default' }]); + }); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: 'number', label: 'Number' }]); + }); + + expect( + wrapper + .find(EuiFieldNumber) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('value') + ).toEqual(0); + }); + + it('allows custom format with number of decimal places', () => { + const stateWithNumberCol: EsDSLPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiFieldNumber) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('onChange')!({ target: { value: '0' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + params: { + format: { id: 'bytes', params: { decimals: 0 } }, + }, + }), + }, + }, + }, + }); + }); + }); + + describe('Drag and drop', () => { + function dragDropState(): EsDSLPrivateState { + return { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: { + foo: { + id: 'foo', + title: 'Foo pattern', + fields: [ + { + aggregatable: true, + name: 'bar', + searchable: true, + type: 'number', + }, + { + aggregatable: true, + name: 'mystring', + searchable: true, + type: 'string', + }, + ], + }, + }, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + myLayer: { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + } + + it('is not droppable if no drag is happening', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext, + state: dragDropState(), + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is not droppable if the dragged item has no field', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { name: 'bar' }, + }, + }) + ).toBe(false); + }); + + it('is not droppable if field is not supported by filterOperations', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + indexPatternId: 'foo', + field: { type: 'string', name: 'mystring', aggregatable: true }, + }, + }, + state: dragDropState(), + filterOperations: () => false, + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is droppable if the field is supported by filterOperations', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }, + }, + state: dragDropState(), + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(true); + }); + + it('is not droppable if the field belongs to another index pattern', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo2', + }, + }, + state: dragDropState(), + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('appends the dropped column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }, + }, + }, + }); + }); + + it('selects the specific operation that was valid on drop', () => { + const dragging = { + field: { type: 'string', name: 'mystring', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + dataType: 'string', + sourceField: 'mystring', + }), + }, + }, + }, + }); + }); + + it('updates a column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }), + }), + }, + }); + }); + + it('does not set the size of the terms aggregation', () => { + const dragging = { + field: { type: 'string', name: 'mystring', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + operationType: 'terms', + params: expect.objectContaining({ size: 3 }), + }), + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/dimension_panel.tsx new file mode 100644 index 0000000000000..36cccfad670cd --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/dimension_panel.tsx @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiLink } from '@elastic/eui'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { + DatasourceDimensionTriggerProps, + DatasourceDimensionEditorProps, + DatasourceDimensionDropProps, + DatasourceDimensionDropHandlerProps, +} from '../../types'; +import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { IndexPatternColumn, OperationType } from '../indexpattern'; +import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations'; +import { PopoverEditor } from './popover_editor'; +import { changeColumn } from '../state_helpers'; +import { isDraggedField, hasField } from '../utils'; +import { EsDSLPrivateState, IndexPatternField } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { DateRange } from '../../../common'; + +export type IndexPatternDimensionTriggerProps = DatasourceDimensionTriggerProps< + EsDSLPrivateState +> & { + uniqueLabel: string; +}; + +export type IndexPatternDimensionEditorProps = DatasourceDimensionEditorProps< + EsDSLPrivateState +> & { + uiSettings: IUiSettingsClient; + storage: IStorageWrapper; + savedObjectsClient: SavedObjectsClientContract; + layerId: string; + http: HttpSetup; + data: DataPublicPluginStart; + uniqueLabel: string; + dateRange: DateRange; +}; + +export interface OperationFieldSupportMatrix { + operationByField: Partial>; + fieldByOperation: Partial>; +} + +type Props = Pick< + DatasourceDimensionDropProps, + 'layerId' | 'columnId' | 'state' | 'filterOperations' +>; + +// TODO: This code has historically been memoized, as a potentially performance +// sensitive task. If we can add memoization without breaking the behavior, we should. +const getOperationFieldSupportMatrix = (props: Props): OperationFieldSupportMatrix => { + const layerId = props.layerId; + const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId]; + + const filteredOperationsByMetadata = getAvailableOperationsByMetadata( + currentIndexPattern + ).filter(operation => props.filterOperations(operation.operationMetaData)); + + const supportedOperationsByField: Partial> = {}; + const supportedFieldsByOperation: Partial> = {}; + + filteredOperationsByMetadata.forEach(({ operations }) => { + operations.forEach(operation => { + if (supportedOperationsByField[operation.field]) { + supportedOperationsByField[operation.field]!.push(operation.operationType); + } else { + supportedOperationsByField[operation.field] = [operation.operationType]; + } + + if (supportedFieldsByOperation[operation.operationType]) { + supportedFieldsByOperation[operation.operationType]!.push(operation.field); + } else { + supportedFieldsByOperation[operation.operationType] = [operation.field]; + } + }); + }); + return { + operationByField: _.mapValues(supportedOperationsByField, _.uniq), + fieldByOperation: _.mapValues(supportedFieldsByOperation, _.uniq), + }; +}; + +export function canHandleDrop(props: DatasourceDimensionDropProps) { + const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); + + const { dragging } = props.dragDropContext; + const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; + + function hasOperationForField(field: IndexPatternField) { + return Boolean(operationFieldSupportMatrix.operationByField[field.name]); + } + + return ( + isDraggedField(dragging) && + layerIndexPatternId === dragging.indexPatternId && + Boolean(hasOperationForField(dragging.field)) + ); +} + +export function onDrop( + props: DatasourceDimensionDropHandlerProps +): boolean { + const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); + const droppedItem = props.droppedItem; + + function hasOperationForField(field: IndexPatternField) { + return Boolean(operationFieldSupportMatrix.operationByField[field.name]); + } + + if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { + // TODO: What do we do if we couldn't find a column? + return false; + } + + const operationsForNewField = + operationFieldSupportMatrix.operationByField[droppedItem.field.name]; + + const layerId = props.layerId; + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + const currentIndexPattern = + props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId]; + + // We need to check if dragging in a new field, was just a field change on the same + // index pattern and on the same operations (therefore checking if the new field supports + // our previous operation) + const hasFieldChanged = + selectedColumn && + hasField(selectedColumn) && + selectedColumn.sourceField !== droppedItem.field.name && + operationsForNewField && + operationsForNewField.includes(selectedColumn.operationType); + + if (!operationsForNewField || operationsForNewField.length === 0) { + return false; + } + + // If only the field has changed use the onFieldChange method on the operation to get the + // new column, otherwise use the regular buildColumn to get a new column. + const newColumn = hasFieldChanged + ? changeField(selectedColumn, currentIndexPattern, droppedItem.field) + : buildColumn({ + op: operationsForNewField[0], + columns: props.state.layers[props.layerId].columns, + indexPattern: currentIndexPattern, + layerId, + suggestedPriority: props.suggestedPriority, + field: droppedItem.field, + previousColumn: selectedColumn, + }); + + trackUiEvent('drop_onto_dimension'); + const hasData = Object.values(props.state.layers).some(({ columns }) => columns.length); + trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); + + props.setState( + changeColumn({ + state: props.state, + layerId, + columnId: props.columnId, + newColumn, + // If the field has changed, the onFieldChange method needs to take care of everything including moving + // over params. If we create a new column above we want changeColumn to move over params. + keepParams: !hasFieldChanged, + }) + ); + + return true; +} + +export const IndexPatternDimensionTriggerComponent = function IndexPatternDimensionTrigger( + props: IndexPatternDimensionTriggerProps +) { + const layerId = props.layerId; + + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + + const { columnId, uniqueLabel } = props; + if (!selectedColumn) { + return null; + } + return ( + { + props.togglePopover(); + }} + data-test-subj="lns-dimensionTrigger" + aria-label={i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit configuration', + })} + title={i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit configuration', + })} + > + {uniqueLabel} + + ); +}; + +export const IndexPatternDimensionEditorComponent = function IndexPatternDimensionPanel( + props: IndexPatternDimensionEditorProps +) { + const layerId = props.layerId; + const currentIndexPattern = + props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId]; + const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); + + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + + return ( + + ); +}; + +export const IndexPatternDimensionTrigger = memo(IndexPatternDimensionTriggerComponent); +export const IndexPatternDimensionEditor = memo(IndexPatternDimensionEditorComponent); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/field_select.tsx new file mode 100644 index 0000000000000..4a0e1d91259dc --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/field_select.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiComboBoxOptionOption } from '@elastic/eui'; +import classNames from 'classnames'; +import { EuiHighlight } from '@elastic/eui'; +import { OperationType } from '../indexpattern'; +import { LensFieldIcon } from '../lens_field_icon'; +import { DataType } from '../../types'; +import { OperationFieldSupportMatrix } from './dimension_panel'; +import { IndexPattern, IndexPatternField, EsDSLPrivateState } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { fieldExists } from '../pure_helpers'; + +export interface FieldChoice { + type: 'field'; + field: string; + operationType?: OperationType; +} + +export interface FieldSelectProps { + currentIndexPattern: IndexPattern; + showEmptyFields: boolean; + fieldMap: Record; + incompatibleSelectedOperationType: OperationType | null; + selectedColumnOperationType?: OperationType; + selectedColumnSourceField?: string; + operationFieldSupportMatrix: OperationFieldSupportMatrix; + onChoose: (choice: FieldChoice) => void; + onDeleteColumn: () => void; + existingFields: EsDSLPrivateState['existingFields']; +} + +export function FieldSelect({ + currentIndexPattern, + showEmptyFields, + fieldMap, + incompatibleSelectedOperationType, + selectedColumnOperationType, + selectedColumnSourceField, + operationFieldSupportMatrix, + onChoose, + onDeleteColumn, + existingFields, +}: FieldSelectProps) { + const { operationByField } = operationFieldSupportMatrix; + const memoizedFieldOptions = useMemo(() => { + const fields = Object.keys(operationByField).sort(); + + function isCompatibleWithCurrentOperation(fieldName: string) { + if (incompatibleSelectedOperationType) { + return operationByField[fieldName]!.includes(incompatibleSelectedOperationType); + } + return ( + !selectedColumnOperationType || + operationByField[fieldName]!.includes(selectedColumnOperationType) + ); + } + + const [specialFields, normalFields] = _.partition( + fields, + field => fieldMap[field].type === 'document' + ); + + function fieldNamesToOptions(items: string[]) { + return items + .map(field => ({ + label: field, + value: { + type: 'field', + field, + dataType: fieldMap[field].type, + operationType: + selectedColumnOperationType && isCompatibleWithCurrentOperation(field) + ? selectedColumnOperationType + : undefined, + }, + exists: + fieldMap[field].type === 'document' || + fieldExists(existingFields, currentIndexPattern.title, field), + compatible: isCompatibleWithCurrentOperation(field), + })) + .filter(field => showEmptyFields || field.exists) + .sort((a, b) => { + if (a.compatible && !b.compatible) { + return -1; + } + if (!a.compatible && b.compatible) { + return 1; + } + return 0; + }) + .map(({ label, value, compatible, exists }) => ({ + label, + value, + className: classNames({ + 'lnFieldSelect__option--incompatible': !compatible, + 'lnFieldSelect__option--nonExistant': !exists, + }), + 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${label}`, + })); + } + + const fieldOptions: unknown[] = fieldNamesToOptions(specialFields); + + if (fields.length > 0) { + fieldOptions.push({ + label: i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', { + defaultMessage: 'Individual fields', + }), + options: fieldNamesToOptions(normalFields), + }); + } + + return fieldOptions; + }, [ + incompatibleSelectedOperationType, + selectedColumnOperationType, + selectedColumnSourceField, + operationFieldSupportMatrix, + currentIndexPattern, + fieldMap, + showEmptyFields, + ]); + + return ( + { + if (choices.length === 0) { + onDeleteColumn(); + return; + } + + trackUiEvent('indexpattern_dimension_field_changed'); + + onChoose((choices[0].value as unknown) as FieldChoice); + }} + renderOption={(option, searchValue) => { + return ( + + + + + + {option.label} + + + ); + }} + /> + ); +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/format_selector.tsx b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/format_selector.tsx new file mode 100644 index 0000000000000..ed68a93c51ca2 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/format_selector.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiFieldNumber, EuiComboBox } from '@elastic/eui'; +import { IndexPatternColumn } from '../indexpattern'; + +const supportedFormats: Record = { + number: { + title: i18n.translate('xpack.lens.indexPattern.numberFormatLabel', { + defaultMessage: 'Number', + }), + }, + percent: { + title: i18n.translate('xpack.lens.indexPattern.percentFormatLabel', { + defaultMessage: 'Percent', + }), + }, + bytes: { + title: i18n.translate('xpack.lens.indexPattern.bytesFormatLabel', { + defaultMessage: 'Bytes (1024)', + }), + }, +}; + +interface FormatSelectorProps { + selectedColumn: IndexPatternColumn; + onChange: (newFormat?: { id: string; params?: Record }) => void; +} + +interface State { + decimalPlaces: number; +} + +export function FormatSelector(props: FormatSelectorProps) { + const { selectedColumn, onChange } = props; + + const currentFormat = + 'params' in selectedColumn && selectedColumn.params && 'format' in selectedColumn.params + ? selectedColumn.params.format + : undefined; + const [state, setState] = useState({ + decimalPlaces: + typeof currentFormat?.params?.decimals === 'number' ? currentFormat.params.decimals : 2, + }); + + const selectedFormat = currentFormat?.id ? supportedFormats[currentFormat.id] : undefined; + + const defaultOption = { + value: '', + label: i18n.translate('xpack.lens.indexPattern.defaultFormatLabel', { + defaultMessage: 'Default', + }), + }; + + return ( + <> + + ({ + value: id, + label: format.title ?? id, + })), + ]} + selectedOptions={ + currentFormat + ? [ + { + value: currentFormat.id, + label: selectedFormat?.title ?? currentFormat.id, + }, + ] + : [defaultOption] + } + onChange={choices => { + if (choices.length === 0) { + return; + } + + if (!choices[0].value) { + onChange(); + return; + } + onChange({ + id: choices[0].value, + params: { decimals: state.decimalPlaces }, + }); + }} + /> + + + {currentFormat ? ( + + { + setState({ decimalPlaces: Number(e.target.value) }); + onChange({ + id: (selectedColumn.params as { format: { id: string } }).format.id, + params: { + decimals: Number(e.target.value), + }, + }); + }} + compressed + fullWidth + /> + + ) : null} + + ); +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/index.ts b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/index.ts new file mode 100644 index 0000000000000..88e5588ce0e01 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dimension_panel'; diff --git a/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/popover_editor.tsx b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/popover_editor.tsx new file mode 100644 index 0000000000000..e26c338b6e240 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/dimension_panel/popover_editor.tsx @@ -0,0 +1,374 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiSideNav, + EuiCallOut, + EuiFormRow, + EuiFieldText, + EuiSpacer, +} from '@elastic/eui'; +import classNames from 'classnames'; +import { IndexPatternColumn, OperationType } from '../indexpattern'; +import { IndexPatternDimensionEditorProps, OperationFieldSupportMatrix } from './dimension_panel'; +import { + operationDefinitionMap, + getOperationDisplay, + buildColumn, + changeField, +} from '../operations'; +import { deleteColumn, changeColumn, updateColumnParam } from '../state_helpers'; +import { FieldSelect } from './field_select'; +import { hasField } from '../utils'; +import { BucketNestingEditor } from './bucket_nesting_editor'; +import { IndexPattern, IndexPatternField } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { FormatSelector } from './format_selector'; + +const operationPanels = getOperationDisplay(); + +export interface PopoverEditorProps extends IndexPatternDimensionEditorProps { + selectedColumn?: IndexPatternColumn; + operationFieldSupportMatrix: OperationFieldSupportMatrix; + currentIndexPattern: IndexPattern; +} + +function asOperationOptions(operationTypes: OperationType[], compatibleWithCurrentField: boolean) { + return [...operationTypes] + .sort((opType1, opType2) => { + return operationPanels[opType1].displayName.localeCompare( + operationPanels[opType2].displayName + ); + }) + .map(operationType => ({ + operationType, + compatibleWithCurrentField, + })); +} + +export function PopoverEditor(props: PopoverEditorProps) { + const { + selectedColumn, + operationFieldSupportMatrix, + state, + columnId, + setState, + layerId, + currentIndexPattern, + hideGrouping, + } = props; + const { operationByField, fieldByOperation } = operationFieldSupportMatrix; + const [ + incompatibleSelectedOperationType, + setInvalidOperationType, + ] = useState(null); + + const ParamEditor = + selectedColumn && operationDefinitionMap[selectedColumn.operationType].paramEditor; + + const fieldMap: Record = useMemo(() => { + const fields: Record = {}; + currentIndexPattern.fields.forEach(field => { + fields[field.name] = field; + }); + return fields; + }, [currentIndexPattern]); + + function getOperationTypes() { + const possibleOperationTypes = Object.keys(fieldByOperation) as OperationType[]; + const validOperationTypes: OperationType[] = []; + + if (!selectedColumn) { + validOperationTypes.push(...(Object.keys(fieldByOperation) as OperationType[])); + } else if (hasField(selectedColumn) && operationByField[selectedColumn.sourceField]) { + validOperationTypes.push(...operationByField[selectedColumn.sourceField]!); + } + + return _.uniq( + [ + ...asOperationOptions(validOperationTypes, true), + ...asOperationOptions(possibleOperationTypes, false), + ], + 'operationType' + ); + } + + function getSideNavItems() { + return [ + { + name: '', + id: '0', + items: getOperationTypes().map(({ operationType, compatibleWithCurrentField }) => ({ + name: operationPanels[operationType].displayName, + id: operationType as string, + className: classNames('lnsIndexPatternDimensionEditor__operation', { + 'lnsIndexPatternDimensionEditor__operation--selected': Boolean( + incompatibleSelectedOperationType === operationType || + (!incompatibleSelectedOperationType && + selectedColumn && + selectedColumn.operationType === operationType) + ), + 'lnsIndexPatternDimensionEditor__operation--incompatible': !compatibleWithCurrentField, + }), + 'data-test-subj': `lns-indexPatternDimension${ + compatibleWithCurrentField ? '' : 'Incompatible' + }-${operationType}`, + onClick() { + if (!selectedColumn || !compatibleWithCurrentField) { + const possibleFields = fieldByOperation[operationType] || []; + + if (possibleFields.length === 1) { + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: buildColumn({ + columns: props.state.layers[props.layerId].columns, + suggestedPriority: props.suggestedPriority, + layerId: props.layerId, + op: operationType, + indexPattern: currentIndexPattern, + field: fieldMap[possibleFields[0]], + previousColumn: selectedColumn, + }), + }) + ); + } else { + setInvalidOperationType(operationType); + } + trackUiEvent(`indexpattern_dimension_operation_${operationType}`); + return; + } + if (incompatibleSelectedOperationType) { + setInvalidOperationType(null); + } + if (selectedColumn.operationType === operationType) { + return; + } + const newColumn: IndexPatternColumn = buildColumn({ + columns: props.state.layers[props.layerId].columns, + suggestedPriority: props.suggestedPriority, + layerId: props.layerId, + op: operationType, + indexPattern: currentIndexPattern, + field: fieldMap[selectedColumn.sourceField], + previousColumn: selectedColumn, + }); + + trackUiEvent( + `indexpattern_dimension_operation_from_${selectedColumn.operationType}_to_${operationType}` + ); + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn, + }) + ); + }, + })), + }, + ]; + } + + return ( +
+ + + { + setState( + deleteColumn({ + state, + layerId, + columnId, + }) + ); + }} + onChoose={choice => { + let column: IndexPatternColumn; + if ( + !incompatibleSelectedOperationType && + selectedColumn && + 'field' in choice && + choice.operationType === selectedColumn.operationType + ) { + // If we just changed the field are not in an error state and the operation didn't change, + // we use the operations onFieldChange method to calculate the new column. + column = changeField(selectedColumn, currentIndexPattern, fieldMap[choice.field]); + } else { + // Otherwise we'll use the buildColumn method to calculate a new column + const compatibleOperations = + ('field' in choice && + operationFieldSupportMatrix.operationByField[choice.field]) || + []; + let operation; + if (compatibleOperations.length > 0) { + operation = + incompatibleSelectedOperationType && + compatibleOperations.includes(incompatibleSelectedOperationType) + ? incompatibleSelectedOperationType + : compatibleOperations[0]; + } else if ('field' in choice) { + operation = choice.operationType; + } + column = buildColumn({ + columns: props.state.layers[props.layerId].columns, + field: fieldMap[choice.field], + indexPattern: currentIndexPattern, + layerId: props.layerId, + suggestedPriority: props.suggestedPriority, + op: operation as OperationType, + previousColumn: selectedColumn, + }); + } + + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: column, + keepParams: false, + }) + ); + setInvalidOperationType(null); + }} + /> + + + + + + + + {incompatibleSelectedOperationType && selectedColumn && ( + + )} + {incompatibleSelectedOperationType && !selectedColumn && ( + + )} + {!incompatibleSelectedOperationType && ParamEditor && ( + <> + + + + )} + {!incompatibleSelectedOperationType && selectedColumn && ( + + { + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: { + ...selectedColumn, + label: e.target.value, + }, + }) + ); + }} + /> + + )} + + {!hideGrouping && ( + { + setState({ + ...state, + layers: { + ...state.layers, + [props.layerId]: { + ...state.layers[props.layerId], + columnOrder, + }, + }, + }); + }} + /> + )} + + {selectedColumn && selectedColumn.dataType === 'number' ? ( + { + setState( + updateColumnParam({ + state, + layerId, + currentColumn: selectedColumn, + paramName: 'format', + value: newFormat, + }) + ); + }} + /> + ) : null} + + + + +
+ ); +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx new file mode 100644 index 0000000000000..fd192cc08a6de --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx @@ -0,0 +1,358 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import _ from 'lodash'; +import React from 'react'; +import { render } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { EuiButton, EuiSelect } from '@elastic/eui'; +import { + DatasourceDimensionEditorProps, + DatasourceDimensionTriggerProps, + DatasourceDataPanelProps, + Operation, + DatasourceLayerPanelProps, + PublicAPIProps, + DataType, +} from '../types'; +import { toExpression } from './to_expression'; +import { EsDSLDataPanel, EsDSLHorizontalDataPanel } from './datapanel'; + +import { EsDSLLayer, EsDSLPrivateState, EsDSLPersistedState } from './types'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { Datasource } from '../types'; +import { esRawResponse } from '../../../../../src/plugins/data/common'; + +export function getEsDSLDatasource({ + core, + storage, + data, +}: { + core: CoreStart; + storage: IStorageWrapper; + data: DataPublicPluginStart; +}) { + // Not stateful. State is persisted to the frame + const esdslDatasource: Datasource = { + id: 'esdsl', + + checkIntegrity: () => { + return []; + }, + getErrorMessages: () => { + return []; + }, + async initialize(state?: EsDSLPersistedState) { + const initState = state || { layers: {} }; + const responses = await Promise.all( + Object.entries(initState.layers).map(([id, layer]) => { + return data.search + .search({ + params: { + size: 0, + index: layer.index, + body: JSON.parse(layer.query), + }, + }) + .toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(initState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = esRawResponse.to!.datatable({ body: response.rawResponse }); + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; + }); + return { + ...initState, + cachedFieldList, + removedLayers: [], + }; + }, + + getPersistableState({ layers }: EsDSLPrivateState) { + return { state: { layers }, savedObjectReferences: [] }; + }, + isValidColumn() { + return true; + }, + insertLayer(state: EsDSLPrivateState, newLayerId: string) { + const removedLayer = state.removedLayers[0]; + const newRemovedList = removedLayer ? state.removedLayers.slice(1) : state.removedLayers; + return { + ...state, + cachedFieldList: { + ...state.cachedFieldList, + [newLayerId]: removedLayer + ? removedLayer.fieldList + : { + fields: [], + singleRow: false, + }, + }, + layers: { + ...state.layers, + [newLayerId]: removedLayer ? removedLayer.layer : blankLayer(), + }, + removedLayers: newRemovedList, + }; + }, + + removeLayer(state: EsDSLPrivateState, layerId: string) { + const deletedLayer = state.layers[layerId]; + const newLayers = { ...state.layers }; + delete newLayers[layerId]; + + const deletedFieldList = state.cachedFieldList[layerId]; + const newFieldList = { ...state.cachedFieldList }; + delete newFieldList[layerId]; + + return { + ...state, + layers: newLayers, + cachedFieldList: newFieldList, + removedLayers: deletedLayer.query + ? [ + { layer: { ...deletedLayer, columns: [] }, fieldList: deletedFieldList }, + ...state.removedLayers, + ] + : state.removedLayers, + }; + }, + + clearLayer(state: EsDSLPrivateState, layerId: string) { + return { + ...state, + layers: { + ...state.layers, + [layerId]: { ...state.layers[layerId], columns: [] }, + }, + }; + }, + + getLayers(state: EsDSLPrivateState) { + return Object.keys(state.layers); + }, + + removeColumn({ prevState, layerId, columnId }) { + return { + ...prevState, + layers: { + ...prevState.layers, + [layerId]: { + ...prevState.layers[layerId], + columns: prevState.layers[layerId].columns.filter((col) => col.columnId !== columnId), + }, + }, + }; + }, + + toExpression, + + getMetaData(state: EsDSLPrivateState) { + return { + filterableIndexPatterns: [], + }; + }, + + renderDataPanel(domElement: Element, props: DatasourceDataPanelProps) { + render( + + + , + domElement + ); + }, + + renderHorizontalDataPanel( + domElement: Element, + props: DatasourceDataPanelProps + ) { + render( + + + , + domElement + ); + }, + + renderDimensionTrigger: ( + domElement: Element, + props: DatasourceDimensionTriggerProps + ) => { + const selectedField = props.state.layers[props.layerId].columns.find( + (column) => column.columnId === props.columnId + )!; + render( {}}>{selectedField.fieldName}, domElement); + }, + + renderDimensionEditor: ( + domElement: Element, + props: DatasourceDimensionEditorProps + ) => { + const fields = props.state.cachedFieldList[props.layerId].fields; + const selectedField = props.state.layers[props.layerId].columns.find( + (column) => column.columnId === props.columnId + ); + render( + ({ value: field.name, text: field.name })), + ]} + onChange={(e) => { + props.setState( + !selectedField + ? { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: [ + ...props.state.layers[props.layerId].columns, + { columnId: props.columnId, fieldName: e.target.value }, + ], + }, + }, + } + : { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: props.state.layers[props.layerId].columns.map((col) => + col.columnId !== props.columnId + ? col + : { ...col, fieldName: e.target.value } + ), + }, + }, + } + ); + }} + />, + domElement + ); + }, + + renderLayerPanel: ( + domElement: Element, + props: DatasourceLayerPanelProps + ) => { + render({props.state.layers[props.layerId].index}, domElement); + }, + + canHandleDrop: () => false, + onDrop: () => false, + uniqueLabels(state: EsDSLPrivateState) { + const layers = state.layers; + const columnLabelMap = {} as Record; + + Object.values(layers).forEach((layer) => { + if (!layer.columns) { + return; + } + Object.entries(layer.columns).forEach(([columnId, column]) => { + columnLabelMap[columnId] = columnId; + }); + }); + + return columnLabelMap; + }, + + getDropProps: () => undefined, + + getPublicAPI({ state, layerId }: PublicAPIProps) { + return { + datasourceId: 'esdsl', + + getTableSpec: () => { + return ( + state.layers[layerId]?.columns.map((column) => ({ columnId: column.columnId })) || [] + ); + }, + getOperationForColumnId: (columnId: string) => { + const layer = state.layers[layerId]; + const column = layer?.columns.find((c) => c.columnId === columnId); + + if (column) { + const field = state.cachedFieldList[layerId].fields.find( + (f) => f.name === column.fieldName + )!; + const overwrite = layer.overwrittenFieldTypes?.[column.fieldName]; + return { + dataType: overwrite || (field.meta.type as DataType), + label: field.name, + isBucketed: false, + }; + } + return null; + }, + }; + }, + getDatasourceSuggestionsForField(state, draggedField) { + return []; + }, + getDatasourceSuggestionsFromCurrentState: (state) => { + return Object.entries(state.layers).map(([id, layer]) => { + const reducedState: EsDSLPrivateState = { + ...state, + cachedFieldList: { + [id]: state.cachedFieldList[id], + }, + layers: { + [id]: state.layers[id], + }, + }; + return { + state: reducedState, + table: { + changeType: 'unchanged', + isMultiRow: !state.cachedFieldList[id].singleRow, + layerId: id, + columns: layer.columns.map((column) => { + const field = state.cachedFieldList[id].fields.find( + (f) => f.name === column.fieldName + )!; + const operation = { + dataType: field.type as DataType, + label: field.name, + isBucketed: false, + }; + return { + columnId: column.columnId, + operation, + }; + }), + }, + keptLayerIds: [id], + }; + }); + }, + }; + + return esdslDatasource; +} + +function blankLayer() { + return { + index: '*', + query: '', + columns: [], + }; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/esdsl_datasource/field_item.test.tsx new file mode 100644 index 0000000000000..6a4a2bd2ba77b --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/field_item.test.tsx @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { EuiLoadingSpinner, EuiPopover } from '@elastic/eui'; +import { FieldItem, FieldItemProps } from './field_item'; +import { coreMock } from 'src/core/public/mocks'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { IndexPattern } from './types'; + +describe('IndexPattern Field Item', () => { + let defaultProps: FieldItemProps; + let indexPattern: IndexPattern; + let core: ReturnType; + let data: DataPublicPluginStart; + + beforeEach(() => { + indexPattern = { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'unsupported', + type: 'geo', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + } as IndexPattern; + + core = coreMock.createSetup(); + data = dataPluginMock.createStartContract(); + core.http.post.mockClear(); + defaultProps = { + indexPattern, + data, + core, + highlight: '', + dateRange: { + fromDate: 'now-7d', + toDate: 'now', + }, + query: { query: '', language: 'lucene' }, + filters: [], + field: { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + exists: true, + }; + + data.fieldFormats = ({ + getDefaultInstance: jest.fn(() => ({ + convert: jest.fn((s: unknown) => JSON.stringify(s)), + })), + } as unknown) as DataPublicPluginStart['fieldFormats']; + }); + + it('should request field stats without a time field, if the index pattern has none', async () => { + indexPattern.timeFieldName = undefined; + core.http.post.mockImplementationOnce(() => { + return Promise.resolve({}); + }); + const wrapper = mountWithIntl(); + + await act(async () => { + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + }); + + expect(core.http.post).toHaveBeenCalledWith( + '/api/lens/index_stats/my-fake-index-pattern/field', + expect.anything() + ); + // Function argument types not detected correctly (https://github.com/microsoft/TypeScript/issues/26591) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { body } = (core.http.post.mock.calls[0] as any)[1]; + expect(JSON.parse(body)).not.toHaveProperty('timeFieldName'); + }); + + it('should request field stats every time the button is clicked', async () => { + let resolveFunction: (arg: unknown) => void; + + core.http.post.mockImplementation(() => { + return new Promise(resolve => { + resolveFunction = resolve; + }); + }); + + const wrapper = mountWithIntl(); + + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + + expect(core.http.post).toHaveBeenCalledWith( + `/api/lens/index_stats/my-fake-index-pattern/field`, + { + body: JSON.stringify({ + dslQuery: { + bool: { + must: [{ match_all: {} }], + filter: [], + should: [], + must_not: [], + }, + }, + fromDate: 'now-7d', + toDate: 'now', + timeFieldName: 'timestamp', + field: { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + }), + } + ); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + await act(async () => { + resolveFunction!({ + totalDocuments: 4633, + sampledDocuments: 4633, + sampledValues: 4633, + histogram: { + buckets: [{ count: 705, key: 0 }], + }, + topValues: { + buckets: [{ count: 147, key: 0 }], + }, + }); + }); + + wrapper.update(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + expect(core.http.post).toHaveBeenCalledTimes(1); + + act(() => { + const closePopover = wrapper.find(EuiPopover).prop('closePopover'); + + closePopover(); + }); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(false); + + act(() => { + wrapper.setProps({ + dateRange: { + fromDate: 'now-14d', + toDate: 'now-7d', + }, + query: { query: 'geo.src : "US"', language: 'kuery' }, + filters: [ + { + match: { phrase: { 'geo.dest': 'US' } }, + }, + ], + }); + }); + + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + + expect(core.http.post).toHaveBeenCalledTimes(2); + expect(core.http.post).toHaveBeenLastCalledWith( + `/api/lens/index_stats/my-fake-index-pattern/field`, + { + body: JSON.stringify({ + dslQuery: { + bool: { + must: [], + filter: [ + { + bool: { + should: [{ match_phrase: { 'geo.src': 'US' } }], + minimum_should_match: 1, + }, + }, + { + match: { phrase: { 'geo.dest': 'US' } }, + }, + ], + should: [], + must_not: [], + }, + }, + fromDate: 'now-14d', + toDate: 'now-7d', + timeFieldName: 'timestamp', + field: { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + }), + } + ); + }); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/field_item.tsx b/x-pack/plugins/lens/public/esdsl_datasource/field_item.tsx new file mode 100644 index 0000000000000..5f0fa95ad0022 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/field_item.tsx @@ -0,0 +1,538 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import DateMath from '@elastic/datemath'; +import { + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiKeyboardAccessible, + EuiLoadingSpinner, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiProgress, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; +import { + Axis, + BarSeries, + Chart, + niceTimeFormatter, + Position, + ScaleType, + Settings, + TooltipType, +} from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { + Query, + KBN_FIELD_TYPES, + ES_FIELD_TYPES, + Filter, + esQuery, + IIndexPattern, +} from '../../../../../src/plugins/data/public'; +import { DraggedField } from './indexpattern'; +import { DragDrop } from '../drag_drop'; +import { DatasourceDataPanelProps, DataType } from '../types'; +import { BucketedAggregation, FieldStatsResponse } from '../../common'; +import { IndexPattern, IndexPatternField } from './types'; +import { LensFieldIcon } from './lens_field_icon'; +import { trackUiEvent } from '../lens_ui_telemetry'; + +export interface FieldItemProps { + core: DatasourceDataPanelProps['core']; + data: DataPublicPluginStart; + field: IndexPatternField; + indexPattern: IndexPattern; + highlight?: string; + exists: boolean; + query: Query; + dateRange: DatasourceDataPanelProps['dateRange']; + filters: Filter[]; + hideDetails?: boolean; +} + +interface State { + isLoading: boolean; + totalDocuments?: number; + sampledDocuments?: number; + sampledValues?: number; + histogram?: BucketedAggregation; + topValues?: BucketedAggregation; +} + +function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; +} + +export function FieldItem(props: FieldItemProps) { + const { + core, + field, + indexPattern, + highlight, + exists, + query, + dateRange, + filters, + hideDetails, + } = props; + + const [infoIsOpen, setOpen] = useState(false); + + const [state, setState] = useState({ + isLoading: false, + }); + + const wrappableName = wrapOnDot(field.name)!; + const wrappableHighlight = wrapOnDot(highlight); + const highlightIndex = wrappableHighlight + ? wrappableName.toLowerCase().indexOf(wrappableHighlight.toLowerCase()) + : -1; + const wrappableHighlightableFieldName = + highlightIndex < 0 ? ( + wrappableName + ) : ( + + {wrappableName.substr(0, highlightIndex)} + {wrappableName.substr(highlightIndex, wrappableHighlight.length)} + {wrappableName.substr(highlightIndex + wrappableHighlight.length)} + + ); + + function fetchData() { + if ( + state.isLoading || + (field.type !== 'number' && + field.type !== 'string' && + field.type !== 'date' && + field.type !== 'boolean' && + field.type !== 'ip') + ) { + return; + } + + setState(s => ({ ...s, isLoading: true })); + + core.http + .post(`/api/lens/index_stats/${indexPattern.title}/field`, { + body: JSON.stringify({ + dslQuery: esQuery.buildEsQuery( + indexPattern as IIndexPattern, + query, + filters, + esQuery.getEsQueryConfig(core.uiSettings) + ), + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, + timeFieldName: indexPattern.timeFieldName, + field, + }), + }) + .then((results: FieldStatsResponse) => { + setState(s => ({ + ...s, + isLoading: false, + totalDocuments: results.totalDocuments, + sampledDocuments: results.sampledDocuments, + sampledValues: results.sampledValues, + histogram: results.histogram, + topValues: results.topValues, + })); + }) + .catch(() => { + setState(s => ({ ...s, isLoading: false })); + }); + } + + function togglePopover() { + if (hideDetails) { + return; + } + + setOpen(!infoIsOpen); + if (!infoIsOpen) { + trackUiEvent('indexpattern_field_info_click'); + fetchData(); + } + } + + return ( + ('.application') || undefined} + button={ + + +
{ + togglePopover(); + }} + onKeyPress={event => { + if (event.key === 'ENTER') { + togglePopover(); + } + }} + aria-label={i18n.translate('xpack.lens.indexPattern.fieldStatsButtonLabel', { + defaultMessage: 'Click for a field preview, or drag and drop to visualize.', + })} + > + + + + {wrappableHighlightableFieldName} + + + +
+
+
+ } + isOpen={infoIsOpen} + closePopover={() => setOpen(false)} + anchorPosition="rightUp" + panelClassName="lnsFieldItem__fieldPopoverPanel" + > + +
+ ); +} + +function FieldItemPopoverContents(props: State & FieldItemProps) { + const { + histogram, + topValues, + indexPattern, + field, + dateRange, + core, + sampledValues, + data: { fieldFormats }, + } = props; + + const IS_DARK_THEME = core.uiSettings.get('theme:darkMode'); + const chartTheme = IS_DARK_THEME ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; + let histogramDefault = !!props.histogram; + + const totalValuesCount = + topValues && topValues.buckets.reduce((prev, bucket) => bucket.count + prev, 0); + const otherCount = sampledValues && totalValuesCount ? sampledValues - totalValuesCount : 0; + + if ( + totalValuesCount && + histogram && + histogram.buckets.length && + topValues && + topValues.buckets.length + ) { + // Default to histogram when top values are less than 10% of total + histogramDefault = otherCount / totalValuesCount > 0.9; + } + + const [showingHistogram, setShowingHistogram] = useState(histogramDefault); + + let formatter: { convert: (data: unknown) => string }; + if (indexPattern.fieldFormatMap && indexPattern.fieldFormatMap[field.name]) { + const FormatType = fieldFormats.getType(indexPattern.fieldFormatMap[field.name].id); + if (FormatType) { + formatter = new FormatType( + indexPattern.fieldFormatMap[field.name].params, + core.uiSettings.get.bind(core.uiSettings) + ); + } else { + formatter = { convert: (data: unknown) => JSON.stringify(data) }; + } + } else { + formatter = fieldFormats.getDefaultInstance( + field.type as KBN_FIELD_TYPES, + field.esTypes as ES_FIELD_TYPES[] + ); + } + + const fromDate = DateMath.parse(dateRange.fromDate); + const toDate = DateMath.parse(dateRange.toDate); + + let title = <>; + + if (props.isLoading) { + return ; + } else if ( + (!props.histogram || props.histogram.buckets.length === 0) && + (!props.topValues || props.topValues.buckets.length === 0) + ) { + return ( + + {i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { + defaultMessage: 'No data to display.', + })} + + ); + } + + if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) { + title = ( + { + setShowingHistogram(optionId === 'histogram'); + }} + idSelected={showingHistogram ? 'histogram' : 'topValues'} + /> + ); + } else if (field.type === 'date') { + title = ( + <> + {i18n.translate('xpack.lens.indexPattern.fieldTimeDistributionLabel', { + defaultMessage: 'Time distribution', + })} + + ); + } else if (topValues && topValues.buckets.length) { + title = ( + <> + {i18n.translate('xpack.lens.indexPattern.fieldTopValuesLabel', { + defaultMessage: 'Top values', + })} + + ); + } + + function wrapInPopover(el: React.ReactElement) { + return ( + <> + {title ? {title} : <>} + {el} + + {props.totalDocuments ? ( + + + {props.sampledDocuments && ( + <> + {i18n.translate('xpack.lens.indexPattern.percentageOfLabel', { + defaultMessage: '{percentage}% of', + values: { + percentage: Math.round((props.sampledDocuments / props.totalDocuments) * 100), + }, + })} + + )}{' '} + + {fieldFormats + .getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER]) + .convert(props.totalDocuments)} + {' '} + {i18n.translate('xpack.lens.indexPattern.ofDocumentsLabel', { + defaultMessage: 'documents', + })} + + + ) : ( + <> + )} + + ); + } + + if (histogram && histogram.buckets.length) { + const specId = i18n.translate('xpack.lens.indexPattern.fieldStatsCountLabel', { + defaultMessage: 'Count', + }); + + if (field.type === 'date') { + return wrapInPopover( + + + + + + + + ); + } else if (showingHistogram || !topValues || !topValues.buckets.length) { + return wrapInPopover( + + + + formatter.convert(d)} + /> + + + + ); + } + } + + if (props.topValues && props.topValues.buckets.length) { + return wrapInPopover( +
+ {props.topValues.buckets.map(topValue => { + const formatted = formatter.convert(topValue.key); + return ( +
+ + + {formatted === '' ? ( + + + {i18n.translate('xpack.lens.indexPattern.fieldPanelEmptyStringValue', { + defaultMessage: 'Empty string', + })} + + + ) : ( + + + {formatted} + + + )} + + + + {Math.round((topValue.count / props.sampledValues!) * 100)}% + + + + + +
+ ); + })} + {otherCount ? ( + <> + + + + {i18n.translate('xpack.lens.indexPattern.otherDocsLabel', { + defaultMessage: 'Other', + })} + + + + + + {Math.round((otherCount / props.sampledValues!) * 100)}% + + + + + + + ) : ( + <> + )} +
+ ); + } + return <>; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/index.ts b/x-pack/plugins/lens/public/esdsl_datasource/index.ts new file mode 100644 index 0000000000000..4b47ccc049bd7 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/index.ts @@ -0,0 +1,46 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup } from 'kibana/public'; +import { get } from 'lodash'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { getEsDSLDatasource } from './esdsl'; +import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, +} from '../../../../../src/plugins/data/public'; +import { Datasource, EditorFrameSetup } from '../types'; + +export interface IndexPatternDatasourceSetupPlugins { + expressions: ExpressionsSetup; + data: DataPublicPluginSetup; + editorFrame: EditorFrameSetup; +} + +export interface IndexPatternDatasourceStartPlugins { + data: DataPublicPluginStart; +} + +export class EsDSLDatasource { + constructor() {} + + setup( + core: CoreSetup, + { expressions, editorFrame }: IndexPatternDatasourceSetupPlugins + ) { + editorFrame.registerDatasource( + core.getStartServices().then(([coreStart, { data }]) => + getEsDSLDatasource({ + core: coreStart, + storage: new Storage(localStorage), + data, + }) + ) as Promise + ); + } +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/esdsl_datasource/indexpattern.test.ts new file mode 100644 index 0000000000000..552afd54a2d96 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/indexpattern.test.ts @@ -0,0 +1,557 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { getEsDSLDatasource, IndexPatternColumn, uniqueLabels } from './indexpattern'; +import { DatasourcePublicAPI, Operation, Datasource } from '../types'; +import { coreMock } from 'src/core/public/mocks'; +import { EsDSLPersistedState, EsDSLPrivateState } from './types'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { Ast } from '@kbn/interpreter/common'; + +jest.mock('./loader'); +jest.mock('../id_generator'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + 2: { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + // Ignored in the UI + histogram: { + agg: 'histogram', + interval: 1000, + }, + avg: { + agg: 'avg', + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, +}; + +function stateFromPersistedState( + persistedState: EsDSLPersistedState +): EsDSLPrivateState { + return { + currentIndexPatternId: persistedState.currentIndexPatternId, + layers: persistedState.layers, + indexPatterns: expectedIndexPatterns, + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: true, + }; +} + +describe('IndexPattern Data Source', () => { + let persistedState: EsDSLPersistedState; + let indexPatternDatasource: Datasource; + + beforeEach(() => { + indexPatternDatasource = getEsDSLDatasource({ + storage: {} as IStorageWrapper, + core: coreMock.createStart(), + data: dataPluginMock.createStartContract(), + }); + + persistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }, + }, + }, + }; + }); + + describe('uniqueLabels', () => { + it('appends a suffix to duplicates', () => { + const col: IndexPatternColumn = { + dataType: 'number', + isBucketed: false, + label: 'Foo', + operationType: 'count', + sourceField: 'Records', + }; + const map = uniqueLabels({ + a: { + columnOrder: ['a', 'b'], + columns: { + a: col, + b: col, + }, + indexPatternId: 'foo', + }, + b: { + columnOrder: ['c', 'd'], + columns: { + c: col, + d: { + ...col, + label: 'Foo [1]', + }, + }, + indexPatternId: 'foo', + }, + }); + + expect(map).toMatchInlineSnapshot(` + Object { + "a": "Foo", + "b": "Foo [1]", + "c": "Foo [2]", + "d": "Foo [1] [1]", + } + `); + }); + }); + + describe('#getPersistedState', () => { + it('should persist from saved state', async () => { + const state = stateFromPersistedState(persistedState); + + expect(indexPatternDatasource.getPersistableState(state)).toEqual(persistedState); + }); + }); + + describe('#toExpression', () => { + it('should generate an empty expression when no columns are selected', async () => { + const state = await indexPatternDatasource.initialize(); + expect(indexPatternDatasource.toExpression(state, 'first')).toEqual(null); + }); + + it('should generate an expression for an aggregated query', async () => { + const queryPersistedState: EsDSLPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object { + "aggConfigs": Array [ + "[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]", + ], + "includeFormatHints": Array [ + true, + ], + "index": Array [ + "1", + ], + "metricsAtAllLevels": Array [ + true, + ], + "partialRows": Array [ + true, + ], + "timeFields": Array [ + "timestamp", + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "idMap": Array [ + "{\\"col--1-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-2-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", + ], + }, + "function": "lens_rename_columns", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + + it('should put all time fields used in date_histograms to the esaggs timeFields parameter', async () => { + const queryPersistedState: EsDSLPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + col3: { + label: 'Date 2', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'another_datefield', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); + }); + + it('should not put date fields used outside date_histograms to the esaggs timeFields parameter', async () => { + const queryPersistedState: EsDSLPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count of records', + dataType: 'date', + isBucketed: false, + sourceField: 'timefield', + operationType: 'cardinality', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']); + expect(ast.chain[0].arguments.timeFields).not.toContain('timefield'); + }); + }); + + describe('#insertLayer', () => { + it('should insert an empty layer into the previous state', () => { + const state = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + showEmptyFields: false, + }; + expect(indexPatternDatasource.insertLayer(state, 'newLayer')).toEqual({ + ...state, + layers: { + ...state.layers, + newLayer: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + }); + }); + }); + + describe('#removeLayer', () => { + it('should remove a layer', () => { + const state = { + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }; + expect(indexPatternDatasource.removeLayer(state, 'first')).toEqual({ + ...state, + layers: { + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + }); + }); + }); + + describe('#getLayers', () => { + it('should list the current layers', () => { + expect( + indexPatternDatasource.getLayers({ + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual(['first', 'second']); + }); + }); + + describe('#getMetadata', () => { + it('should return the title of the index patterns', () => { + expect( + indexPatternDatasource.getMetaData({ + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual({ + filterableIndexPatterns: [ + { + id: '1', + title: 'my-fake-index-pattern', + }, + { + id: '2', + title: 'my-fake-restricted-pattern', + }, + ], + }); + }); + }); + + describe('#getPublicAPI', () => { + let publicAPI: DatasourcePublicAPI; + + beforeEach(async () => { + const initialState = stateFromPersistedState(persistedState); + publicAPI = indexPatternDatasource.getPublicAPI({ + state: initialState, + layerId: 'first', + dateRange: { + fromDate: 'now-30d', + toDate: 'now', + }, + }); + }); + + describe('getTableSpec', () => { + it('should include col1', () => { + expect(publicAPI.getTableSpec()).toEqual([ + { + columnId: 'col1', + }, + ]); + }); + }); + + describe('getOperationForColumnId', () => { + it('should get an operation for col1', () => { + expect(publicAPI.getOperationForColumnId('col1')).toEqual({ + label: 'My Op', + dataType: 'string', + isBucketed: true, + } as Operation); + }); + + it('should return null for non-existant columns', () => { + expect(publicAPI.getOperationForColumnId('col2')).toBe(null); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.test.tsx new file mode 100644 index 0000000000000..b2600df3e5a5b --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.test.tsx @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EsDSLPrivateState } from './types'; +import { IndexPatternLayerPanelProps, LayerPanel } from './layerpanel'; +import { shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +import { ShallowWrapper } from 'enzyme'; +import { EuiSelectable, EuiSelectableList } from '@elastic/eui'; +import { ChangeIndexPattern } from './change_indexpattern'; + +jest.mock('./state_helpers'); + +const initialState: EsDSLPrivateState = { + indexPatternRefs: [ + { id: '1', title: 'my-fake-index-pattern' }, + { id: '2', title: 'my-fake-restricted-pattern' }, + { id: '3', title: 'my-compatible-pattern' }, + ], + existingFields: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'source', + params: { + size: 5, + orderDirection: 'asc', + orderBy: { + type: 'alphabetical', + }, + }, + }, + col2: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + operationType: 'avg', + sourceField: 'memory', + }, + }, + }, + }, + indexPatterns: { + '1': { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'unsupported', + type: 'geo', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + '2': { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + histogram: { + agg: 'histogram', + interval: 1000, + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, + '3': { + id: '3', + title: 'my-compatible-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + }, +}; +describe('Layer Data Panel', () => { + let defaultProps: IndexPatternLayerPanelProps; + + beforeEach(() => { + defaultProps = { + layerId: 'first', + state: initialState, + setState: jest.fn(), + onChangeIndexPattern: jest.fn(async () => {}), + }; + }); + + function getIndexPatternPickerList(instance: ShallowWrapper) { + return instance + .find(ChangeIndexPattern) + .first() + .dive() + .find(EuiSelectable); + } + + function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { + const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( + instance + ).map(option => + option.label === selectedLabel + ? { ...option, checked: 'on' } + : { ...option, checked: undefined } + ); + return getIndexPatternPickerList(instance).prop('onChange')!(options); + } + + function getIndexPatternPickerOptions(instance: ShallowWrapper) { + return getIndexPatternPickerList(instance) + .dive() + .find(EuiSelectableList) + .prop('options'); + } + + it('should list all index patterns', () => { + const instance = shallow(); + + expect(getIndexPatternPickerOptions(instance)!.map(option => option.label)).toEqual([ + 'my-fake-index-pattern', + 'my-fake-restricted-pattern', + 'my-compatible-pattern', + ]); + }); + + it('should switch data panel to target index pattern', () => { + const instance = shallow(); + + selectIndexPatternPickerOption(instance, 'my-compatible-pattern'); + + expect(defaultProps.onChangeIndexPattern).toHaveBeenCalledWith('3'); + }); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.tsx new file mode 100644 index 0000000000000..5ef73f8b31526 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { DatasourceLayerPanelProps } from '../types'; +import { EsDSLPrivateState } from './types'; +import { ChangeIndexPattern } from './change_indexpattern'; + +export interface IndexPatternLayerPanelProps + extends DatasourceLayerPanelProps { + state: EsDSLPrivateState; + onChangeIndexPattern: (newId: string) => void; +} + +export function LayerPanel({ state, layerId, onChangeIndexPattern }: IndexPatternLayerPanelProps) { + const layer = state.layers[layerId]; + + return ( + + + + ); +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/lens_field_icon.test.tsx b/x-pack/plugins/lens/public/esdsl_datasource/lens_field_icon.test.tsx new file mode 100644 index 0000000000000..317ce8f032f94 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/lens_field_icon.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; +import { LensFieldIcon } from './lens_field_icon'; + +test('LensFieldIcon renders properly', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('LensFieldIcon accepts FieldIcon props', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/lens_field_icon.tsx b/x-pack/plugins/lens/public/esdsl_datasource/lens_field_icon.tsx new file mode 100644 index 0000000000000..bcc83e799d889 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/lens_field_icon.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FieldIcon, FieldIconProps } from '../../../../../src/plugins/kibana_react/public'; +import { DataType } from '../types'; +import { normalizeOperationDataType } from './utils'; + +export function LensFieldIcon({ type, ...rest }: FieldIconProps & { type: DataType }) { + return ( + + ); +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/loader.test.ts b/x-pack/plugins/lens/public/esdsl_datasource/loader.test.ts new file mode 100644 index 0000000000000..96ccf7fe9502d --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/loader.test.ts @@ -0,0 +1,585 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/public'; +import _ from 'lodash'; +import { + loadInitialState, + loadIndexPatterns, + changeIndexPattern, + changeLayerIndexPattern, + syncExistingFields, +} from './loader'; +import { EsDSLPersistedState, EsDSLPrivateState } from './types'; +import { documentField } from './document_field'; + +jest.mock('./operations'); + +const sampleIndexPatterns = { + a: { + id: 'a', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + esTypes: ['keyword'], + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + esTypes: ['keyword'], + }, + documentField, + ], + }, + b: { + id: 'b', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + // Ignored in the UI + histogram: { + agg: 'histogram', + interval: 1000, + }, + avg: { + agg: 'avg', + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: false, + searchable: false, + scripted: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + esTypes: ['keyword'], + }, + documentField, + ], + }, +}; + +function indexPatternSavedObject({ id }: { id: keyof typeof sampleIndexPatterns }) { + const pattern = { + ...sampleIndexPatterns[id], + fields: [ + ...sampleIndexPatterns[id].fields, + { + name: 'description', + type: 'string', + aggregatable: false, + searchable: true, + esTypes: ['text'], + }, + ], + }; + return { + id, + type: 'index-pattern', + attributes: { + title: pattern.title, + timeFieldName: pattern.timeFieldName, + fields: JSON.stringify(pattern.fields.filter(f => f.type !== 'document')), + }, + }; +} + +function mockClient() { + return ({ + find: jest.fn(async () => ({ + savedObjects: [ + { id: 'a', attributes: { title: sampleIndexPatterns.a.title } }, + { id: 'b', attributes: { title: sampleIndexPatterns.b.title } }, + ], + })), + async bulkGet(indexPatterns: Array<{ id: keyof typeof sampleIndexPatterns }>) { + return { + savedObjects: indexPatterns.map(({ id }) => indexPatternSavedObject({ id })), + }; + }, + } as unknown) as Pick; +} + +describe('loader', () => { + describe('loadIndexPatterns', () => { + it('should not load index patterns that are already loaded', async () => { + const cache = await loadIndexPatterns({ + cache: sampleIndexPatterns, + patterns: ['a', 'b'], + savedObjectsClient: { + bulkGet: jest.fn(() => Promise.reject('bulkGet should not have been called')), + find: jest.fn(() => Promise.reject('find should not have been called')), + }, + }); + + expect(cache).toEqual(sampleIndexPatterns); + }); + + it('should load index patterns that are not loaded', async () => { + const cache = await loadIndexPatterns({ + cache: { + b: sampleIndexPatterns.b, + }, + patterns: ['a', 'b'], + savedObjectsClient: mockClient(), + }); + + expect(cache).toMatchObject(sampleIndexPatterns); + }); + + it('should allow scripted, but not full text fields', async () => { + const cache = await loadIndexPatterns({ + cache: {}, + patterns: ['a', 'b'], + savedObjectsClient: mockClient(), + }); + + expect(cache).toMatchObject(sampleIndexPatterns); + }); + + it('should apply field restrictions from typeMeta', async () => { + const cache = await loadIndexPatterns({ + cache: {}, + patterns: ['foo'], + savedObjectsClient: ({ + ...mockClient(), + async bulkGet() { + return { + savedObjects: [ + { + id: 'foo', + type: 'index-pattern', + attributes: { + title: 'Foo index', + typeMeta: JSON.stringify({ + aggs: { + date_histogram: { + timestamp: { + agg: 'date_histogram', + fixed_interval: 'm', + }, + }, + sum: { + bytes: { + agg: 'sum', + }, + }, + }, + }), + fields: JSON.stringify([ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + ]), + }, + }, + ], + }; + }, + } as unknown) as Pick, + }); + + expect(cache.foo.fields.find(f => f.name === 'bytes')!.aggregationRestrictions).toEqual({ + sum: { agg: 'sum' }, + }); + expect(cache.foo.fields.find(f => f.name === 'timestamp')!.aggregationRestrictions).toEqual({ + date_histogram: { agg: 'date_histogram', fixed_interval: 'm' }, + }); + }); + }); + + describe('loadInitialState', () => { + it('should load a default state', async () => { + const state = await loadInitialState({ + savedObjectsClient: mockClient(), + }); + + expect(state).toMatchObject({ + currentIndexPatternId: 'a', + indexPatternRefs: [ + { id: 'a', title: sampleIndexPatterns.a.title }, + { id: 'b', title: sampleIndexPatterns.b.title }, + ], + indexPatterns: { + a: sampleIndexPatterns.a, + }, + layers: {}, + showEmptyFields: false, + }); + }); + + it('should use the default index pattern id, if provided', async () => { + const state = await loadInitialState({ + defaultIndexPatternId: 'b', + savedObjectsClient: mockClient(), + }); + + expect(state).toMatchObject({ + currentIndexPatternId: 'b', + indexPatternRefs: [ + { id: 'a', title: sampleIndexPatterns.a.title }, + { id: 'b', title: sampleIndexPatterns.b.title }, + ], + indexPatterns: { + b: sampleIndexPatterns.b, + }, + layers: {}, + showEmptyFields: false, + }); + }); + + it('should initialize from saved state', async () => { + const savedState: EsDSLPersistedState = { + currentIndexPatternId: 'b', + layers: { + layerb: { + indexPatternId: 'b', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'date', + isBucketed: true, + label: 'My date', + operationType: 'date_histogram', + params: { + interval: 'm', + }, + sourceField: 'timestamp', + }, + col2: { + dataType: 'number', + isBucketed: false, + label: 'Sum of bytes', + operationType: 'sum', + sourceField: 'bytes', + }, + }, + }, + }, + }; + const state = await loadInitialState({ + state: savedState, + savedObjectsClient: mockClient(), + }); + + expect(state).toMatchObject({ + currentIndexPatternId: 'b', + indexPatternRefs: [ + { id: 'a', title: sampleIndexPatterns.a.title }, + { id: 'b', title: sampleIndexPatterns.b.title }, + ], + indexPatterns: { + b: sampleIndexPatterns.b, + }, + layers: savedState.layers, + showEmptyFields: false, + }); + }); + }); + + describe('changeIndexPattern', () => { + it('loads the index pattern and then sets it as current', async () => { + const setState = jest.fn(); + const state: EsDSLPrivateState = { + currentIndexPatternId: 'b', + indexPatternRefs: [], + indexPatterns: {}, + existingFields: {}, + layers: {}, + showEmptyFields: true, + }; + + await changeIndexPattern({ + state, + setState, + id: 'a', + savedObjectsClient: mockClient(), + onError: jest.fn(), + }); + + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0](state)).toMatchObject({ + currentIndexPatternId: 'a', + indexPatterns: { + a: sampleIndexPatterns.a, + }, + }); + }); + + it('handles errors', async () => { + const setState = jest.fn(); + const onError = jest.fn(); + const err = new Error('NOPE!'); + const state: EsDSLPrivateState = { + currentIndexPatternId: 'b', + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + layers: {}, + showEmptyFields: true, + }; + + await changeIndexPattern({ + state, + setState, + id: 'a', + savedObjectsClient: { + ...mockClient(), + bulkGet: jest.fn(async () => { + throw err; + }), + }, + onError, + }); + + expect(setState).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(err); + }); + }); + + describe('changeLayerIndexPattern', () => { + it('loads the index pattern and then changes the specified layer', async () => { + const setState = jest.fn(); + const state: EsDSLPrivateState = { + currentIndexPatternId: 'b', + indexPatternRefs: [], + existingFields: {}, + indexPatterns: { + a: sampleIndexPatterns.a, + }, + layers: { + l0: { + columnOrder: ['col1'], + columns: {}, + indexPatternId: 'a', + }, + l1: { + columnOrder: ['col2'], + columns: { + col2: { + dataType: 'date', + isBucketed: true, + label: 'My hist', + operationType: 'date_histogram', + params: { + interval: 'm', + }, + sourceField: 'timestamp', + }, + }, + indexPatternId: 'a', + }, + }, + showEmptyFields: true, + }; + + await changeLayerIndexPattern({ + state, + setState, + indexPatternId: 'b', + layerId: 'l1', + savedObjectsClient: mockClient(), + onError: jest.fn(), + }); + + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0](state)).toMatchObject({ + currentIndexPatternId: 'b', + indexPatterns: { + a: sampleIndexPatterns.a, + b: sampleIndexPatterns.b, + }, + layers: { + l0: { + columnOrder: ['col1'], + columns: {}, + indexPatternId: 'a', + }, + l1: { + columnOrder: ['col2'], + columns: { + col2: { + dataType: 'date', + isBucketed: true, + label: 'My hist', + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + indexPatternId: 'b', + }, + }, + }); + }); + + it('handles errors', async () => { + const setState = jest.fn(); + const onError = jest.fn(); + const err = new Error('NOPE!'); + const state: EsDSLPrivateState = { + currentIndexPatternId: 'b', + indexPatternRefs: [], + existingFields: {}, + indexPatterns: { + a: sampleIndexPatterns.a, + }, + layers: { + l0: { + columnOrder: ['col1'], + columns: {}, + indexPatternId: 'a', + }, + }, + showEmptyFields: true, + }; + + await changeLayerIndexPattern({ + state, + setState, + indexPatternId: 'b', + layerId: 'l0', + savedObjectsClient: { + ...mockClient(), + bulkGet: jest.fn(async () => { + throw err; + }), + }, + onError, + }); + + expect(setState).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(err); + }); + }); + + describe('syncExistingFields', () => { + const dslQuery = { + bool: { + must: [], + filter: [{ match_all: {} }], + should: [], + must_not: [], + }, + }; + + it('should call once for each index pattern', async () => { + const setState = jest.fn(); + const fetchJson = jest.fn((path: string) => { + const indexPatternTitle = _.last(path.split('/')); + return { + indexPatternTitle, + existingFieldNames: ['field_1', 'field_2'].map( + fieldName => `${indexPatternTitle}_${fieldName}` + ), + }; + }); + + await syncExistingFields({ + dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fetchJson: fetchJson as any, + indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + setState, + dslQuery, + }); + + expect(fetchJson).toHaveBeenCalledTimes(3); + expect(setState).toHaveBeenCalledTimes(1); + + const [fn] = setState.mock.calls[0]; + const newState = fn({ + foo: 'bar', + existingFields: {}, + }); + + expect(newState).toEqual({ + foo: 'bar', + existingFields: { + a: { a_field_1: true, a_field_2: true }, + b: { b_field_1: true, b_field_2: true }, + c: { c_field_1: true, c_field_2: true }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/loader.ts b/x-pack/plugins/lens/public/esdsl_datasource/loader.ts new file mode 100644 index 0000000000000..8139a8ba5a1df --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/loader.ts @@ -0,0 +1,315 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { SavedObjectsClientContract, SavedObjectAttributes, HttpSetup } from 'kibana/public'; +import { SimpleSavedObject } from 'kibana/public'; +import { StateSetter } from '../types'; +import { + IndexPattern, + IndexPatternRef, + EsDSLPersistedState, + EsDSLPrivateState, + IndexPatternField, +} from './types'; +import { updateLayerIndexPattern } from './state_helpers'; +import { DateRange, ExistingFields } from '../../common/types'; +import { BASE_API_URL } from '../../common'; +import { documentField } from './document_field'; +import { + indexPatterns as indexPatternsUtils, + IFieldType, + IndexPatternTypeMeta, +} from '../../../../../src/plugins/data/public'; + +interface SavedIndexPatternAttributes extends SavedObjectAttributes { + title: string; + timeFieldName: string | null; + fields: string; + fieldFormatMap: string; + typeMeta: string; +} + +type SetState = StateSetter; +type SavedObjectsClient = Pick; +type ErrorHandler = (err: Error) => void; + +export async function loadIndexPatterns({ + patterns, + savedObjectsClient, + cache, +}: { + patterns: string[]; + savedObjectsClient: SavedObjectsClient; + cache: Record; +}) { + const missingIds = patterns.filter(id => !cache[id]); + + if (missingIds.length === 0) { + return cache; + } + + const resp = await savedObjectsClient.bulkGet( + missingIds.map(id => ({ id, type: 'index-pattern' })) + ); + + return resp.savedObjects.reduce( + (acc, savedObject) => { + const indexPattern = fromSavedObject( + savedObject as SimpleSavedObject + ); + acc[indexPattern.id] = indexPattern; + return acc; + }, + { ...cache } + ); +} + +export async function loadInitialState({ + state, + savedObjectsClient, + defaultIndexPatternId, +}: { + state?: EsDSLPersistedState; + savedObjectsClient: SavedObjectsClient; + defaultIndexPatternId?: string; +}): Promise { + const indexPatternRefs = await loadIndexPatternRefs(savedObjectsClient); + const requiredPatterns = _.unique( + state + ? Object.values(state.layers) + .map(l => l.indexPatternId) + .concat(state.currentIndexPatternId) + : [defaultIndexPatternId || indexPatternRefs[0].id] + ); + + const currentIndexPatternId = requiredPatterns[0]; + const indexPatterns = await loadIndexPatterns({ + savedObjectsClient, + cache: {}, + patterns: requiredPatterns, + }); + + if (state) { + return { + ...state, + currentIndexPatternId, + indexPatternRefs, + indexPatterns, + showEmptyFields: false, + existingFields: {}, + }; + } + + return { + currentIndexPatternId, + indexPatternRefs, + indexPatterns, + layers: {}, + showEmptyFields: false, + existingFields: {}, + }; +} + +export async function changeIndexPattern({ + id, + savedObjectsClient, + state, + setState, + onError, +}: { + id: string; + savedObjectsClient: SavedObjectsClient; + state: EsDSLPrivateState; + setState: SetState; + onError: ErrorHandler; +}) { + try { + const indexPatterns = await loadIndexPatterns({ + savedObjectsClient, + cache: state.indexPatterns, + patterns: [id], + }); + + setState(s => ({ + ...s, + layers: isSingleEmptyLayer(state.layers) + ? _.mapValues(state.layers, layer => updateLayerIndexPattern(layer, indexPatterns[id])) + : state.layers, + indexPatterns: { + ...s.indexPatterns, + [id]: indexPatterns[id], + }, + currentIndexPatternId: id, + })); + } catch (err) { + onError(err); + } +} + +export async function changeLayerIndexPattern({ + indexPatternId, + layerId, + savedObjectsClient, + state, + setState, + onError, + replaceIfPossible, +}: { + indexPatternId: string; + layerId: string; + savedObjectsClient: SavedObjectsClient; + state: EsDSLPrivateState; + setState: SetState; + onError: ErrorHandler; + replaceIfPossible?: boolean; +}) { + try { + const indexPatterns = await loadIndexPatterns({ + savedObjectsClient, + cache: state.indexPatterns, + patterns: [indexPatternId], + }); + + setState(s => ({ + ...s, + layers: { + ...s.layers, + [layerId]: updateLayerIndexPattern(s.layers[layerId], indexPatterns[indexPatternId]), + }, + indexPatterns: { + ...s.indexPatterns, + [indexPatternId]: indexPatterns[indexPatternId], + }, + currentIndexPatternId: replaceIfPossible ? indexPatternId : s.currentIndexPatternId, + })); + } catch (err) { + onError(err); + } +} + +async function loadIndexPatternRefs( + savedObjectsClient: SavedObjectsClient +): Promise { + const result = await savedObjectsClient.find<{ title: string }>({ + type: 'index-pattern', + fields: ['title'], + perPage: 10000, + }); + + return result.savedObjects + .map(o => ({ + id: String(o.id), + title: (o.attributes as { title: string }).title, + })) + .sort((a, b) => { + return a.title.localeCompare(b.title); + }); +} + +export async function syncExistingFields({ + indexPatterns, + dateRange, + fetchJson, + setState, + dslQuery, +}: { + dateRange: DateRange; + indexPatterns: Array<{ id: string; timeFieldName?: string | null }>; + fetchJson: HttpSetup['post']; + setState: SetState; + dslQuery: object; +}) { + const emptinessInfo = await Promise.all( + indexPatterns.map(pattern => { + const body: Record = { + dslQuery, + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, + }; + + if (pattern.timeFieldName) { + body.timeFieldName = pattern.timeFieldName; + } + + return fetchJson(`${BASE_API_URL}/existing_fields/${pattern.id}`, { + body: JSON.stringify(body), + }) as Promise; + }) + ); + + setState(state => ({ + ...state, + existingFields: emptinessInfo.reduce((acc, info) => { + acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); + return acc; + }, state.existingFields), + })); +} + +function booleanMap(keys: string[]) { + return keys.reduce((acc, key) => { + acc[key] = true; + return acc; + }, {} as Record); +} + +function isSingleEmptyLayer(layerMap: EsDSLPrivateState['layers']) { + const layers = Object.values(layerMap); + return layers.length === 1 && layers[0].columnOrder.length === 0; +} + +function fromSavedObject( + savedObject: SimpleSavedObject +): IndexPattern { + const { id, attributes, type } = savedObject; + const indexPattern = { + ...attributes, + id, + type, + title: attributes.title, + fields: (JSON.parse(attributes.fields) as IFieldType[]) + .filter( + field => + !indexPatternsUtils.isNestedField(field) && (!!field.aggregatable || !!field.scripted) + ) + .concat(documentField) as IndexPatternField[], + typeMeta: attributes.typeMeta + ? (JSON.parse(attributes.typeMeta) as IndexPatternTypeMeta) + : undefined, + fieldFormatMap: attributes.fieldFormatMap ? JSON.parse(attributes.fieldFormatMap) : undefined, + }; + + const { typeMeta } = indexPattern; + if (!typeMeta) { + return indexPattern; + } + + const newFields = [...(indexPattern.fields as IndexPatternField[])]; + + if (typeMeta.aggs) { + const aggs = Object.keys(typeMeta.aggs); + newFields.forEach((field, index) => { + const restrictionsObj: IndexPatternField['aggregationRestrictions'] = {}; + aggs.forEach(agg => { + const restriction = typeMeta.aggs && typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name]; + if (restriction) { + restrictionsObj[agg] = restriction; + } + }); + if (Object.keys(restrictionsObj).length) { + newFields[index] = { ...field, aggregationRestrictions: restrictionsObj }; + } + }); + } + + return { + id: indexPattern.id, + title: indexPattern.title, + timeFieldName: indexPattern.timeFieldName || undefined, + fields: newFields, + }; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/mocks.ts b/x-pack/plugins/lens/public/esdsl_datasource/mocks.ts new file mode 100644 index 0000000000000..dff3e61342a6a --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/mocks.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DragContextState } from '../drag_drop'; +import { IndexPattern } from './types'; + +export const createMockedIndexPattern = (): IndexPattern => ({ + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], +}); + +export const createMockedRestrictedIndexPattern = () => ({ + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + typeMeta: { + params: { + rollup_index: 'my-fake-index-pattern', + }, + aggs: { + terms: { + source: { + agg: 'terms', + }, + }, + date_histogram: { + timestamp: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + histogram: { + bytes: { + agg: 'histogram', + interval: 1000, + }, + }, + avg: { + bytes: { + agg: 'avg', + }, + }, + max: { + bytes: { + agg: 'max', + }, + }, + min: { + bytes: { + agg: 'min', + }, + }, + sum: { + bytes: { + agg: 'sum', + }, + }, + }, + }, +}); + +export function createMockedDragDropContext(): jest.Mocked { + return { + dragging: undefined, + setDragging: jest.fn(), + }; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/pure_helpers.test.ts b/x-pack/plugins/lens/public/esdsl_datasource/pure_helpers.test.ts new file mode 100644 index 0000000000000..05b00a66e8348 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/pure_helpers.test.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fieldExists } from './pure_helpers'; + +describe('fieldExists', () => { + it('returns whether or not a field exists', () => { + expect(fieldExists({ a: { b: true } }, 'a', 'b')).toBeTruthy(); + expect(fieldExists({ a: { b: true } }, 'a', 'c')).toBeFalsy(); + expect(fieldExists({ a: { b: true } }, 'b', 'b')).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/pure_helpers.ts b/x-pack/plugins/lens/public/esdsl_datasource/pure_helpers.ts new file mode 100644 index 0000000000000..9d93d57f6497f --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/pure_helpers.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EsDSLPrivateState } from './types'; + +export function fieldExists( + existingFields: EsDSLPrivateState['existingFields'], + indexPatternTitle: string, + fieldName: string +) { + return existingFields[indexPatternTitle] && existingFields[indexPatternTitle][fieldName]; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/rename_columns.test.ts b/x-pack/plugins/lens/public/esdsl_datasource/rename_columns.test.ts new file mode 100644 index 0000000000000..4bfd6a4f93c75 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/rename_columns.test.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renameColumns } from './rename_columns'; +import { KibanaDatatable } from '../../../../../src/plugins/expressions/public'; +import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; + +describe('rename_columns', () => { + it('should rename columns of a given datatable', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }; + + const idMap = { + a: { + id: 'b', + label: 'Austrailia', + }, + b: { + id: 'c', + label: 'Boomerang', + }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "b", + "name": "Austrailia", + }, + Object { + "id": "c", + "name": "Boomerang", + }, + ], + "rows": Array [ + Object { + "b": 1, + "c": 2, + }, + Object { + "b": 3, + "c": 4, + }, + Object { + "b": 5, + "c": 6, + }, + Object { + "b": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", + } + `); + }); + + it('should replace "" with a visible value', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'a', name: 'A' }], + rows: [{ a: '' }], + }; + + const idMap = { + a: { + id: 'a', + label: 'Austrailia', + }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result.rows[0].a).toEqual('(empty)'); + }); + + it('should keep columns which are not mapped', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }; + + const idMap = { + b: { id: 'c', label: 'Catamaran' }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "a", + "name": "A", + }, + Object { + "id": "c", + "name": "Catamaran", + }, + ], + "rows": Array [ + Object { + "a": 1, + "c": 2, + }, + Object { + "a": 3, + "c": 4, + }, + Object { + "a": 5, + "c": 6, + }, + Object { + "a": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", + } + `); + }); + + it('should rename date histograms', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'banana per 30 seconds' }, + ], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }; + + const idMap = { + b: { id: 'c', label: 'Apple', operationType: 'date_histogram', sourceField: 'banana' }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "a", + "name": "A", + }, + Object { + "id": "c", + "name": "Apple per 30 seconds", + }, + ], + "rows": Array [ + Object { + "a": 1, + "c": 2, + }, + Object { + "a": 3, + "c": 4, + }, + Object { + "a": 5, + "c": 6, + }, + Object { + "a": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", + } + `); + }); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/rename_columns.ts b/x-pack/plugins/lens/public/esdsl_datasource/rename_columns.ts new file mode 100644 index 0000000000000..248eb12ec8026 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/rename_columns.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { + ExpressionFunctionDefinition, + KibanaDatatable, + KibanaDatatableColumn, +} from 'src/plugins/expressions'; +import { IndexPatternColumn } from './operations'; + +interface RemapArgs { + idMap: string; +} + +export type OriginalColumn = { id: string } & IndexPatternColumn; + +export const renameColumns: ExpressionFunctionDefinition< + 'lens_rename_columns', + KibanaDatatable, + RemapArgs, + KibanaDatatable +> = { + name: 'lens_rename_columns', + type: 'kibana_datatable', + help: i18n.translate('xpack.lens.functions.renameColumns.help', { + defaultMessage: 'A helper to rename the columns of a datatable', + }), + args: { + idMap: { + types: ['string'], + help: i18n.translate('xpack.lens.functions.renameColumns.idMap.help', { + defaultMessage: + 'A JSON encoded object in which keys are the old column ids and values are the corresponding new ones. All other columns ids are kept.', + }), + }, + }, + inputTypes: ['kibana_datatable'], + fn(data, { idMap: encodedIdMap }) { + const idMap = JSON.parse(encodedIdMap) as Record; + + return { + type: 'kibana_datatable', + rows: data.rows.map(row => { + const mappedRow: Record = {}; + Object.entries(idMap).forEach(([fromId, toId]) => { + mappedRow[toId.id] = row[fromId]; + }); + + Object.entries(row).forEach(([id, value]) => { + if (id in idMap) { + mappedRow[idMap[id].id] = sanitizeValue(value); + } else { + mappedRow[id] = sanitizeValue(value); + } + }); + + return mappedRow; + }), + columns: data.columns.map(column => { + const mappedItem = idMap[column.id]; + + if (!mappedItem) { + return column; + } + + return { + ...column, + id: mappedItem.id, + name: getColumnName(mappedItem, column), + }; + }), + }; + }, +}; + +function getColumnName(originalColumn: OriginalColumn, newColumn: KibanaDatatableColumn) { + if (originalColumn && originalColumn.operationType === 'date_histogram') { + const fieldName = originalColumn.sourceField; + + // HACK: This is a hack, and introduces some fragility into + // column naming. Eventually, this should be calculated and + // built more systematically. + return newColumn.name.replace(fieldName, originalColumn.label); + } + + return originalColumn.label; +} + +function sanitizeValue(value: unknown) { + if (value === '') { + return i18n.translate('xpack.lens.indexpattern.emptyTextColumnValue', { + defaultMessage: '(empty)', + }); + } + + return value; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/state_helpers.test.ts b/x-pack/plugins/lens/public/esdsl_datasource/state_helpers.test.ts new file mode 100644 index 0000000000000..6b730cf8a1261 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/state_helpers.test.ts @@ -0,0 +1,732 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + updateColumnParam, + changeColumn, + getColumnOrder, + deleteColumn, + updateLayerIndexPattern, +} from './state_helpers'; +import { operationDefinitionMap } from './operations'; +import { TermsIndexPatternColumn } from './operations/definitions/terms'; +import { DateHistogramIndexPatternColumn } from './operations/definitions/date_histogram'; +import { AvgIndexPatternColumn } from './operations/definitions/metrics'; +import { IndexPattern, EsDSLPrivateState, EsDSLLayer } from './types'; + +jest.mock('./operations'); + +describe('state_helpers', () => { + describe('deleteColumn', () => { + it('should remove column', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'column', columnId: 'col2' }, + orderDirection: 'desc', + size: 5, + }, + }; + + const state: EsDSLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }; + + expect( + deleteColumn({ state, columnId: 'col2', layerId: 'first' }).layers.first.columns + ).toEqual({ + col1: { + ...termsColumn, + params: { + ...termsColumn.params, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }); + }); + + it('should adjust when deleting other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }; + + const state: EsDSLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }; + + deleteColumn({ + state, + columnId: 'col2', + layerId: 'first', + }); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + }); + }); + }); + + describe('updateColumnParam', () => { + it('should set the param for the given column', () => { + const currentColumn: DateHistogramIndexPatternColumn = { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }; + + const state: EsDSLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }, + }, + }; + + expect( + updateColumnParam({ + state, + layerId: 'first', + currentColumn, + paramName: 'interval', + value: 'M', + }).layers.first.columns.col1 + ).toEqual({ + ...currentColumn, + params: { interval: 'M' }, + }); + }); + + it('should set optional params', () => { + const currentColumn: AvgIndexPatternColumn = { + label: 'Avg of bytes', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bytes', + }; + + const state: EsDSLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }, + }, + }; + + expect( + updateColumnParam({ + state, + layerId: 'first', + currentColumn, + paramName: 'format', + value: { id: 'bytes' }, + }).layers.first.columns.col1 + ).toEqual({ + ...currentColumn, + params: { format: { id: 'bytes' } }, + }); + }); + }); + + describe('changeColumn', () => { + it('should update order on changing the column', () => { + const state: EsDSLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col2: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }, + }, + }; + expect( + changeColumn({ + state, + columnId: 'col2', + layerId: 'first', + newColumn: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }) + ).toEqual({ + ...state, + layers: { + first: expect.objectContaining({ + columnOrder: ['col2', 'col1'], + }), + }, + }); + }); + + it('should carry over params from old column if the operation type stays the same', () => { + const state: EsDSLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }, + }, + }, + }; + expect( + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn: { + label: 'Date histogram of order_date', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'order_date', + params: { + interval: 'w', + }, + }, + }).layers.first.columns.col1 + ).toEqual( + expect.objectContaining({ + params: { interval: 'h' }, + }) + ); + }); + + it('should execute adjustments for other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }; + + const newColumn: AvgIndexPatternColumn = { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }; + + const state: EsDSLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }; + + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn, + }); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + col2: newColumn, + }); + }); + }); + + describe('getColumnOrder', () => { + it('should work for empty columns', () => { + expect(getColumnOrder({})).toEqual([]); + }); + + it('should work for one column', () => { + expect( + getColumnOrder({ + col1: { + label: 'Value of timestamp', + dataType: 'string', + isBucketed: false, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }) + ).toEqual(['col1']); + }); + + it('should put any number of aggregations before metrics', () => { + expect( + getColumnOrder({ + col1: { + label: 'Top values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + }, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col3: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col1', 'col3', 'col2']); + }); + + it('should reorder aggregations based on suggested priority', () => { + expect( + getColumnOrder({ + col1: { + label: 'Top values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + suggestedPriority: 2, + }, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + suggestedPriority: 0, + }, + col3: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + suggestedPriority: 1, + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col3', 'col1', 'col2']); + }); + }); + + describe('updateLayerIndexPattern', () => { + const indexPattern: IndexPattern = { + id: 'test', + title: '', + fields: [ + { + name: 'fieldA', + aggregatable: true, + searchable: true, + type: 'string', + }, + { + name: 'fieldB', + aggregatable: true, + searchable: true, + type: 'number', + aggregationRestrictions: { + avg: { + agg: 'avg', + }, + }, + }, + { + name: 'fieldC', + aggregatable: false, + searchable: true, + type: 'date', + }, + { + name: 'fieldD', + aggregatable: true, + searchable: true, + type: 'date', + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'CET', + calendar_interval: 'w', + }, + }, + }, + { + name: 'fieldE', + aggregatable: true, + searchable: true, + type: 'date', + }, + ], + }; + + it('should switch index pattern id in layer', () => { + const layer = { columnOrder: [], columns: {}, indexPatternId: 'original' }; + expect(updateLayerIndexPattern(layer, indexPattern)).toEqual({ + ...layer, + indexPatternId: 'test', + }); + }); + + it('should remove operations referencing unavailable fields', () => { + const layer: EsDSLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'xxx', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + + it('should remove operations referencing fields with insufficient capabilities', () => { + const layer: EsDSLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'fieldC', + params: { + interval: 'd', + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'fieldB', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col2']); + expect(updatedLayer.columns).toEqual({ + col2: layer.columns.col2, + }); + }); + + it('should rewrite column params if that is necessary due to restrictions', () => { + const layer: EsDSLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'date', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'fieldD', + params: { + interval: 'd', + }, + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: { + ...layer.columns.col1, + params: { + interval: 'w', + timeZone: 'CET', + }, + }, + }); + }); + + it('should remove operations referencing fields with wrong field types', () => { + const layer: EsDSLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'fieldD', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + + it('should remove operations referencing fields with incompatible restrictions', () => { + const layer: EsDSLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'min', + sourceField: 'fieldC', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/state_helpers.ts b/x-pack/plugins/lens/public/esdsl_datasource/state_helpers.ts new file mode 100644 index 0000000000000..c0af696f2bbcf --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/state_helpers.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { isColumnTransferable } from './operations'; +import { operationDefinitionMap, IndexPatternColumn } from './operations'; +import { IndexPattern, EsDSLPrivateState, EsDSLLayer } from './types'; + +export function updateColumnParam({ + state, + layerId, + currentColumn, + paramName, + value, +}: { + state: EsDSLPrivateState; + layerId: string; + currentColumn: C; + paramName: string; + value: unknown; +}): EsDSLPrivateState { + const columnId = Object.entries(state.layers[layerId].columns).find( + ([_columnId, column]) => column === currentColumn + )![0]; + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columns: { + ...state.layers[layerId].columns, + [columnId]: { + ...currentColumn, + params: { + ...currentColumn.params, + [paramName]: value, + }, + }, + }, + }, + }, + }; +} + +function adjustColumnReferencesForChangedColumn( + columns: Record, + columnId: string +) { + const newColumns = { ...columns }; + Object.keys(newColumns).forEach(currentColumnId => { + if (currentColumnId !== columnId) { + const currentColumn = newColumns[currentColumnId]; + const operationDefinition = operationDefinitionMap[currentColumn.operationType]; + newColumns[currentColumnId] = operationDefinition.onOtherColumnChanged + ? operationDefinition.onOtherColumnChanged(currentColumn, newColumns) + : currentColumn; + } + }); + return newColumns; +} + +export function changeColumn({ + state, + layerId, + columnId, + newColumn, + keepParams = true, +}: { + state: EsDSLPrivateState; + layerId: string; + columnId: string; + newColumn: C; + keepParams?: boolean; +}): EsDSLPrivateState { + const oldColumn = state.layers[layerId].columns[columnId]; + + const updatedColumn = + keepParams && + oldColumn && + oldColumn.operationType === newColumn.operationType && + 'params' in oldColumn + ? { ...newColumn, params: oldColumn.params } + : newColumn; + + const newColumns = adjustColumnReferencesForChangedColumn( + { + ...state.layers[layerId].columns, + [columnId]: updatedColumn, + }, + columnId + ); + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }, + }, + }; +} + +export function deleteColumn({ + state, + layerId, + columnId, +}: { + state: EsDSLPrivateState; + layerId: string; + columnId: string; +}): EsDSLPrivateState { + const hypotheticalColumns = { ...state.layers[layerId].columns }; + delete hypotheticalColumns[columnId]; + + const newColumns = adjustColumnReferencesForChangedColumn(hypotheticalColumns, columnId); + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }, + }, + }; +} + +export function getColumnOrder(columns: Record): string[] { + const entries = Object.entries(columns); + + const [aggregations, metrics] = _.partition(entries, ([id, col]) => col.isBucketed); + + return aggregations + .sort(([id, col], [id2, col2]) => { + return ( + // Sort undefined orders last + (col.suggestedPriority !== undefined ? col.suggestedPriority : Number.MAX_SAFE_INTEGER) - + (col2.suggestedPriority !== undefined ? col2.suggestedPriority : Number.MAX_SAFE_INTEGER) + ); + }) + .map(([id]) => id) + .concat(metrics.map(([id]) => id)); +} + +export function updateLayerIndexPattern( + layer: EsDSLLayer, + newIndexPattern: IndexPattern +): EsDSLLayer { + const keptColumns: EsDSLLayer['columns'] = _.pick(layer.columns, column => + isColumnTransferable(column, newIndexPattern) + ); + const newColumns: EsDSLLayer['columns'] = _.mapValues(keptColumns, column => { + const operationDefinition = operationDefinitionMap[column.operationType]; + return operationDefinition.transfer + ? operationDefinition.transfer(column, newIndexPattern) + : column; + }); + const newColumnOrder = layer.columnOrder.filter(columnId => newColumns[columnId]); + + return { + ...layer, + indexPatternId: newIndexPattern.id, + columns: newColumns, + columnOrder: newColumnOrder, + }; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/to_expression.ts b/x-pack/plugins/lens/public/esdsl_datasource/to_expression.ts new file mode 100644 index 0000000000000..38db032aa1626 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/to_expression.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { Ast, ExpressionFunctionAST } from '@kbn/interpreter/common'; +import { IndexPatternColumn } from './indexpattern'; +import { operationDefinitionMap } from './operations'; +import { IndexPattern, EsDSLPrivateState, EsDSLLayer } from './types'; +import { OriginalColumn } from './rename_columns'; +import { dateHistogramOperation } from './operations/definitions'; + +function getExpressionForLayer(layer: EsDSLLayer): Ast | null { + if (layer.columns.length === 0) { + return null; + } + + const idMap = layer.columns.reduce((currentIdMap, column, index) => { + return { + ...currentIdMap, + [column.fieldName]: { + id: column.columnId, + }, + }; + }, {} as Record); + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'esdsl', + arguments: { + index: [layer.index], + dsl: [layer.query], + }, + }, + { + type: 'function', + function: 'lens_rename_columns', + arguments: { + idMap: [JSON.stringify(idMap)], + overwrittenFieldTypes: layer.overwrittenFieldTypes + ? [JSON.stringify(layer.overwrittenFieldTypes)] + : [], + }, + }, + ], + }; +} + +export function toExpression(state: EsDSLPrivateState, layerId: string) { + if (state.layers[layerId]) { + return getExpressionForLayer(state.layers[layerId]); + } + + return null; +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/types.ts b/x-pack/plugins/lens/public/esdsl_datasource/types.ts new file mode 100644 index 0000000000000..6659597006baa --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/types.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface EsDSLLayer { + index: string; + query: string; + columns: Array<{ columnId: string; fieldName: string }>; + timeField?: string; + overwrittenFieldTypes?: Record; +} + +export interface EsDSLPersistedState { + layers: Record; +} + +export type EsDSLPrivateState = EsDSLPersistedState & { + cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + >; + removedLayers: Array<{ + layer: EsDSLLayer; + fieldList: { fields: Array<{ name: string; type: string }>; singleRow: boolean }; + }>; +}; diff --git a/x-pack/plugins/lens/public/esdsl_datasource/utils.ts b/x-pack/plugins/lens/public/esdsl_datasource/utils.ts new file mode 100644 index 0000000000000..fadee01e695d5 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/utils.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { DraggedField } from './indexpattern'; +import { + BaseIndexPatternColumn, + FieldBasedIndexPatternColumn, +} from './operations/definitions/column_types'; +import { DataType } from '../types'; + +/** + * Normalizes the specified operation type. (e.g. document operations + * produce 'number') + */ +export function normalizeOperationDataType(type: DataType) { + return type === 'document' ? 'number' : type; +} + +export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function sortByField(columns: C[]) { + return [...columns].sort((column1, column2) => { + if (hasField(column1) && hasField(column2)) { + return column1.sourceField.localeCompare(column2.sourceField); + } + return column1.operationType.localeCompare(column2.operationType); + }); +} + +export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { + return ( + typeof fieldCandidate === 'object' && + fieldCandidate !== null && + 'field' in fieldCandidate && + 'indexPatternId' in fieldCandidate + ); +} diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index fb0a922c7e9a2..ab1dbd3575b26 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -84,6 +84,7 @@ import type { SaveModalContainerProps } from './app_plugin/save_modal_container' import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; import { setupExpressions } from './expressions'; import { getSearchProvider } from './search_provider'; +import { EsDSLDatasource } from './esdsl_datasource'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -165,6 +166,7 @@ export class LensPlugin { private datatableVisualization: DatatableVisualizationType | undefined; private editorFrameService: EditorFrameServiceType | undefined; private indexpatternDatasource: IndexPatternDatasourceType | undefined; + private esdslDatasource: IndexPatternDatasourceType | undefined; private xyVisualization: XyVisualizationType | undefined; private metricVisualization: MetricVisualizationType | undefined; private pieVisualization: PieVisualizationType | undefined; @@ -311,6 +313,7 @@ export class LensPlugin { this.datatableVisualization = new DatatableVisualization(); this.editorFrameService = new EditorFrameService(); this.indexpatternDatasource = new IndexPatternDatasource(); + this.esdslDatasource = new EsDSLDatasource(); this.xyVisualization = new XyVisualization(); this.metricVisualization = new MetricVisualization(); this.pieVisualization = new PieVisualization(); @@ -331,6 +334,7 @@ export class LensPlugin { formatFactory, }; this.indexpatternDatasource.setup(core, dependencies); + this.esdslDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index af9897581fcf4..f5e53de44b5f1 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -52,10 +52,12 @@ export const getPreloadedState = ({ const initialDatasourceId = getInitialDatasourceId(datasourceMap); const datasourceStates: LensAppState['datasourceStates'] = {}; if (initialDatasourceId) { - datasourceStates[initialDatasourceId] = { - state: null, - isLoading: true, - }; + Object.keys(datasourceMap).forEach((datasourceId) => { + datasourceStates[datasourceId] = { + state: null, + isLoading: true, + }; + }); } const state = { @@ -383,13 +385,18 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { }; } ) => { + const activeDatasourceId = state.activeDatasourceId!; + const activeDatasource = datasourceMap[activeDatasourceId]; return { ...state, datasourceStates: { ...state.datasourceStates, - [payload.newDatasourceId]: state.datasourceStates[payload.newDatasourceId] || { - state: null, - isLoading: true, + [payload.newDatasourceId]: { + isLoading: false, + state: datasourceMap[payload.newDatasourceId].insertLayer( + state.datasourceStates[payload.newDatasourceId].state, + activeDatasource.getLayers(state.datasourceStates[activeDatasourceId].state)[0] + ), }, }, activeDatasourceId: payload.newDatasourceId, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a9a9539064659..1c22aebeb71ae 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -198,6 +198,10 @@ export interface Datasource { domElement: Element, props: DatasourceDataPanelProps ) => ((cleanupElement: Element) => void) | void; + renderHorizontalDataPanel?: ( + domElement: Element, + props: DatasourceDataPanelProps + ) => ((cleanupElement: Element) => void) | void; renderDimensionTrigger: ( domElement: Element, props: DatasourceDimensionTriggerProps From 265b94adcbd19457de22936eca11e7604a760582 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 16 Nov 2021 13:12:22 +0100 Subject: [PATCH 02/15] use data views instead of index string --- .../data/common/search/expressions/esdsl.ts | 30 +- .../data/public/search/expressions/esdsl.ts | 3 +- .../rename_columns/rename_columns_fn.ts | 8 +- .../esdsl_datasource/change_indexpattern.tsx | 118 ++++ .../public/esdsl_datasource/datapanel.tsx | 105 ++-- .../lens/public/esdsl_datasource/esdsl.tsx | 38 +- .../public/esdsl_datasource/loader.test.ts | 585 ------------------ .../lens/public/esdsl_datasource/loader.ts | 315 ---------- .../public/esdsl_datasource/to_expression.ts | 14 +- .../lens/public/esdsl_datasource/types.ts | 11 +- 10 files changed, 270 insertions(+), 957 deletions(-) create mode 100644 x-pack/plugins/lens/public/esdsl_datasource/change_indexpattern.tsx delete mode 100644 x-pack/plugins/lens/public/esdsl_datasource/loader.test.ts delete mode 100644 x-pack/plugins/lens/public/esdsl_datasource/loader.ts diff --git a/src/plugins/data/common/search/expressions/esdsl.ts b/src/plugins/data/common/search/expressions/esdsl.ts index faa43dab65657..490191c4363cf 100644 --- a/src/plugins/data/common/search/expressions/esdsl.ts +++ b/src/plugins/data/common/search/expressions/esdsl.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { buildEsQuery } from '@kbn/es-query'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { get } from 'lodash'; import { EsRawResponse } from './es_raw_response'; import { RequestStatistics, RequestAdapter } from '../../../../inspector/common'; @@ -25,6 +26,7 @@ interface Arguments { dsl: string; index: string; size: number; + timeField?: string; } export type EsdslExpressionFunctionDefinition = ExpressionFunctionDefinition< @@ -68,6 +70,10 @@ export const getEsdslFn = ({ }), required: true, }, + timeField: { + types: ['string'], + help: '', + }, size: { types: ['number'], help: i18n.translate('data.search.esdsl.size.help', { @@ -77,11 +83,29 @@ export const getEsdslFn = ({ }, }, async fn(input, args, { inspectorAdapters, abortSignal, getKibanaRequest }) { - const { search, uiSettingsClient } = await getStartDependencies(getKibanaRequest); + const { + search, + uiSettingsClient, + query: queryS, + } = await getStartDependencies(getKibanaRequest); const dsl = JSON.parse(args.dsl); if (input) { + const timeRange: any = get(input, 'timeRange', undefined); + const timeField = get(args, 'timeField', undefined); + const timeBounds = + timeRange && timeField && queryS.timefilter.timefilter.calculateBounds(timeRange); + const timeFilter = timeBounds && { + range: { + [timeField]: { + format: 'strict_date_optional_time', + gte: timeBounds.min!.toISOString(), + lte: timeBounds.max!.toISOString(), + }, + }, + }; + const esQueryConfigs = getEsQueryConfig(uiSettingsClient as any); const query = buildEsQuery( undefined, // args.index, @@ -94,6 +118,10 @@ export const getEsdslFn = ({ query.bool.must.push(dsl.query); } + if (timeFilter) { + query.bool.must.push(timeFilter); + } + dsl.query = query; } diff --git a/src/plugins/data/public/search/expressions/esdsl.ts b/src/plugins/data/public/search/expressions/esdsl.ts index 8d65f6e4ee688..04414ff5a6db1 100644 --- a/src/plugins/data/public/search/expressions/esdsl.ts +++ b/src/plugins/data/public/search/expressions/esdsl.ts @@ -32,10 +32,11 @@ export function getEsdsl({ }) { return getEsdslFn({ getStartDependencies: async () => { - const [core, , { search }] = await getStartServices(); + const [core, , { search, query }] = await getStartServices(); return { uiSettingsClient: core.uiSettings as any as UiSettingsCommon, search: search.search, + query, }; }, }); diff --git a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts index 9129bac763142..57a33630d0c32 100644 --- a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts +++ b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts @@ -57,9 +57,11 @@ export const renameColumnFn: RenameColumnsExpressionFunction['fn'] = ( ...column, id: mappedItem.id, name: getColumnName(mappedItem, column), - type: overwrittenFieldTypes[column.id] || column.type, - serializedParams: - overwrittenFieldTypes[column.id] === 'date' ? { id: 'date' } : column.serializedParams, + meta: { + ...column.meta, + type: overwrittenFieldTypes[column.id] || column.meta.type, + params: overwrittenFieldTypes[column.id] === 'date' ? { id: 'date' } : column.meta.params, + }, }; }), }; diff --git a/x-pack/plugins/lens/public/esdsl_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/esdsl_datasource/change_indexpattern.tsx new file mode 100644 index 0000000000000..d5fabb9d7ef80 --- /dev/null +++ b/x-pack/plugins/lens/public/esdsl_datasource/change_indexpattern.tsx @@ -0,0 +1,118 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { EuiPopover, EuiPopoverTitle, EuiSelectable, EuiSelectableProps } from '@elastic/eui'; +import { IndexPatternRef } from './types'; +import { trackUiEvent } from '../lens_ui_telemetry'; +import { ToolbarButton, ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; + +export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & { + label: string; + title?: string; +}; + +export function ChangeIndexPattern({ + indexPatternRefs, + isMissingCurrent, + indexPatternId, + onChangeIndexPattern, + trigger, + selectableProps, +}: { + trigger: ChangeIndexPatternTriggerProps; + indexPatternRefs: IndexPatternRef[]; + isMissingCurrent?: boolean; + onChangeIndexPattern: (newId: string) => void; + indexPatternId?: string; + selectableProps?: EuiSelectableProps; +}) { + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + + // be careful to only add color with a value, otherwise it will fallbacks to "primary" + const colorProp = isMissingCurrent + ? { + color: 'danger' as const, + } + : {}; + + const createTrigger = function () { + const { label, title, ...rest } = trigger; + return ( + setPopoverIsOpen(!isPopoverOpen)} + fullWidth + {...colorProp} + {...rest} + > + {label} + + ); + }; + + return ( + <> + setPopoverIsOpen(false)} + display="block" + panelPaddingSize="s" + ownFocus + > +
+ + {i18n.translate('xpack.lens.indexPattern.changeDataViewTitle', { + defaultMessage: 'Data view', + })} + + + {...selectableProps} + searchable + singleSelection="always" + options={indexPatternRefs.map(({ title, id }) => ({ + key: id, + label: title, + value: id, + checked: id === indexPatternId ? 'on' : undefined, + }))} + onChange={(choices) => { + const choice = choices.find(({ checked }) => checked) as unknown as { + value: string; + }; + trackUiEvent('indexpattern_changed'); + onChangeIndexPattern(choice.value); + setPopoverIsOpen(false); + }} + searchProps={{ + compressed: true, + ...(selectableProps ? selectableProps.searchProps : undefined), + }} + > + {(list, search) => ( + <> + {search} + {list} + + )} + +
+
+ + ); +} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx index fd125cc4bb00b..68601170e2486 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx @@ -32,14 +32,15 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { EuiFieldText, EuiSelect } from '@elastic/eui'; import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; import { IndexPattern, EsDSLPrivateState, IndexPatternField, IndexPatternRef } from './types'; import { esRawResponse } from '../../../../../src/plugins/data/common'; +import { ChangeIndexPattern } from './change_indexpattern'; export type Props = DatasourceDataPanelProps & { data: DataPublicPluginStart; }; -import { EuiFieldText, EuiSelect } from '@elastic/eui'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { flatten } from './flatten'; @@ -211,46 +212,57 @@ export function EsDSLHorizontalDataPanel({ > - {Object.entries(layers).map(([id, layer]) => ( - - - { - setLocalState({ - ...state, - layers: { - ...state.layers, - [id]: { - ...layer, - index: e.target.value, + {Object.entries(layers).map(([id, layer]) => { + const ref = state.indexPatternRefs.find((r) => r.id === layer.index); + return ( + + + { + setState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + index: newId, + }, }, - }, - }); - }} - /> - { - setLocalState({ - ...state, - layers: { - ...state.layers, - [id]: { - ...layer, - query: val, + }); + }} + /> + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + query: val, + }, }, - }, - }); - }} - /> - - - ))} + }); + }} + /> + + + ); + })} {Object.entries(removedLayers).map(([id, { layer }]) => ( @@ -278,7 +290,9 @@ export function EsDSLHorizontalDataPanel({ .search({ params: { size: 0, - index: layer.index, + index: [ + state.indexPatternRefs.find((r) => r.id === layer.index)!.title, + ], body: JSON.parse(layer.query), }, }) @@ -295,6 +309,17 @@ export function EsDSLHorizontalDataPanel({ const { rows, columns } = esRawResponse.to!.datatable({ body: response.rawResponse, }); + columns.forEach((col) => { + const testVal = rows[0][col.id]; + if (typeof testVal === 'number' && Math.log10(testVal) > 11) { + // col.meta.type = 'date'; + // col.meta.params = { id: 'date' }; + localState.layers[layerId].overwrittenFieldTypes = { + ...(localState.layers[layerId].overwrittenFieldTypes || {}), + [col.id]: 'date', + }; + } + }); // todo hack some logic in for dates cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; }); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx index fd192cc08a6de..93d8ff675e096 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx +++ b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx @@ -25,11 +25,28 @@ import { import { toExpression } from './to_expression'; import { EsDSLDataPanel, EsDSLHorizontalDataPanel } from './datapanel'; +import type { DataViewsService } from '../../../../../src/plugins/data_views/common'; import { EsDSLLayer, EsDSLPrivateState, EsDSLPersistedState } from './types'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { Datasource } from '../types'; import { esRawResponse } from '../../../../../src/plugins/data/common'; +async function loadIndexPatternRefs( + indexPatternsService: DataViewsService +): Promise { + const indexPatterns = await indexPatternsService.getIdsWithTitle(); + + const timefields = await Promise.all( + indexPatterns.map((p) => indexPatternsService.get(p.id).then((pat) => pat.timeFieldName)) + ); + + return indexPatterns + .map((p, i) => ({ ...p, timeField: timefields[i] })) + .sort((a, b) => { + return a.title.localeCompare(b.title); + }); +} + export function getEsDSLDatasource({ core, storage, @@ -51,6 +68,7 @@ export function getEsDSLDatasource({ }, async initialize(state?: EsDSLPersistedState) { const initState = state || { layers: {} }; + const indexPatternRefs: IndexPatternRef[] = await loadIndexPatternRefs(data.dataViews); const responses = await Promise.all( Object.entries(initState.layers).map(([id, layer]) => { return data.search @@ -79,6 +97,7 @@ export function getEsDSLDatasource({ ...initState, cachedFieldList, removedLayers: [], + indexPatternRefs, }; }, @@ -104,7 +123,9 @@ export function getEsDSLDatasource({ }, layers: { ...state.layers, - [newLayerId]: removedLayer ? removedLayer.layer : blankLayer(), + [newLayerId]: removedLayer + ? removedLayer.layer + : blankLayer(state.indexPatternRefs[0].id), }, removedLayers: newRemovedList, }; @@ -254,7 +275,16 @@ export function getEsDSLDatasource({ domElement: Element, props: DatasourceLayerPanelProps ) => { - render({props.state.layers[props.layerId].index}, domElement); + render( + + { + props.state.indexPatternRefs.find( + (r) => r.id === props.state.layers[props.layerId].index + )!.title + } + , + domElement + ); }, canHandleDrop: () => false, @@ -349,9 +379,9 @@ export function getEsDSLDatasource({ return esdslDatasource; } -function blankLayer() { +function blankLayer(index: string) { return { - index: '*', + index, query: '', columns: [], }; diff --git a/x-pack/plugins/lens/public/esdsl_datasource/loader.test.ts b/x-pack/plugins/lens/public/esdsl_datasource/loader.test.ts deleted file mode 100644 index 96ccf7fe9502d..0000000000000 --- a/x-pack/plugins/lens/public/esdsl_datasource/loader.test.ts +++ /dev/null @@ -1,585 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { SavedObjectsClientContract } from 'kibana/public'; -import _ from 'lodash'; -import { - loadInitialState, - loadIndexPatterns, - changeIndexPattern, - changeLayerIndexPattern, - syncExistingFields, -} from './loader'; -import { EsDSLPersistedState, EsDSLPrivateState } from './types'; -import { documentField } from './document_field'; - -jest.mock('./operations'); - -const sampleIndexPatterns = { - a: { - id: 'a', - title: 'my-fake-index-pattern', - timeFieldName: 'timestamp', - fields: [ - { - name: 'timestamp', - type: 'date', - aggregatable: true, - searchable: true, - }, - { - name: 'start_date', - type: 'date', - aggregatable: true, - searchable: true, - }, - { - name: 'bytes', - type: 'number', - aggregatable: true, - searchable: true, - }, - { - name: 'memory', - type: 'number', - aggregatable: true, - searchable: true, - }, - { - name: 'source', - type: 'string', - aggregatable: true, - searchable: true, - esTypes: ['keyword'], - }, - { - name: 'dest', - type: 'string', - aggregatable: true, - searchable: true, - esTypes: ['keyword'], - }, - documentField, - ], - }, - b: { - id: 'b', - title: 'my-fake-restricted-pattern', - timeFieldName: 'timestamp', - fields: [ - { - name: 'timestamp', - type: 'date', - aggregatable: true, - searchable: true, - aggregationRestrictions: { - date_histogram: { - agg: 'date_histogram', - fixed_interval: '1d', - delay: '7d', - time_zone: 'UTC', - }, - }, - }, - { - name: 'bytes', - type: 'number', - aggregatable: true, - searchable: true, - aggregationRestrictions: { - // Ignored in the UI - histogram: { - agg: 'histogram', - interval: 1000, - }, - avg: { - agg: 'avg', - }, - max: { - agg: 'max', - }, - min: { - agg: 'min', - }, - sum: { - agg: 'sum', - }, - }, - }, - { - name: 'source', - type: 'string', - aggregatable: false, - searchable: false, - scripted: true, - aggregationRestrictions: { - terms: { - agg: 'terms', - }, - }, - esTypes: ['keyword'], - }, - documentField, - ], - }, -}; - -function indexPatternSavedObject({ id }: { id: keyof typeof sampleIndexPatterns }) { - const pattern = { - ...sampleIndexPatterns[id], - fields: [ - ...sampleIndexPatterns[id].fields, - { - name: 'description', - type: 'string', - aggregatable: false, - searchable: true, - esTypes: ['text'], - }, - ], - }; - return { - id, - type: 'index-pattern', - attributes: { - title: pattern.title, - timeFieldName: pattern.timeFieldName, - fields: JSON.stringify(pattern.fields.filter(f => f.type !== 'document')), - }, - }; -} - -function mockClient() { - return ({ - find: jest.fn(async () => ({ - savedObjects: [ - { id: 'a', attributes: { title: sampleIndexPatterns.a.title } }, - { id: 'b', attributes: { title: sampleIndexPatterns.b.title } }, - ], - })), - async bulkGet(indexPatterns: Array<{ id: keyof typeof sampleIndexPatterns }>) { - return { - savedObjects: indexPatterns.map(({ id }) => indexPatternSavedObject({ id })), - }; - }, - } as unknown) as Pick; -} - -describe('loader', () => { - describe('loadIndexPatterns', () => { - it('should not load index patterns that are already loaded', async () => { - const cache = await loadIndexPatterns({ - cache: sampleIndexPatterns, - patterns: ['a', 'b'], - savedObjectsClient: { - bulkGet: jest.fn(() => Promise.reject('bulkGet should not have been called')), - find: jest.fn(() => Promise.reject('find should not have been called')), - }, - }); - - expect(cache).toEqual(sampleIndexPatterns); - }); - - it('should load index patterns that are not loaded', async () => { - const cache = await loadIndexPatterns({ - cache: { - b: sampleIndexPatterns.b, - }, - patterns: ['a', 'b'], - savedObjectsClient: mockClient(), - }); - - expect(cache).toMatchObject(sampleIndexPatterns); - }); - - it('should allow scripted, but not full text fields', async () => { - const cache = await loadIndexPatterns({ - cache: {}, - patterns: ['a', 'b'], - savedObjectsClient: mockClient(), - }); - - expect(cache).toMatchObject(sampleIndexPatterns); - }); - - it('should apply field restrictions from typeMeta', async () => { - const cache = await loadIndexPatterns({ - cache: {}, - patterns: ['foo'], - savedObjectsClient: ({ - ...mockClient(), - async bulkGet() { - return { - savedObjects: [ - { - id: 'foo', - type: 'index-pattern', - attributes: { - title: 'Foo index', - typeMeta: JSON.stringify({ - aggs: { - date_histogram: { - timestamp: { - agg: 'date_histogram', - fixed_interval: 'm', - }, - }, - sum: { - bytes: { - agg: 'sum', - }, - }, - }, - }), - fields: JSON.stringify([ - { - name: 'timestamp', - type: 'date', - aggregatable: true, - searchable: true, - }, - { - name: 'bytes', - type: 'number', - aggregatable: true, - searchable: true, - }, - ]), - }, - }, - ], - }; - }, - } as unknown) as Pick, - }); - - expect(cache.foo.fields.find(f => f.name === 'bytes')!.aggregationRestrictions).toEqual({ - sum: { agg: 'sum' }, - }); - expect(cache.foo.fields.find(f => f.name === 'timestamp')!.aggregationRestrictions).toEqual({ - date_histogram: { agg: 'date_histogram', fixed_interval: 'm' }, - }); - }); - }); - - describe('loadInitialState', () => { - it('should load a default state', async () => { - const state = await loadInitialState({ - savedObjectsClient: mockClient(), - }); - - expect(state).toMatchObject({ - currentIndexPatternId: 'a', - indexPatternRefs: [ - { id: 'a', title: sampleIndexPatterns.a.title }, - { id: 'b', title: sampleIndexPatterns.b.title }, - ], - indexPatterns: { - a: sampleIndexPatterns.a, - }, - layers: {}, - showEmptyFields: false, - }); - }); - - it('should use the default index pattern id, if provided', async () => { - const state = await loadInitialState({ - defaultIndexPatternId: 'b', - savedObjectsClient: mockClient(), - }); - - expect(state).toMatchObject({ - currentIndexPatternId: 'b', - indexPatternRefs: [ - { id: 'a', title: sampleIndexPatterns.a.title }, - { id: 'b', title: sampleIndexPatterns.b.title }, - ], - indexPatterns: { - b: sampleIndexPatterns.b, - }, - layers: {}, - showEmptyFields: false, - }); - }); - - it('should initialize from saved state', async () => { - const savedState: EsDSLPersistedState = { - currentIndexPatternId: 'b', - layers: { - layerb: { - indexPatternId: 'b', - columnOrder: ['col1', 'col2'], - columns: { - col1: { - dataType: 'date', - isBucketed: true, - label: 'My date', - operationType: 'date_histogram', - params: { - interval: 'm', - }, - sourceField: 'timestamp', - }, - col2: { - dataType: 'number', - isBucketed: false, - label: 'Sum of bytes', - operationType: 'sum', - sourceField: 'bytes', - }, - }, - }, - }, - }; - const state = await loadInitialState({ - state: savedState, - savedObjectsClient: mockClient(), - }); - - expect(state).toMatchObject({ - currentIndexPatternId: 'b', - indexPatternRefs: [ - { id: 'a', title: sampleIndexPatterns.a.title }, - { id: 'b', title: sampleIndexPatterns.b.title }, - ], - indexPatterns: { - b: sampleIndexPatterns.b, - }, - layers: savedState.layers, - showEmptyFields: false, - }); - }); - }); - - describe('changeIndexPattern', () => { - it('loads the index pattern and then sets it as current', async () => { - const setState = jest.fn(); - const state: EsDSLPrivateState = { - currentIndexPatternId: 'b', - indexPatternRefs: [], - indexPatterns: {}, - existingFields: {}, - layers: {}, - showEmptyFields: true, - }; - - await changeIndexPattern({ - state, - setState, - id: 'a', - savedObjectsClient: mockClient(), - onError: jest.fn(), - }); - - expect(setState).toHaveBeenCalledTimes(1); - expect(setState.mock.calls[0][0](state)).toMatchObject({ - currentIndexPatternId: 'a', - indexPatterns: { - a: sampleIndexPatterns.a, - }, - }); - }); - - it('handles errors', async () => { - const setState = jest.fn(); - const onError = jest.fn(); - const err = new Error('NOPE!'); - const state: EsDSLPrivateState = { - currentIndexPatternId: 'b', - indexPatternRefs: [], - existingFields: {}, - indexPatterns: {}, - layers: {}, - showEmptyFields: true, - }; - - await changeIndexPattern({ - state, - setState, - id: 'a', - savedObjectsClient: { - ...mockClient(), - bulkGet: jest.fn(async () => { - throw err; - }), - }, - onError, - }); - - expect(setState).not.toHaveBeenCalled(); - expect(onError).toHaveBeenCalledWith(err); - }); - }); - - describe('changeLayerIndexPattern', () => { - it('loads the index pattern and then changes the specified layer', async () => { - const setState = jest.fn(); - const state: EsDSLPrivateState = { - currentIndexPatternId: 'b', - indexPatternRefs: [], - existingFields: {}, - indexPatterns: { - a: sampleIndexPatterns.a, - }, - layers: { - l0: { - columnOrder: ['col1'], - columns: {}, - indexPatternId: 'a', - }, - l1: { - columnOrder: ['col2'], - columns: { - col2: { - dataType: 'date', - isBucketed: true, - label: 'My hist', - operationType: 'date_histogram', - params: { - interval: 'm', - }, - sourceField: 'timestamp', - }, - }, - indexPatternId: 'a', - }, - }, - showEmptyFields: true, - }; - - await changeLayerIndexPattern({ - state, - setState, - indexPatternId: 'b', - layerId: 'l1', - savedObjectsClient: mockClient(), - onError: jest.fn(), - }); - - expect(setState).toHaveBeenCalledTimes(1); - expect(setState.mock.calls[0][0](state)).toMatchObject({ - currentIndexPatternId: 'b', - indexPatterns: { - a: sampleIndexPatterns.a, - b: sampleIndexPatterns.b, - }, - layers: { - l0: { - columnOrder: ['col1'], - columns: {}, - indexPatternId: 'a', - }, - l1: { - columnOrder: ['col2'], - columns: { - col2: { - dataType: 'date', - isBucketed: true, - label: 'My hist', - operationType: 'date_histogram', - params: { - interval: '1d', - }, - sourceField: 'timestamp', - }, - }, - indexPatternId: 'b', - }, - }, - }); - }); - - it('handles errors', async () => { - const setState = jest.fn(); - const onError = jest.fn(); - const err = new Error('NOPE!'); - const state: EsDSLPrivateState = { - currentIndexPatternId: 'b', - indexPatternRefs: [], - existingFields: {}, - indexPatterns: { - a: sampleIndexPatterns.a, - }, - layers: { - l0: { - columnOrder: ['col1'], - columns: {}, - indexPatternId: 'a', - }, - }, - showEmptyFields: true, - }; - - await changeLayerIndexPattern({ - state, - setState, - indexPatternId: 'b', - layerId: 'l0', - savedObjectsClient: { - ...mockClient(), - bulkGet: jest.fn(async () => { - throw err; - }), - }, - onError, - }); - - expect(setState).not.toHaveBeenCalled(); - expect(onError).toHaveBeenCalledWith(err); - }); - }); - - describe('syncExistingFields', () => { - const dslQuery = { - bool: { - must: [], - filter: [{ match_all: {} }], - should: [], - must_not: [], - }, - }; - - it('should call once for each index pattern', async () => { - const setState = jest.fn(); - const fetchJson = jest.fn((path: string) => { - const indexPatternTitle = _.last(path.split('/')); - return { - indexPatternTitle, - existingFieldNames: ['field_1', 'field_2'].map( - fieldName => `${indexPatternTitle}_${fieldName}` - ), - }; - }); - - await syncExistingFields({ - dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fetchJson: fetchJson as any, - indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], - setState, - dslQuery, - }); - - expect(fetchJson).toHaveBeenCalledTimes(3); - expect(setState).toHaveBeenCalledTimes(1); - - const [fn] = setState.mock.calls[0]; - const newState = fn({ - foo: 'bar', - existingFields: {}, - }); - - expect(newState).toEqual({ - foo: 'bar', - existingFields: { - a: { a_field_1: true, a_field_2: true }, - b: { b_field_1: true, b_field_2: true }, - c: { c_field_1: true, c_field_2: true }, - }, - }); - }); - }); -}); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/loader.ts b/x-pack/plugins/lens/public/esdsl_datasource/loader.ts deleted file mode 100644 index 8139a8ba5a1df..0000000000000 --- a/x-pack/plugins/lens/public/esdsl_datasource/loader.ts +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import { SavedObjectsClientContract, SavedObjectAttributes, HttpSetup } from 'kibana/public'; -import { SimpleSavedObject } from 'kibana/public'; -import { StateSetter } from '../types'; -import { - IndexPattern, - IndexPatternRef, - EsDSLPersistedState, - EsDSLPrivateState, - IndexPatternField, -} from './types'; -import { updateLayerIndexPattern } from './state_helpers'; -import { DateRange, ExistingFields } from '../../common/types'; -import { BASE_API_URL } from '../../common'; -import { documentField } from './document_field'; -import { - indexPatterns as indexPatternsUtils, - IFieldType, - IndexPatternTypeMeta, -} from '../../../../../src/plugins/data/public'; - -interface SavedIndexPatternAttributes extends SavedObjectAttributes { - title: string; - timeFieldName: string | null; - fields: string; - fieldFormatMap: string; - typeMeta: string; -} - -type SetState = StateSetter; -type SavedObjectsClient = Pick; -type ErrorHandler = (err: Error) => void; - -export async function loadIndexPatterns({ - patterns, - savedObjectsClient, - cache, -}: { - patterns: string[]; - savedObjectsClient: SavedObjectsClient; - cache: Record; -}) { - const missingIds = patterns.filter(id => !cache[id]); - - if (missingIds.length === 0) { - return cache; - } - - const resp = await savedObjectsClient.bulkGet( - missingIds.map(id => ({ id, type: 'index-pattern' })) - ); - - return resp.savedObjects.reduce( - (acc, savedObject) => { - const indexPattern = fromSavedObject( - savedObject as SimpleSavedObject - ); - acc[indexPattern.id] = indexPattern; - return acc; - }, - { ...cache } - ); -} - -export async function loadInitialState({ - state, - savedObjectsClient, - defaultIndexPatternId, -}: { - state?: EsDSLPersistedState; - savedObjectsClient: SavedObjectsClient; - defaultIndexPatternId?: string; -}): Promise { - const indexPatternRefs = await loadIndexPatternRefs(savedObjectsClient); - const requiredPatterns = _.unique( - state - ? Object.values(state.layers) - .map(l => l.indexPatternId) - .concat(state.currentIndexPatternId) - : [defaultIndexPatternId || indexPatternRefs[0].id] - ); - - const currentIndexPatternId = requiredPatterns[0]; - const indexPatterns = await loadIndexPatterns({ - savedObjectsClient, - cache: {}, - patterns: requiredPatterns, - }); - - if (state) { - return { - ...state, - currentIndexPatternId, - indexPatternRefs, - indexPatterns, - showEmptyFields: false, - existingFields: {}, - }; - } - - return { - currentIndexPatternId, - indexPatternRefs, - indexPatterns, - layers: {}, - showEmptyFields: false, - existingFields: {}, - }; -} - -export async function changeIndexPattern({ - id, - savedObjectsClient, - state, - setState, - onError, -}: { - id: string; - savedObjectsClient: SavedObjectsClient; - state: EsDSLPrivateState; - setState: SetState; - onError: ErrorHandler; -}) { - try { - const indexPatterns = await loadIndexPatterns({ - savedObjectsClient, - cache: state.indexPatterns, - patterns: [id], - }); - - setState(s => ({ - ...s, - layers: isSingleEmptyLayer(state.layers) - ? _.mapValues(state.layers, layer => updateLayerIndexPattern(layer, indexPatterns[id])) - : state.layers, - indexPatterns: { - ...s.indexPatterns, - [id]: indexPatterns[id], - }, - currentIndexPatternId: id, - })); - } catch (err) { - onError(err); - } -} - -export async function changeLayerIndexPattern({ - indexPatternId, - layerId, - savedObjectsClient, - state, - setState, - onError, - replaceIfPossible, -}: { - indexPatternId: string; - layerId: string; - savedObjectsClient: SavedObjectsClient; - state: EsDSLPrivateState; - setState: SetState; - onError: ErrorHandler; - replaceIfPossible?: boolean; -}) { - try { - const indexPatterns = await loadIndexPatterns({ - savedObjectsClient, - cache: state.indexPatterns, - patterns: [indexPatternId], - }); - - setState(s => ({ - ...s, - layers: { - ...s.layers, - [layerId]: updateLayerIndexPattern(s.layers[layerId], indexPatterns[indexPatternId]), - }, - indexPatterns: { - ...s.indexPatterns, - [indexPatternId]: indexPatterns[indexPatternId], - }, - currentIndexPatternId: replaceIfPossible ? indexPatternId : s.currentIndexPatternId, - })); - } catch (err) { - onError(err); - } -} - -async function loadIndexPatternRefs( - savedObjectsClient: SavedObjectsClient -): Promise { - const result = await savedObjectsClient.find<{ title: string }>({ - type: 'index-pattern', - fields: ['title'], - perPage: 10000, - }); - - return result.savedObjects - .map(o => ({ - id: String(o.id), - title: (o.attributes as { title: string }).title, - })) - .sort((a, b) => { - return a.title.localeCompare(b.title); - }); -} - -export async function syncExistingFields({ - indexPatterns, - dateRange, - fetchJson, - setState, - dslQuery, -}: { - dateRange: DateRange; - indexPatterns: Array<{ id: string; timeFieldName?: string | null }>; - fetchJson: HttpSetup['post']; - setState: SetState; - dslQuery: object; -}) { - const emptinessInfo = await Promise.all( - indexPatterns.map(pattern => { - const body: Record = { - dslQuery, - fromDate: dateRange.fromDate, - toDate: dateRange.toDate, - }; - - if (pattern.timeFieldName) { - body.timeFieldName = pattern.timeFieldName; - } - - return fetchJson(`${BASE_API_URL}/existing_fields/${pattern.id}`, { - body: JSON.stringify(body), - }) as Promise; - }) - ); - - setState(state => ({ - ...state, - existingFields: emptinessInfo.reduce((acc, info) => { - acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); - return acc; - }, state.existingFields), - })); -} - -function booleanMap(keys: string[]) { - return keys.reduce((acc, key) => { - acc[key] = true; - return acc; - }, {} as Record); -} - -function isSingleEmptyLayer(layerMap: EsDSLPrivateState['layers']) { - const layers = Object.values(layerMap); - return layers.length === 1 && layers[0].columnOrder.length === 0; -} - -function fromSavedObject( - savedObject: SimpleSavedObject -): IndexPattern { - const { id, attributes, type } = savedObject; - const indexPattern = { - ...attributes, - id, - type, - title: attributes.title, - fields: (JSON.parse(attributes.fields) as IFieldType[]) - .filter( - field => - !indexPatternsUtils.isNestedField(field) && (!!field.aggregatable || !!field.scripted) - ) - .concat(documentField) as IndexPatternField[], - typeMeta: attributes.typeMeta - ? (JSON.parse(attributes.typeMeta) as IndexPatternTypeMeta) - : undefined, - fieldFormatMap: attributes.fieldFormatMap ? JSON.parse(attributes.fieldFormatMap) : undefined, - }; - - const { typeMeta } = indexPattern; - if (!typeMeta) { - return indexPattern; - } - - const newFields = [...(indexPattern.fields as IndexPatternField[])]; - - if (typeMeta.aggs) { - const aggs = Object.keys(typeMeta.aggs); - newFields.forEach((field, index) => { - const restrictionsObj: IndexPatternField['aggregationRestrictions'] = {}; - aggs.forEach(agg => { - const restriction = typeMeta.aggs && typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name]; - if (restriction) { - restrictionsObj[agg] = restriction; - } - }); - if (Object.keys(restrictionsObj).length) { - newFields[index] = { ...field, aggregationRestrictions: restrictionsObj }; - } - }); - } - - return { - id: indexPattern.id, - title: indexPattern.title, - timeFieldName: indexPattern.timeFieldName || undefined, - fields: newFields, - }; -} diff --git a/x-pack/plugins/lens/public/esdsl_datasource/to_expression.ts b/x-pack/plugins/lens/public/esdsl_datasource/to_expression.ts index 38db032aa1626..bc12e66d2aae5 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/esdsl_datasource/to_expression.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. */ import _ from 'lodash'; @@ -12,7 +13,7 @@ import { IndexPattern, EsDSLPrivateState, EsDSLLayer } from './types'; import { OriginalColumn } from './rename_columns'; import { dateHistogramOperation } from './operations/definitions'; -function getExpressionForLayer(layer: EsDSLLayer): Ast | null { +function getExpressionForLayer(layer: EsDSLLayer, refs: any): Ast | null { if (layer.columns.length === 0) { return null; } @@ -33,8 +34,9 @@ function getExpressionForLayer(layer: EsDSLLayer): Ast | null { type: 'function', function: 'esdsl', arguments: { - index: [layer.index], + index: [refs.find((r) => r.id === layer.index)!.title], dsl: [layer.query], + timeField: [refs.find((r) => r.id === layer.index)!.timeField], }, }, { @@ -42,7 +44,7 @@ function getExpressionForLayer(layer: EsDSLLayer): Ast | null { function: 'lens_rename_columns', arguments: { idMap: [JSON.stringify(idMap)], - overwrittenFieldTypes: layer.overwrittenFieldTypes + overwriteTypes: layer.overwrittenFieldTypes ? [JSON.stringify(layer.overwrittenFieldTypes)] : [], }, @@ -53,7 +55,7 @@ function getExpressionForLayer(layer: EsDSLLayer): Ast | null { export function toExpression(state: EsDSLPrivateState, layerId: string) { if (state.layers[layerId]) { - return getExpressionForLayer(state.layers[layerId]); + return getExpressionForLayer(state.layers[layerId], state.indexPatternRefs); } return null; diff --git a/x-pack/plugins/lens/public/esdsl_datasource/types.ts b/x-pack/plugins/lens/public/esdsl_datasource/types.ts index 6659597006baa..dbe36176a027d 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/types.ts +++ b/x-pack/plugins/lens/public/esdsl_datasource/types.ts @@ -1,7 +1,8 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. */ export interface EsDSLLayer { @@ -17,6 +18,7 @@ export interface EsDSLPersistedState { } export type EsDSLPrivateState = EsDSLPersistedState & { + indexPatternRefs: IndexPatternRef[]; cachedFieldList: Record< string, { fields: Array<{ name: string; type: string }>; singleRow: boolean } @@ -26,3 +28,8 @@ export type EsDSLPrivateState = EsDSLPersistedState & { fieldList: { fields: Array<{ name: string; type: string }>; singleRow: boolean }; }>; }; + +export interface IndexPatternRef { + id: string; + title: string; +} From 79fd5761409232c81a0f725d4afba99383e91eb5 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 17 Nov 2021 19:28:04 +0100 Subject: [PATCH 03/15] add essql support to Lens --- .../functions/browser/essql.ts | 32 +- .../canvas/common/lib/request/filters.ts | 1 + x-pack/plugins/canvas/public/plugin.tsx | 37 +- .../editor_frame/data_panel_wrapper.scss | 3 - .../public/esdsl_datasource/datapanel.tsx | 2 +- .../lens/public/esdsl_datasource/esdsl.tsx | 8 +- .../public/essql_datasource/_datapanel.scss | 67 + .../public/essql_datasource/_field_item.scss | 87 + .../lens/public/essql_datasource/_index.scss | 4 + .../essql_datasource/change_indexpattern.tsx | 118 ++ .../public/essql_datasource/datapanel.tsx | 342 ++++ .../dimension_panel/_field_select.scss | 7 + .../dimension_panel/_index.scss | 2 + .../dimension_panel/_popover.scss | 38 + .../bucket_nesting_editor.test.tsx | 262 +++ .../dimension_panel/bucket_nesting_editor.tsx | 138 ++ .../dimension_panel/dimension_panel.test.tsx | 1422 +++++++++++++++++ .../dimension_panel/dimension_panel.tsx | 234 +++ .../dimension_panel/field_select.tsx | 183 +++ .../dimension_panel/format_selector.tsx | 136 ++ .../essql_datasource/dimension_panel/index.ts | 7 + .../dimension_panel/popover_editor.tsx | 374 +++++ .../lens/public/essql_datasource/essql.tsx | 391 +++++ .../essql_datasource/field_item.test.tsx | 237 +++ .../public/essql_datasource/field_item.tsx | 538 +++++++ .../lens/public/essql_datasource/index.ts | 47 + .../essql_datasource/indexpattern.test.ts | 557 +++++++ .../essql_datasource/layerpanel.test.tsx | 228 +++ .../public/essql_datasource/layerpanel.tsx | 39 + .../essql_datasource/lens_field_icon.test.tsx | 24 + .../essql_datasource/lens_field_icon.tsx | 20 + .../lens/public/essql_datasource/mocks.ts | 131 ++ .../essql_datasource/pure_helpers.test.ts | 15 + .../public/essql_datasource/pure_helpers.ts | 15 + .../essql_datasource/rename_columns.test.ts | 221 +++ .../public/essql_datasource/rename_columns.ts | 101 ++ .../essql_datasource/state_helpers.test.ts | 732 +++++++++ .../public/essql_datasource/state_helpers.ts | 176 ++ .../public/essql_datasource/to_expression.ts | 61 + .../lens/public/essql_datasource/types.ts | 35 + .../lens/public/essql_datasource/utils.ts | 43 + x-pack/plugins/lens/public/plugin.ts | 4 + 42 files changed, 7108 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/lens/public/essql_datasource/_datapanel.scss create mode 100644 x-pack/plugins/lens/public/essql_datasource/_field_item.scss create mode 100644 x-pack/plugins/lens/public/essql_datasource/_index.scss create mode 100644 x-pack/plugins/lens/public/essql_datasource/change_indexpattern.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/datapanel.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/_field_select.scss create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/_index.scss create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/_popover.scss create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/bucket_nesting_editor.test.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/bucket_nesting_editor.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/dimension_panel.test.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/dimension_panel.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/field_select.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/format_selector.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/index.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/dimension_panel/popover_editor.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/essql.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/field_item.test.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/field_item.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/index.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/indexpattern.test.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/layerpanel.test.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/layerpanel.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/lens_field_icon.test.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/lens_field_icon.tsx create mode 100644 x-pack/plugins/lens/public/essql_datasource/mocks.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/pure_helpers.test.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/pure_helpers.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/rename_columns.test.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/rename_columns.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/state_helpers.test.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/state_helpers.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/to_expression.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/types.ts create mode 100644 x-pack/plugins/lens/public/essql_datasource/utils.ts diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/essql.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/essql.ts index 1339c93032ea9..a90b2d99ab68f 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/essql.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/essql.ts @@ -13,6 +13,8 @@ import { searchService } from '../../../public/services'; import { ESSQL_SEARCH_STRATEGY } from '../../../common/lib/constants'; import { EssqlSearchStrategyRequest, EssqlSearchStrategyResponse } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; +import { buildEsQuery, Filter } from '@kbn/es-query'; +import { calculateBounds } from '../../../../../../src/plugins/data/common'; interface Arguments { query: string; @@ -33,7 +35,7 @@ export function essql(): ExpressionFunctionDefinition< name: 'essql', type: 'datatable', context: { - types: ['filter'], + types: ['filter', 'kibana_context'], }, help, args: { @@ -59,6 +61,10 @@ export function essql(): ExpressionFunctionDefinition< default: 'UTC', help: argHelp.timezone, }, + timefield: { + types: ['string'], + help: '', + }, }, fn: (input, args, handlers) => { const search = searchService.getService().search; @@ -66,7 +72,29 @@ export function essql(): ExpressionFunctionDefinition< const req = { ...restOfArgs, params: parameter, - filter: input.and, + filter: + input.type === 'kibana_context' + ? [ + { + filterType: 'direct', + query: { + bool: { + filter: [ + { + range: { + [args.timefield]: { + gte: input.timeRange.from, + lte: input.timeRange.to, + }, + }, + }, + buildEsQuery(undefined, input.query, input.filters, {}) + ], + }, + }, + }, + ] + : input.and, }; return search diff --git a/x-pack/plugins/canvas/common/lib/request/filters.ts b/x-pack/plugins/canvas/common/lib/request/filters.ts index f1465fe48bdcf..8e22909b35b12 100644 --- a/x-pack/plugins/canvas/common/lib/request/filters.ts +++ b/x-pack/plugins/canvas/common/lib/request/filters.ts @@ -72,4 +72,5 @@ export const filters: Record = { exactly, time, luceneQueryString, + direct: (filter: any) => filter.query, }; diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index d2375064603c3..4539362f4b047 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -103,6 +103,42 @@ export class CanvasPlugin })); } + const load = async () => { + setupExpressions({ coreSetup, setupPlugins }); + const { CanvasSrcPlugin } = await import('../canvas_plugin_src/plugin'); + const srcPlugin = new CanvasSrcPlugin(); + + srcPlugin.setup(coreSetup, { canvas: canvasApi }); + + // Get start services + const [coreStart, startPlugins] = await coreSetup.getStartServices(); + + srcPlugin.start(coreStart, startPlugins); + + const { pluginServices } = await import('./services'); + pluginServices.setRegistry( + pluginServiceRegistry.start({ + coreStart, + startPlugins, + appUpdater: this.appUpdater, + initContext: this.initContext, + }) + ); + + // Load application bundle + const { renderApp, initializeCanvas, teardownCanvas } = await import('./application'); + + const canvasStore = await initializeCanvas( + coreSetup, + coreStart, + setupPlugins, + startPlugins, + registries, + this.appUpdater + ); + }; + load(); + coreSetup.application.register({ category: DEFAULT_APP_CATEGORIES.kibana, id: CANVAS_APP, @@ -115,7 +151,6 @@ export class CanvasPlugin const srcPlugin = new CanvasSrcPlugin(); srcPlugin.setup(coreSetup, { canvas: canvasApi }); - setupExpressions({ coreSetup, setupPlugins }); // Get start services const [coreStart, startPlugins] = await coreSetup.getStartServices(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.scss index a7c8e4dfc6baa..15110a78d9eee 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.scss @@ -5,7 +5,4 @@ } .lnsDataPanelWrapper__switchSource { - position: absolute; - right: $euiSize + $euiSizeXS; - top: $euiSize + $euiSizeXS; } diff --git a/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx index 68601170e2486..19d622b3d8aa3 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx @@ -87,7 +87,7 @@ export function EsDSLDataPanel({ {field.name} ({field.type}){' '} f.name === column.fieldName )!; const operation = { - dataType: field.type as DataType, - label: field.name, + dataType: field?.type as DataType, + label: field?.name, isBucketed: false, }; return { diff --git a/x-pack/plugins/lens/public/essql_datasource/_datapanel.scss b/x-pack/plugins/lens/public/essql_datasource/_datapanel.scss new file mode 100644 index 0000000000000..77d4b41a0413c --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/_datapanel.scss @@ -0,0 +1,67 @@ +.lnsInnerIndexPatternDataPanel { + width: 100%; + height: 100%; + padding: $euiSize $euiSize 0; +} + +.lnsInnerIndexPatternDataPanel__header { + display: flex; + align-items: center; + height: $euiSize * 3; + margin-top: -$euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__triggerButton { + @include euiTitle('xs'); + line-height: $euiSizeXXL; +} + +.lnsInnerIndexPatternDataPanel__filterWrapper { + flex-grow: 0; +} + +/** + * 1. Don't cut off the shadow of the field items + */ + +.lnsInnerIndexPatternDataPanel__listWrapper { + @include euiOverflowShadow; + @include euiScrollBar; + margin-left: -$euiSize; /* 1 */ + position: relative; + flex-grow: 1; + overflow: auto; +} + +.lnsInnerIndexPatternDataPanel__list { + padding-top: $euiSizeS; + position: absolute; + top: 0; + left: $euiSize; /* 1 */ + right: $euiSizeXS; /* 1 */ +} + +.lnsInnerIndexPatternDataPanel__filterButton { + width: 100%; + color: $euiColorPrimary; + padding-left: $euiSizeS; + padding-right: $euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__textField { + @include euiFormControlLayoutPadding(1, 'right'); + @include euiFormControlLayoutPadding(1, 'left'); +} + +.lnsInnerIndexPatternDataPanel__filterType { + padding: $euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__filterTypeInner { + display: flex; + align-items: center; + + .lnsFieldListPanel__fieldIcon { + margin-right: $euiSizeS; + } +} diff --git a/x-pack/plugins/lens/public/essql_datasource/_field_item.scss b/x-pack/plugins/lens/public/essql_datasource/_field_item.scss new file mode 100644 index 0000000000000..41919b900c71f --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/_field_item.scss @@ -0,0 +1,87 @@ +.lnsFieldItem { + @include euiFontSizeS; + background: lightOrDarkTheme($euiColorEmptyShade, $euiColorLightestShade); + border-radius: $euiBorderRadius; + margin-bottom: $euiSizeXS; +} + +.lnsFieldItem__popoverAnchor:hover, +.lnsFieldItem__popoverAnchor:focus, +.lnsFieldItem__popoverAnchor:focus-within { + @include euiBottomShadowMedium; + border-radius: $euiBorderRadius; + z-index: 2; +} + +.lnsFieldItem--missing { + background: lightOrDarkTheme(transparentize($euiColorMediumShade, .9), $euiColorEmptyShade); + color: $euiColorDarkShade; +} + +.lnsFieldItem__info { + border-radius: $euiBorderRadius - 1px; + padding: $euiSizeS; + display: flex; + align-items: flex-start; + transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance, + background-color $euiAnimSpeedFast $euiAnimSlightResistance; // sass-lint:disable-line indentation + + .lnsFieldItem__name { + margin-left: $euiSizeS; + flex-grow: 1; + } + + .lnsFieldListPanel__fieldIcon, + .lnsFieldItem__infoIcon { + flex-shrink: 0; + } + + .lnsFieldListPanel__fieldIcon { + margin-top: $euiSizeXS / 2; + margin-right: $euiSizeXS / 2; + } + + .lnsFieldItem__infoIcon { + visibility: hidden; + } + + &:hover, + &:focus { + cursor: grab; + + .lnsFieldItem__infoIcon { + visibility: visible; + } + } +} + +.lnsFieldItem__info-isOpen { + @include euiFocusRing; +} + +.lnsFieldItem__topValue { + margin-bottom: $euiSizeS; + + &:last-of-type { + margin-bottom: 0; + } +} + +.lnsFieldItem__topValueProgress { + background-color: $euiColorLightestShade; + + // sass-lint:disable-block no-vendor-prefixes + &::-webkit-progress-bar { + background-color: $euiColorLightestShade; + } +} + +.lnsFieldItem__fieldPopoverPanel { + min-width: 260px; + max-width: 300px; +} + +.lnsFieldItem__popoverButtonGroup { + // Enforce lowercase for buttons or else some browsers inherit all caps from popover title + text-transform: none; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/_index.scss b/x-pack/plugins/lens/public/essql_datasource/_index.scss new file mode 100644 index 0000000000000..e5d8b408e33e5 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/_index.scss @@ -0,0 +1,4 @@ +@import 'datapanel'; +@import 'field_item'; + +@import 'dimension_panel/index'; diff --git a/x-pack/plugins/lens/public/essql_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/essql_datasource/change_indexpattern.tsx new file mode 100644 index 0000000000000..d5fabb9d7ef80 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/change_indexpattern.tsx @@ -0,0 +1,118 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { EuiPopover, EuiPopoverTitle, EuiSelectable, EuiSelectableProps } from '@elastic/eui'; +import { IndexPatternRef } from './types'; +import { trackUiEvent } from '../lens_ui_telemetry'; +import { ToolbarButton, ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; + +export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & { + label: string; + title?: string; +}; + +export function ChangeIndexPattern({ + indexPatternRefs, + isMissingCurrent, + indexPatternId, + onChangeIndexPattern, + trigger, + selectableProps, +}: { + trigger: ChangeIndexPatternTriggerProps; + indexPatternRefs: IndexPatternRef[]; + isMissingCurrent?: boolean; + onChangeIndexPattern: (newId: string) => void; + indexPatternId?: string; + selectableProps?: EuiSelectableProps; +}) { + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + + // be careful to only add color with a value, otherwise it will fallbacks to "primary" + const colorProp = isMissingCurrent + ? { + color: 'danger' as const, + } + : {}; + + const createTrigger = function () { + const { label, title, ...rest } = trigger; + return ( + setPopoverIsOpen(!isPopoverOpen)} + fullWidth + {...colorProp} + {...rest} + > + {label} + + ); + }; + + return ( + <> + setPopoverIsOpen(false)} + display="block" + panelPaddingSize="s" + ownFocus + > +
+ + {i18n.translate('xpack.lens.indexPattern.changeDataViewTitle', { + defaultMessage: 'Data view', + })} + + + {...selectableProps} + searchable + singleSelection="always" + options={indexPatternRefs.map(({ title, id }) => ({ + key: id, + label: title, + value: id, + checked: id === indexPatternId ? 'on' : undefined, + }))} + onChange={(choices) => { + const choice = choices.find(({ checked }) => checked) as unknown as { + value: string; + }; + trackUiEvent('indexpattern_changed'); + onChangeIndexPattern(choice.value); + setPopoverIsOpen(false); + }} + searchProps={{ + compressed: true, + ...(selectableProps ? selectableProps.searchProps : undefined), + }} + > + {(list, search) => ( + <> + {search} + {list} + + )} + +
+
+ + ); +} diff --git a/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx new file mode 100644 index 0000000000000..406b881422f1e --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx @@ -0,0 +1,342 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniq, indexBy } from 'lodash'; +import React, { useState, useEffect, memo, useCallback } from 'react'; +import { + // @ts-ignore + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiContextMenuPanelProps, + EuiPopover, + EuiPopoverTitle, + EuiPopoverFooter, + EuiCallOut, + EuiFormControlLayout, + EuiSwitch, + EuiFacetButton, + EuiIcon, + EuiSpacer, + EuiFormLabel, + EuiButton, + EuiCodeEditor, + EuiAccordion, + EuiPanel, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { EuiFieldText, EuiSelect } from '@elastic/eui'; +import { ExpressionsStart } from 'src/plugins/expressions/public'; +import { buildExpressionFunction } from '../../../../../src/plugins/expressions/public'; +import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; +import { IndexPattern, EsSQLPrivateState, IndexPatternField, IndexPatternRef } from './types'; +import { esRawResponse } from '../../../../../src/plugins/data/common'; +import { ChangeIndexPattern } from './change_indexpattern'; + +export type Props = DatasourceDataPanelProps & { + data: DataPublicPluginStart; + expressions: ExpressionsStart; +}; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { flatten } from './flatten'; + +export function EsSQLDataPanel({ + setState, + state, + dragDropContext, + core, + data, + query, + filters, + dateRange, + expressions, +}: Props) { + const [localState, setLocalState] = useState(state); + + useEffect(() => { + setLocalState(state); + }, [state]); + + const { layers, removedLayers } = localState; + + return ( + + + + {Object.entries(layers).map(([id, layer]) => { + const ref = state.indexPatternRefs.find((r) => r.id === layer.index); + return ( + + + {localState.cachedFieldList[id]?.fields.length > 0 && + localState.cachedFieldList[id].fields.map((field) => ( +
+ + {field.name} ({field.type}){' '} + + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + overwrittenFieldTypes: { + ...(layer.overwrittenFieldTypes || {}), + [field.name]: e.target.value, + }, + }, + }, + }); + }} + /> +
+ ))} + +
+ { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + timeField: e.target.value, + }, + }, + }); + }} + /> + + + { + setState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + index: newId, + }, + }, + }); + }} + /> + + +
+
+
+
+ ); + })} + {state !== localState && ( + + { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + const ast = { + type: 'expression', + chain: [ + buildExpressionFunction('essql', { + query: layer.query, + }).toAst(), + ], + }; + return expressions.run(ast, null).toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = response.result; + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; + }); + setState({ + ...localState, + cachedFieldList, + }); + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }} + > + Apply changes + + + )} +
+
+ ); +} + +export function EsSQLHorizontalDataPanel({ + setState, + state, + dragDropContext, + core, + data, + query, + filters, + dateRange, + expressions, +}: Props) { + const [localState, setLocalState] = useState(state); + + useEffect(() => { + setLocalState(state); + }, [state]); + + const { layers, removedLayers } = localState; + + return ( + + + + {Object.entries(layers).map(([id, layer]) => { + const ref = state.indexPatternRefs.find((r) => r.id === layer.index); + return ( + + + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + query: val, + }, + }, + }); + }} + /> + + + ); + })} + {Object.entries(removedLayers).map(([id, { layer }]) => ( + + + Currently detached. Add new layers to your visualization to use. + + + + + ))} + {state !== localState && ( + + { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + const ast = { + type: 'expression', + chain: [ + buildExpressionFunction('essql', { + query: layer.query, + }).toAst(), + ], + }; + return expressions.run(ast, null).toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = response.result; + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; + }); + setState({ + ...localState, + cachedFieldList, + }); + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }} + > + Apply changes + + + )} + + + ); +} diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/_field_select.scss b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/_field_select.scss new file mode 100644 index 0000000000000..993174f3e6223 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/_field_select.scss @@ -0,0 +1,7 @@ +.lnFieldSelect__option--incompatible { + color: $euiColorLightShade; +} + +.lnFieldSelect__option--nonExistant { + background-color: $euiColorLightestShade; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/_index.scss b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/_index.scss new file mode 100644 index 0000000000000..085a00a2c33c5 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/_index.scss @@ -0,0 +1,2 @@ +@import 'field_select'; +@import 'popover'; diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/_popover.scss b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/_popover.scss new file mode 100644 index 0000000000000..07a72ee1f66fc --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/_popover.scss @@ -0,0 +1,38 @@ +.lnsIndexPatternDimensionEditor { + flex-grow: 1; + line-height: 0; + overflow: hidden; +} + +.lnsIndexPatternDimensionEditor__left, +.lnsIndexPatternDimensionEditor__right { + padding: $euiSizeS; +} + +.lnsIndexPatternDimensionEditor__left { + padding-top: 0; + background-color: $euiPageBackgroundColor; +} + +.lnsIndexPatternDimensionEditor__right { + width: $euiSize * 20; +} + +.lnsIndexPatternDimensionEditor__operation { + @include euiFontSizeS; + color: $euiColorPrimary; + + // TODO: Fix in EUI or don't use EuiSideNav + .euiSideNavItemButton__label { + color: inherit; + } +} + +.lnsIndexPatternDimensionEditor__operation--selected { + font-weight: bold; + color: $euiTextColor; +} + +.lnsIndexPatternDimensionEditor__operation--incompatible { + color: $euiColorMediumShade; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/bucket_nesting_editor.test.tsx b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/bucket_nesting_editor.test.tsx new file mode 100644 index 0000000000000..c6dbb6f617acf --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/bucket_nesting_editor.test.tsx @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { BucketNestingEditor } from './bucket_nesting_editor'; +import { IndexPatternColumn } from '../indexpattern'; + +describe('BucketNestingEditor', () => { + function mockCol(col: Partial = {}): IndexPatternColumn { + const result = { + dataType: 'string', + isBucketed: true, + label: 'a', + operationType: 'terms', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + sourceField: 'a', + suggestedPriority: 0, + ...col, + }; + + return result as IndexPatternColumn; + } + + it('should display the top level grouping when at the root', () => { + const component = mount( + + ); + const control1 = component.find('[data-test-subj="indexPattern-nesting-topLevel"]').first(); + const control2 = component.find('[data-test-subj="indexPattern-nesting-bottomLevel"]').first(); + + expect(control1.prop('checked')).toBeTruthy(); + expect(control2.prop('checked')).toBeFalsy(); + }); + + it('should display the bottom level grouping when appropriate', () => { + const component = mount( + + ); + + const control1 = component.find('[data-test-subj="indexPattern-nesting-topLevel"]').first(); + const control2 = component.find('[data-test-subj="indexPattern-nesting-bottomLevel"]').first(); + + expect(control1.prop('checked')).toBeFalsy(); + expect(control2.prop('checked')).toBeTruthy(); + }); + + it('should reorder the columns when toggled', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + const control1 = component.find('[data-test-subj="indexPattern-nesting-topLevel"]').first(); + + (control1.prop('onChange') as () => {})(); + + expect(setColumns).toHaveBeenCalledTimes(1); + expect(setColumns).toHaveBeenCalledWith(['a', 'b', 'c']); + + component.setProps({ + layer: { + columnOrder: ['a', 'b', 'c'], + columns: { + a: mockCol({ suggestedPriority: 0 }), + b: mockCol({ suggestedPriority: 1 }), + c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: false }), + }, + indexPatternId: 'foo', + }, + }); + + const control2 = component.find('[data-test-subj="indexPattern-nesting-bottomLevel"]').first(); + + (control2.prop('onChange') as () => {})(); + + expect(setColumns).toHaveBeenCalledTimes(2); + expect(setColumns).toHaveBeenLastCalledWith(['b', 'a', 'c']); + }); + + it('should display nothing if there are no buckets', () => { + const component = mount( + + ); + + expect(component.children().length).toBe(0); + }); + + it('should display nothing if there is one bucket', () => { + const component = mount( + + ); + + expect(component.children().length).toBe(0); + }); + + it('should display a dropdown with the parent column selected if 3+ buckets', () => { + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + + expect(control.prop('value')).toEqual('c'); + }); + + it('should reorder the columns when a column is selected in the dropdown', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: 'b' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['c', 'b', 'a']); + }); + + it('should move to root if the first dropdown item is selected', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: '' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['a', 'c', 'b']); + }); + + it('should allow the last bucket to be moved', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: '' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['b', 'c', 'a']); + }); +}); diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/bucket_nesting_editor.tsx new file mode 100644 index 0000000000000..fc2e6511dc82c --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/bucket_nesting_editor.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiHorizontalRule, EuiRadio, EuiSelect, htmlIdGenerator } from '@elastic/eui'; +import { EsSQLLayer } from '../types'; +import { hasField } from '../utils'; + +const generator = htmlIdGenerator('lens-nesting'); + +function nestColumn(columnOrder: string[], outer: string, inner: string) { + const result = columnOrder.filter(c => c !== inner); + const outerPosition = result.indexOf(outer); + + result.splice(outerPosition + 1, 0, inner); + + return result; +} + +export function BucketNestingEditor({ + columnId, + layer, + setColumns, +}: { + columnId: string; + layer: EsSQLLayer; + setColumns: (columns: string[]) => void; +}) { + const column = layer.columns[columnId]; + const columns = Object.entries(layer.columns); + const aggColumns = columns + .filter(([id, c]) => id !== columnId && c.isBucketed) + .map(([value, c]) => ({ + value, + text: c.label, + fieldName: hasField(c) ? c.sourceField : '', + })); + + if (!column || !column.isBucketed || !aggColumns.length) { + return null; + } + + const fieldName = hasField(column) ? column.sourceField : ''; + + const prevColumn = layer.columnOrder[layer.columnOrder.indexOf(columnId) - 1]; + + if (aggColumns.length === 1) { + const [target] = aggColumns; + + function toggleNesting() { + if (prevColumn) { + setColumns(nestColumn(layer.columnOrder, columnId, target.value)); + } else { + setColumns(nestColumn(layer.columnOrder, target.value, columnId)); + } + } + + return ( + <> + + + <> + + + + + + ); + } + + return ( + <> + + + ({ value, text })), + ]} + value={prevColumn} + onChange={e => setColumns(nestColumn(layer.columnOrder, e.target.value, columnId))} + /> + + + ); +} diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/dimension_panel.test.tsx new file mode 100644 index 0000000000000..514f3093341b8 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/dimension_panel.test.tsx @@ -0,0 +1,1422 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper, ShallowWrapper } from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { EuiComboBox, EuiSideNav, EuiSideNavItemType, EuiFieldNumber } from '@elastic/eui'; +import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { changeColumn } from '../state_helpers'; +import { + IndexPatternDimensionEditorComponent, + IndexPatternDimensionEditorProps, + onDrop, + canHandleDrop, +} from './dimension_panel'; +import { DragContextState } from '../../drag_drop'; +import { createMockedDragDropContext } from '../mocks'; +import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { EsSQLPrivateState } from '../types'; +import { documentField } from '../document_field'; +import { OperationMetadata } from '../../types'; + +jest.mock('../loader'); +jest.mock('../state_helpers'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + hasExistence: true, + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + documentField, + ], + }, +}; + +describe('IndexPatternDimensionEditorPanel', () => { + let state: EsSQLPrivateState; + let setState: jest.Mock; + let defaultProps: IndexPatternDimensionEditorProps; + let dragDropContext: DragContextState; + + beforeEach(() => { + state = { + indexPatternRefs: [], + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: '1', + showEmptyFields: false, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + + setState = jest.fn(); + + dragDropContext = createMockedDragDropContext(); + + defaultProps = { + state, + setState, + dateRange: { fromDate: 'now-1d', toDate: 'now' }, + columnId: 'col1', + layerId: 'first', + uniqueLabel: 'stuff', + filterOperations: () => true, + storage: {} as IStorageWrapper, + uiSettings: {} as IUiSettingsClient, + savedObjectsClient: {} as SavedObjectsClientContract, + http: {} as HttpSetup, + data: ({ + fieldFormats: ({ + getType: jest.fn().mockReturnValue({ + id: 'number', + title: 'Number', + }), + getDefaultType: jest.fn().mockReturnValue({ + id: 'bytes', + title: 'Bytes', + }), + } as unknown) as DataPublicPluginStart['fieldFormats'], + } as unknown) as DataPublicPluginStart, + core: {} as CoreSetup, + }; + + jest.clearAllMocks(); + }); + + describe('Editor component', () => { + let wrapper: ReactWrapper | ShallowWrapper; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + it('should call the filterOperations function', () => { + const filterOperations = jest.fn().mockReturnValue(true); + + wrapper = shallow( + + ); + + expect(filterOperations).toBeCalled(); + }); + + it('should show field select combo box on click', () => { + wrapper = mount(); + + expect( + wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]') + ).toHaveLength(1); + }); + + it('should not show any choices if the filter returns false', () => { + wrapper = mount( + false} + /> + ); + + expect( + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')! + .prop('options')! + ).toHaveLength(0); + }); + + it('should list all field names and document as a whole in prioritized order', () => { + wrapper = mount(); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options).toHaveLength(2); + + expect(options![0].label).toEqual('Records'); + + expect(options![1].options!.map(({ label }) => label)).toEqual([ + 'timestamp', + 'bytes', + 'memory', + 'source', + ]); + }); + + it('should hide fields that have no data', () => { + const props = { + ...defaultProps, + state: { + ...defaultProps.state, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + source: true, + }, + }, + }, + }; + wrapper = mount(); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![1].options!.map(({ label }) => label)).toEqual(['timestamp', 'source']); + }); + + it('should indicate fields which are incompatible for the operation of the current column', () => { + wrapper = mount( + + ); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-Records'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate operations which are incompatible for the field of the current column', () => { + wrapper = mount( + + ); + + interface ItemType { + name: string; + 'data-test-subj': string; + } + const items: Array> = wrapper.find(EuiSideNav).prop('items'); + const options = (items[0].items as unknown) as ItemType[]; + + expect(options.find(({ name }) => name === 'Minimum')!['data-test-subj']).not.toContain( + 'Incompatible' + ); + + expect(options.find(({ name }) => name === 'Date histogram')!['data-test-subj']).toContain( + 'Incompatible' + ); + }); + + it('should keep the operation when switching to another field compatible with this operation', () => { + const initialState: EsSQLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + params: { format: { id: 'bytes' } }, + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'max', + sourceField: 'memory', + params: { format: { id: 'bytes' } }, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should switch operations when selecting a field that requires another operation', () => { + wrapper = mount(); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should keep the field when switching to another operation compatible for this field', () => { + wrapper = mount( + + ); + + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'min', + sourceField: 'bytes', + params: { format: { id: 'bytes' } }, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should not set the state if selecting the currently active operation', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should update label on label input changes', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('input[data-test-subj="indexPattern-label-edit"]') + .simulate('change', { target: { value: 'New Label' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + label: 'New Label', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + describe('transient invalid state', () => { + it('should not set the state if selecting an operation incompatible with the current field', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should show error message in invalid state', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).not.toHaveLength( + 0 + ); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should leave error state if a compatible operation is selected', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); + }); + + it('should indicate fields compatible with selected operation', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should select compatible operation if field not compatible with selected operation', () => { + wrapper = mount( + + ); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]'); + const options = comboBox.prop('options'); + + // options[1][2] is a `source` field of type `string` which doesn't support `avg` operation + act(() => { + comboBox.prop('onChange')!([options![1].options![2]]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'source', + operationType: 'terms', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select the Records field when count is selected', () => { + const initialState: EsSQLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'bytes', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-count"]') + .simulate('click'); + + const newColumnState = setState.mock.calls[0][0].layers.first.columns.col2; + expect(newColumnState.operationType).toEqual('count'); + expect(newColumnState.sourceField).toEqual('Records'); + }); + + it('should indicate document and field compatibility with selected document operation', () => { + const initialState: EsSQLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'count', + sourceField: 'Records', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should set datasource state if compatible field is selected for operation', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + }); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox + .prop('options')![1] + .options!.find(({ label }) => label === 'source')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + sourceField: 'source', + operationType: 'terms', + }), + }, + }, + }, + }); + }); + }); + + it('should support selecting the operation before the field', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]'); + const options = comboBox.prop('options'); + + act(() => { + comboBox.prop('onChange')!([options![1].options![0]]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select operation directly if only one field is possible', () => { + const initialState = { + ...state, + indexPatterns: { + 1: { + ...state.indexPatterns['1'], + fields: state.indexPatterns['1'].fields.filter(field => field.name !== 'memory'), + }, + }, + }; + + wrapper = mount( + + ); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...initialState.layers.first, + columns: { + ...initialState.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select operation directly if only document is possible', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-count"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + operationType: 'count', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should indicate compatible fields when selecting the operation first', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'bytes')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate document compatibility when document operation is selected', () => { + const initialState: EsSQLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'count', + sourceField: 'Records', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).not.toContain('Incompatible'); + + options![1].options!.map(operation => + expect(operation['data-test-subj']).toContain('Incompatible') + ); + }); + + it('should show all operations that are not filtered out', () => { + wrapper = mount( + !op.isBucketed && op.dataType === 'number'} + /> + ); + + interface ItemType { + name: React.ReactNode; + } + const items: Array> = wrapper.find(EuiSideNav).prop('items'); + const options = (items[0].items as unknown) as ItemType[]; + + expect(options.map(({ name }: { name: React.ReactNode }) => name)).toEqual([ + 'Unique count', + 'Average', + 'Count', + 'Maximum', + 'Minimum', + 'Sum', + ]); + }); + + it('should add a column on selection of a field', () => { + wrapper = mount(); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options![0]; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should use helper function when changing the function', () => { + const initialState: EsSQLPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + act(() => { + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); + }); + + expect(changeColumn).toHaveBeenCalledWith({ + state: initialState, + columnId: 'col1', + layerId: 'first', + newColumn: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'min', + }), + }); + }); + + it('should clear the dimension when removing the selection in field combobox', () => { + wrapper = mount(); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('onChange')!([]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + it('allows custom format', () => { + const stateWithNumberCol: EsSQLPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: 'bytes', label: 'Bytes' }]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }), + }, + }, + }, + }); + }); + + it('keeps decimal places while switching', () => { + const stateWithNumberCol: EsSQLPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + params: { + format: { id: 'bytes', params: { decimals: 0 } }, + }, + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: '', label: 'Default' }]); + }); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: 'number', label: 'Number' }]); + }); + + expect( + wrapper + .find(EuiFieldNumber) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('value') + ).toEqual(0); + }); + + it('allows custom format with number of decimal places', () => { + const stateWithNumberCol: EsSQLPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiFieldNumber) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('onChange')!({ target: { value: '0' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + params: { + format: { id: 'bytes', params: { decimals: 0 } }, + }, + }), + }, + }, + }, + }); + }); + }); + + describe('Drag and drop', () => { + function dragDropState(): EsSQLPrivateState { + return { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: { + foo: { + id: 'foo', + title: 'Foo pattern', + fields: [ + { + aggregatable: true, + name: 'bar', + searchable: true, + type: 'number', + }, + { + aggregatable: true, + name: 'mystring', + searchable: true, + type: 'string', + }, + ], + }, + }, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + myLayer: { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + } + + it('is not droppable if no drag is happening', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext, + state: dragDropState(), + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is not droppable if the dragged item has no field', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { name: 'bar' }, + }, + }) + ).toBe(false); + }); + + it('is not droppable if field is not supported by filterOperations', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + indexPatternId: 'foo', + field: { type: 'string', name: 'mystring', aggregatable: true }, + }, + }, + state: dragDropState(), + filterOperations: () => false, + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is droppable if the field is supported by filterOperations', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }, + }, + state: dragDropState(), + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(true); + }); + + it('is not droppable if the field belongs to another index pattern', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo2', + }, + }, + state: dragDropState(), + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('appends the dropped column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }, + }, + }, + }); + }); + + it('selects the specific operation that was valid on drop', () => { + const dragging = { + field: { type: 'string', name: 'mystring', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + dataType: 'string', + sourceField: 'mystring', + }), + }, + }, + }, + }); + }); + + it('updates a column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }), + }), + }, + }); + }); + + it('does not set the size of the terms aggregation', () => { + const dragging = { + field: { type: 'string', name: 'mystring', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + operationType: 'terms', + params: expect.objectContaining({ size: 3 }), + }), + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/dimension_panel.tsx new file mode 100644 index 0000000000000..2a7445b8d273c --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/dimension_panel.tsx @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiLink } from '@elastic/eui'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { + DatasourceDimensionTriggerProps, + DatasourceDimensionEditorProps, + DatasourceDimensionDropProps, + DatasourceDimensionDropHandlerProps, +} from '../../types'; +import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { IndexPatternColumn, OperationType } from '../indexpattern'; +import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations'; +import { PopoverEditor } from './popover_editor'; +import { changeColumn } from '../state_helpers'; +import { isDraggedField, hasField } from '../utils'; +import { EsSQLPrivateState, IndexPatternField } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { DateRange } from '../../../common'; + +export type IndexPatternDimensionTriggerProps = DatasourceDimensionTriggerProps< + EsSQLPrivateState +> & { + uniqueLabel: string; +}; + +export type IndexPatternDimensionEditorProps = DatasourceDimensionEditorProps< + EsSQLPrivateState +> & { + uiSettings: IUiSettingsClient; + storage: IStorageWrapper; + savedObjectsClient: SavedObjectsClientContract; + layerId: string; + http: HttpSetup; + data: DataPublicPluginStart; + uniqueLabel: string; + dateRange: DateRange; +}; + +export interface OperationFieldSupportMatrix { + operationByField: Partial>; + fieldByOperation: Partial>; +} + +type Props = Pick< + DatasourceDimensionDropProps, + 'layerId' | 'columnId' | 'state' | 'filterOperations' +>; + +// TODO: This code has historically been memoized, as a potentially performance +// sensitive task. If we can add memoization without breaking the behavior, we should. +const getOperationFieldSupportMatrix = (props: Props): OperationFieldSupportMatrix => { + const layerId = props.layerId; + const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId]; + + const filteredOperationsByMetadata = getAvailableOperationsByMetadata( + currentIndexPattern + ).filter(operation => props.filterOperations(operation.operationMetaData)); + + const supportedOperationsByField: Partial> = {}; + const supportedFieldsByOperation: Partial> = {}; + + filteredOperationsByMetadata.forEach(({ operations }) => { + operations.forEach(operation => { + if (supportedOperationsByField[operation.field]) { + supportedOperationsByField[operation.field]!.push(operation.operationType); + } else { + supportedOperationsByField[operation.field] = [operation.operationType]; + } + + if (supportedFieldsByOperation[operation.operationType]) { + supportedFieldsByOperation[operation.operationType]!.push(operation.field); + } else { + supportedFieldsByOperation[operation.operationType] = [operation.field]; + } + }); + }); + return { + operationByField: _.mapValues(supportedOperationsByField, _.uniq), + fieldByOperation: _.mapValues(supportedFieldsByOperation, _.uniq), + }; +}; + +export function canHandleDrop(props: DatasourceDimensionDropProps) { + const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); + + const { dragging } = props.dragDropContext; + const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; + + function hasOperationForField(field: IndexPatternField) { + return Boolean(operationFieldSupportMatrix.operationByField[field.name]); + } + + return ( + isDraggedField(dragging) && + layerIndexPatternId === dragging.indexPatternId && + Boolean(hasOperationForField(dragging.field)) + ); +} + +export function onDrop( + props: DatasourceDimensionDropHandlerProps +): boolean { + const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); + const droppedItem = props.droppedItem; + + function hasOperationForField(field: IndexPatternField) { + return Boolean(operationFieldSupportMatrix.operationByField[field.name]); + } + + if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { + // TODO: What do we do if we couldn't find a column? + return false; + } + + const operationsForNewField = + operationFieldSupportMatrix.operationByField[droppedItem.field.name]; + + const layerId = props.layerId; + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + const currentIndexPattern = + props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId]; + + // We need to check if dragging in a new field, was just a field change on the same + // index pattern and on the same operations (therefore checking if the new field supports + // our previous operation) + const hasFieldChanged = + selectedColumn && + hasField(selectedColumn) && + selectedColumn.sourceField !== droppedItem.field.name && + operationsForNewField && + operationsForNewField.includes(selectedColumn.operationType); + + if (!operationsForNewField || operationsForNewField.length === 0) { + return false; + } + + // If only the field has changed use the onFieldChange method on the operation to get the + // new column, otherwise use the regular buildColumn to get a new column. + const newColumn = hasFieldChanged + ? changeField(selectedColumn, currentIndexPattern, droppedItem.field) + : buildColumn({ + op: operationsForNewField[0], + columns: props.state.layers[props.layerId].columns, + indexPattern: currentIndexPattern, + layerId, + suggestedPriority: props.suggestedPriority, + field: droppedItem.field, + previousColumn: selectedColumn, + }); + + trackUiEvent('drop_onto_dimension'); + const hasData = Object.values(props.state.layers).some(({ columns }) => columns.length); + trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); + + props.setState( + changeColumn({ + state: props.state, + layerId, + columnId: props.columnId, + newColumn, + // If the field has changed, the onFieldChange method needs to take care of everything including moving + // over params. If we create a new column above we want changeColumn to move over params. + keepParams: !hasFieldChanged, + }) + ); + + return true; +} + +export const IndexPatternDimensionTriggerComponent = function IndexPatternDimensionTrigger( + props: IndexPatternDimensionTriggerProps +) { + const layerId = props.layerId; + + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + + const { columnId, uniqueLabel } = props; + if (!selectedColumn) { + return null; + } + return ( + { + props.togglePopover(); + }} + data-test-subj="lns-dimensionTrigger" + aria-label={i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit configuration', + })} + title={i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit configuration', + })} + > + {uniqueLabel} + + ); +}; + +export const IndexPatternDimensionEditorComponent = function IndexPatternDimensionPanel( + props: IndexPatternDimensionEditorProps +) { + const layerId = props.layerId; + const currentIndexPattern = + props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId]; + const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); + + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + + return ( + + ); +}; + +export const IndexPatternDimensionTrigger = memo(IndexPatternDimensionTriggerComponent); +export const IndexPatternDimensionEditor = memo(IndexPatternDimensionEditorComponent); diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/field_select.tsx new file mode 100644 index 0000000000000..72d306a4a126e --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/field_select.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiComboBoxOptionOption } from '@elastic/eui'; +import classNames from 'classnames'; +import { EuiHighlight } from '@elastic/eui'; +import { OperationType } from '../indexpattern'; +import { LensFieldIcon } from '../lens_field_icon'; +import { DataType } from '../../types'; +import { OperationFieldSupportMatrix } from './dimension_panel'; +import { IndexPattern, IndexPatternField, EsSQLPrivateState } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { fieldExists } from '../pure_helpers'; + +export interface FieldChoice { + type: 'field'; + field: string; + operationType?: OperationType; +} + +export interface FieldSelectProps { + currentIndexPattern: IndexPattern; + showEmptyFields: boolean; + fieldMap: Record; + incompatibleSelectedOperationType: OperationType | null; + selectedColumnOperationType?: OperationType; + selectedColumnSourceField?: string; + operationFieldSupportMatrix: OperationFieldSupportMatrix; + onChoose: (choice: FieldChoice) => void; + onDeleteColumn: () => void; + existingFields: EsSQLPrivateState['existingFields']; +} + +export function FieldSelect({ + currentIndexPattern, + showEmptyFields, + fieldMap, + incompatibleSelectedOperationType, + selectedColumnOperationType, + selectedColumnSourceField, + operationFieldSupportMatrix, + onChoose, + onDeleteColumn, + existingFields, +}: FieldSelectProps) { + const { operationByField } = operationFieldSupportMatrix; + const memoizedFieldOptions = useMemo(() => { + const fields = Object.keys(operationByField).sort(); + + function isCompatibleWithCurrentOperation(fieldName: string) { + if (incompatibleSelectedOperationType) { + return operationByField[fieldName]!.includes(incompatibleSelectedOperationType); + } + return ( + !selectedColumnOperationType || + operationByField[fieldName]!.includes(selectedColumnOperationType) + ); + } + + const [specialFields, normalFields] = _.partition( + fields, + field => fieldMap[field].type === 'document' + ); + + function fieldNamesToOptions(items: string[]) { + return items + .map(field => ({ + label: field, + value: { + type: 'field', + field, + dataType: fieldMap[field].type, + operationType: + selectedColumnOperationType && isCompatibleWithCurrentOperation(field) + ? selectedColumnOperationType + : undefined, + }, + exists: + fieldMap[field].type === 'document' || + fieldExists(existingFields, currentIndexPattern.title, field), + compatible: isCompatibleWithCurrentOperation(field), + })) + .filter(field => showEmptyFields || field.exists) + .sort((a, b) => { + if (a.compatible && !b.compatible) { + return -1; + } + if (!a.compatible && b.compatible) { + return 1; + } + return 0; + }) + .map(({ label, value, compatible, exists }) => ({ + label, + value, + className: classNames({ + 'lnFieldSelect__option--incompatible': !compatible, + 'lnFieldSelect__option--nonExistant': !exists, + }), + 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${label}`, + })); + } + + const fieldOptions: unknown[] = fieldNamesToOptions(specialFields); + + if (fields.length > 0) { + fieldOptions.push({ + label: i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', { + defaultMessage: 'Individual fields', + }), + options: fieldNamesToOptions(normalFields), + }); + } + + return fieldOptions; + }, [ + incompatibleSelectedOperationType, + selectedColumnOperationType, + selectedColumnSourceField, + operationFieldSupportMatrix, + currentIndexPattern, + fieldMap, + showEmptyFields, + ]); + + return ( + { + if (choices.length === 0) { + onDeleteColumn(); + return; + } + + trackUiEvent('indexpattern_dimension_field_changed'); + + onChoose((choices[0].value as unknown) as FieldChoice); + }} + renderOption={(option, searchValue) => { + return ( + + + + + + {option.label} + + + ); + }} + /> + ); +} diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/format_selector.tsx b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/format_selector.tsx new file mode 100644 index 0000000000000..ed68a93c51ca2 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/format_selector.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiFieldNumber, EuiComboBox } from '@elastic/eui'; +import { IndexPatternColumn } from '../indexpattern'; + +const supportedFormats: Record = { + number: { + title: i18n.translate('xpack.lens.indexPattern.numberFormatLabel', { + defaultMessage: 'Number', + }), + }, + percent: { + title: i18n.translate('xpack.lens.indexPattern.percentFormatLabel', { + defaultMessage: 'Percent', + }), + }, + bytes: { + title: i18n.translate('xpack.lens.indexPattern.bytesFormatLabel', { + defaultMessage: 'Bytes (1024)', + }), + }, +}; + +interface FormatSelectorProps { + selectedColumn: IndexPatternColumn; + onChange: (newFormat?: { id: string; params?: Record }) => void; +} + +interface State { + decimalPlaces: number; +} + +export function FormatSelector(props: FormatSelectorProps) { + const { selectedColumn, onChange } = props; + + const currentFormat = + 'params' in selectedColumn && selectedColumn.params && 'format' in selectedColumn.params + ? selectedColumn.params.format + : undefined; + const [state, setState] = useState({ + decimalPlaces: + typeof currentFormat?.params?.decimals === 'number' ? currentFormat.params.decimals : 2, + }); + + const selectedFormat = currentFormat?.id ? supportedFormats[currentFormat.id] : undefined; + + const defaultOption = { + value: '', + label: i18n.translate('xpack.lens.indexPattern.defaultFormatLabel', { + defaultMessage: 'Default', + }), + }; + + return ( + <> + + ({ + value: id, + label: format.title ?? id, + })), + ]} + selectedOptions={ + currentFormat + ? [ + { + value: currentFormat.id, + label: selectedFormat?.title ?? currentFormat.id, + }, + ] + : [defaultOption] + } + onChange={choices => { + if (choices.length === 0) { + return; + } + + if (!choices[0].value) { + onChange(); + return; + } + onChange({ + id: choices[0].value, + params: { decimals: state.decimalPlaces }, + }); + }} + /> + + + {currentFormat ? ( + + { + setState({ decimalPlaces: Number(e.target.value) }); + onChange({ + id: (selectedColumn.params as { format: { id: string } }).format.id, + params: { + decimals: Number(e.target.value), + }, + }); + }} + compressed + fullWidth + /> + + ) : null} + + ); +} diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/index.ts b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/index.ts new file mode 100644 index 0000000000000..88e5588ce0e01 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dimension_panel'; diff --git a/x-pack/plugins/lens/public/essql_datasource/dimension_panel/popover_editor.tsx b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/popover_editor.tsx new file mode 100644 index 0000000000000..e26c338b6e240 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/dimension_panel/popover_editor.tsx @@ -0,0 +1,374 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiSideNav, + EuiCallOut, + EuiFormRow, + EuiFieldText, + EuiSpacer, +} from '@elastic/eui'; +import classNames from 'classnames'; +import { IndexPatternColumn, OperationType } from '../indexpattern'; +import { IndexPatternDimensionEditorProps, OperationFieldSupportMatrix } from './dimension_panel'; +import { + operationDefinitionMap, + getOperationDisplay, + buildColumn, + changeField, +} from '../operations'; +import { deleteColumn, changeColumn, updateColumnParam } from '../state_helpers'; +import { FieldSelect } from './field_select'; +import { hasField } from '../utils'; +import { BucketNestingEditor } from './bucket_nesting_editor'; +import { IndexPattern, IndexPatternField } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { FormatSelector } from './format_selector'; + +const operationPanels = getOperationDisplay(); + +export interface PopoverEditorProps extends IndexPatternDimensionEditorProps { + selectedColumn?: IndexPatternColumn; + operationFieldSupportMatrix: OperationFieldSupportMatrix; + currentIndexPattern: IndexPattern; +} + +function asOperationOptions(operationTypes: OperationType[], compatibleWithCurrentField: boolean) { + return [...operationTypes] + .sort((opType1, opType2) => { + return operationPanels[opType1].displayName.localeCompare( + operationPanels[opType2].displayName + ); + }) + .map(operationType => ({ + operationType, + compatibleWithCurrentField, + })); +} + +export function PopoverEditor(props: PopoverEditorProps) { + const { + selectedColumn, + operationFieldSupportMatrix, + state, + columnId, + setState, + layerId, + currentIndexPattern, + hideGrouping, + } = props; + const { operationByField, fieldByOperation } = operationFieldSupportMatrix; + const [ + incompatibleSelectedOperationType, + setInvalidOperationType, + ] = useState(null); + + const ParamEditor = + selectedColumn && operationDefinitionMap[selectedColumn.operationType].paramEditor; + + const fieldMap: Record = useMemo(() => { + const fields: Record = {}; + currentIndexPattern.fields.forEach(field => { + fields[field.name] = field; + }); + return fields; + }, [currentIndexPattern]); + + function getOperationTypes() { + const possibleOperationTypes = Object.keys(fieldByOperation) as OperationType[]; + const validOperationTypes: OperationType[] = []; + + if (!selectedColumn) { + validOperationTypes.push(...(Object.keys(fieldByOperation) as OperationType[])); + } else if (hasField(selectedColumn) && operationByField[selectedColumn.sourceField]) { + validOperationTypes.push(...operationByField[selectedColumn.sourceField]!); + } + + return _.uniq( + [ + ...asOperationOptions(validOperationTypes, true), + ...asOperationOptions(possibleOperationTypes, false), + ], + 'operationType' + ); + } + + function getSideNavItems() { + return [ + { + name: '', + id: '0', + items: getOperationTypes().map(({ operationType, compatibleWithCurrentField }) => ({ + name: operationPanels[operationType].displayName, + id: operationType as string, + className: classNames('lnsIndexPatternDimensionEditor__operation', { + 'lnsIndexPatternDimensionEditor__operation--selected': Boolean( + incompatibleSelectedOperationType === operationType || + (!incompatibleSelectedOperationType && + selectedColumn && + selectedColumn.operationType === operationType) + ), + 'lnsIndexPatternDimensionEditor__operation--incompatible': !compatibleWithCurrentField, + }), + 'data-test-subj': `lns-indexPatternDimension${ + compatibleWithCurrentField ? '' : 'Incompatible' + }-${operationType}`, + onClick() { + if (!selectedColumn || !compatibleWithCurrentField) { + const possibleFields = fieldByOperation[operationType] || []; + + if (possibleFields.length === 1) { + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: buildColumn({ + columns: props.state.layers[props.layerId].columns, + suggestedPriority: props.suggestedPriority, + layerId: props.layerId, + op: operationType, + indexPattern: currentIndexPattern, + field: fieldMap[possibleFields[0]], + previousColumn: selectedColumn, + }), + }) + ); + } else { + setInvalidOperationType(operationType); + } + trackUiEvent(`indexpattern_dimension_operation_${operationType}`); + return; + } + if (incompatibleSelectedOperationType) { + setInvalidOperationType(null); + } + if (selectedColumn.operationType === operationType) { + return; + } + const newColumn: IndexPatternColumn = buildColumn({ + columns: props.state.layers[props.layerId].columns, + suggestedPriority: props.suggestedPriority, + layerId: props.layerId, + op: operationType, + indexPattern: currentIndexPattern, + field: fieldMap[selectedColumn.sourceField], + previousColumn: selectedColumn, + }); + + trackUiEvent( + `indexpattern_dimension_operation_from_${selectedColumn.operationType}_to_${operationType}` + ); + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn, + }) + ); + }, + })), + }, + ]; + } + + return ( +
+ + + { + setState( + deleteColumn({ + state, + layerId, + columnId, + }) + ); + }} + onChoose={choice => { + let column: IndexPatternColumn; + if ( + !incompatibleSelectedOperationType && + selectedColumn && + 'field' in choice && + choice.operationType === selectedColumn.operationType + ) { + // If we just changed the field are not in an error state and the operation didn't change, + // we use the operations onFieldChange method to calculate the new column. + column = changeField(selectedColumn, currentIndexPattern, fieldMap[choice.field]); + } else { + // Otherwise we'll use the buildColumn method to calculate a new column + const compatibleOperations = + ('field' in choice && + operationFieldSupportMatrix.operationByField[choice.field]) || + []; + let operation; + if (compatibleOperations.length > 0) { + operation = + incompatibleSelectedOperationType && + compatibleOperations.includes(incompatibleSelectedOperationType) + ? incompatibleSelectedOperationType + : compatibleOperations[0]; + } else if ('field' in choice) { + operation = choice.operationType; + } + column = buildColumn({ + columns: props.state.layers[props.layerId].columns, + field: fieldMap[choice.field], + indexPattern: currentIndexPattern, + layerId: props.layerId, + suggestedPriority: props.suggestedPriority, + op: operation as OperationType, + previousColumn: selectedColumn, + }); + } + + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: column, + keepParams: false, + }) + ); + setInvalidOperationType(null); + }} + /> + + + + + + + + {incompatibleSelectedOperationType && selectedColumn && ( + + )} + {incompatibleSelectedOperationType && !selectedColumn && ( + + )} + {!incompatibleSelectedOperationType && ParamEditor && ( + <> + + + + )} + {!incompatibleSelectedOperationType && selectedColumn && ( + + { + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: { + ...selectedColumn, + label: e.target.value, + }, + }) + ); + }} + /> + + )} + + {!hideGrouping && ( + { + setState({ + ...state, + layers: { + ...state.layers, + [props.layerId]: { + ...state.layers[props.layerId], + columnOrder, + }, + }, + }); + }} + /> + )} + + {selectedColumn && selectedColumn.dataType === 'number' ? ( + { + setState( + updateColumnParam({ + state, + layerId, + currentColumn: selectedColumn, + paramName: 'format', + value: newFormat, + }) + ); + }} + /> + ) : null} + + + + +
+ ); +} diff --git a/x-pack/plugins/lens/public/essql_datasource/essql.tsx b/x-pack/plugins/lens/public/essql_datasource/essql.tsx new file mode 100644 index 0000000000000..8cd9a6e96ba37 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/essql.tsx @@ -0,0 +1,391 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import _ from 'lodash'; +import React from 'react'; +import { render } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { EuiButton, EuiSelect } from '@elastic/eui'; +import { + DatasourceDimensionEditorProps, + DatasourceDimensionTriggerProps, + DatasourceDataPanelProps, + Operation, + DatasourceLayerPanelProps, + PublicAPIProps, + DataType, +} from '../types'; +import { toExpression } from './to_expression'; +import { EsSQLDataPanel, EsSQLHorizontalDataPanel } from './datapanel'; + +import type { DataViewsService } from '../../../../../src/plugins/data_views/common'; +import { EsSQLLayer, EsSQLPrivateState, EsSQLPersistedState } from './types'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { Datasource } from '../types'; +import { esRawResponse } from '../../../../../src/plugins/data/common'; +import { ExpressionsStart } from 'src/plugins/expressions/public'; + +async function loadIndexPatternRefs( + indexPatternsService: DataViewsService +): Promise { + const indexPatterns = await indexPatternsService.getIdsWithTitle(); + + const timefields = await Promise.all( + indexPatterns.map((p) => indexPatternsService.get(p.id).then((pat) => pat.timeFieldName)) + ); + + return indexPatterns + .map((p, i) => ({ ...p, timeField: timefields[i] })) + .sort((a, b) => { + return a.title.localeCompare(b.title); + }); +} + +export function getEsSQLDatasource({ + core, + storage, + data, + expressions, +}: { + core: CoreStart; + storage: IStorageWrapper; + data: DataPublicPluginStart; + expressions: ExpressionsStart; +}) { + // Not stateful. State is persisted to the frame + const essqlDatasource: Datasource = { + id: 'essql', + + checkIntegrity: () => { + return []; + }, + getErrorMessages: () => { + return []; + }, + async initialize(state?: EsSQLPersistedState) { + const initState = state || { layers: {} }; + const indexPatternRefs: IndexPatternRef[] = await loadIndexPatternRefs(data.dataViews); + const responses = await Promise.all( + Object.entries(initState.layers).map(([id, layer]) => { + return data.search + .search({ + params: { + size: 0, + index: layer.index, + body: JSON.parse(layer.query), + }, + }) + .toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(initState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = esRawResponse.to!.datatable({ body: response.rawResponse }); + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; + }); + return { + ...initState, + cachedFieldList, + removedLayers: [], + indexPatternRefs, + }; + }, + + getPersistableState({ layers }: EsSQLPrivateState) { + return { state: { layers }, savedObjectReferences: [] }; + }, + isValidColumn() { + return true; + }, + insertLayer(state: EsSQLPrivateState, newLayerId: string) { + const removedLayer = state.removedLayers[0]; + const newRemovedList = removedLayer ? state.removedLayers.slice(1) : state.removedLayers; + return { + ...state, + cachedFieldList: { + ...state.cachedFieldList, + [newLayerId]: removedLayer + ? removedLayer.fieldList + : { + fields: [], + singleRow: false, + }, + }, + layers: { + ...state.layers, + [newLayerId]: removedLayer + ? removedLayer.layer + : blankLayer(state.indexPatternRefs[0].id), + }, + removedLayers: newRemovedList, + }; + }, + + removeLayer(state: EsSQLPrivateState, layerId: string) { + const deletedLayer = state.layers[layerId]; + const newLayers = { ...state.layers }; + delete newLayers[layerId]; + + const deletedFieldList = state.cachedFieldList[layerId]; + const newFieldList = { ...state.cachedFieldList }; + delete newFieldList[layerId]; + + return { + ...state, + layers: newLayers, + cachedFieldList: newFieldList, + removedLayers: deletedLayer.query + ? [ + { layer: { ...deletedLayer, columns: [] }, fieldList: deletedFieldList }, + ...state.removedLayers, + ] + : state.removedLayers, + }; + }, + + clearLayer(state: EsSQLPrivateState, layerId: string) { + return { + ...state, + layers: { + ...state.layers, + [layerId]: { ...state.layers[layerId], columns: [] }, + }, + }; + }, + + getLayers(state: EsSQLPrivateState) { + return Object.keys(state.layers); + }, + + removeColumn({ prevState, layerId, columnId }) { + return { + ...prevState, + layers: { + ...prevState.layers, + [layerId]: { + ...prevState.layers[layerId], + columns: prevState.layers[layerId].columns.filter((col) => col.columnId !== columnId), + }, + }, + }; + }, + + toExpression, + + getMetaData(state: EsSQLPrivateState) { + return { + filterableIndexPatterns: [], + }; + }, + + renderDataPanel(domElement: Element, props: DatasourceDataPanelProps) { + render( + + + , + domElement + ); + }, + + renderHorizontalDataPanel( + domElement: Element, + props: DatasourceDataPanelProps + ) { + render( + + + , + domElement + ); + }, + + renderDimensionTrigger: ( + domElement: Element, + props: DatasourceDimensionTriggerProps + ) => { + const selectedField = props.state.layers[props.layerId].columns.find( + (column) => column.columnId === props.columnId + )!; + render( {}}>{selectedField.fieldName}, domElement); + }, + + renderDimensionEditor: ( + domElement: Element, + props: DatasourceDimensionEditorProps + ) => { + const fields = props.state.cachedFieldList[props.layerId].fields; + const selectedField = props.state.layers[props.layerId].columns.find( + (column) => column.columnId === props.columnId + ); + render( + ({ value: field.name, text: field.name })), + ]} + onChange={(e) => { + props.setState( + !selectedField + ? { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: [ + ...props.state.layers[props.layerId].columns, + { columnId: props.columnId, fieldName: e.target.value }, + ], + }, + }, + } + : { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: props.state.layers[props.layerId].columns.map((col) => + col.columnId !== props.columnId + ? col + : { ...col, fieldName: e.target.value } + ), + }, + }, + } + ); + }} + />, + domElement + ); + }, + + renderLayerPanel: ( + domElement: Element, + props: DatasourceLayerPanelProps + ) => { + render( + + { + props.state.indexPatternRefs.find( + (r) => r.id === props.state.layers[props.layerId].index + )!.title + } + , + domElement + ); + }, + + canHandleDrop: () => false, + onDrop: () => false, + uniqueLabels(state: EsSQLPrivateState) { + const layers = state.layers; + const columnLabelMap = {} as Record; + + Object.values(layers).forEach((layer) => { + if (!layer.columns) { + return; + } + Object.entries(layer.columns).forEach(([columnId, column]) => { + columnLabelMap[columnId] = columnId; + }); + }); + + return columnLabelMap; + }, + + getDropProps: () => undefined, + + getPublicAPI({ state, layerId }: PublicAPIProps) { + return { + datasourceId: 'essql', + + getTableSpec: () => { + return ( + state.layers[layerId]?.columns.map((column) => ({ columnId: column.columnId })) || [] + ); + }, + getOperationForColumnId: (columnId: string) => { + const layer = state.layers[layerId]; + const column = layer?.columns.find((c) => c.columnId === columnId); + + if (column) { + const field = state.cachedFieldList[layerId].fields.find( + (f) => f.name === column.fieldName + )!; + const overwrite = layer.overwrittenFieldTypes?.[column.fieldName]; + return { + dataType: overwrite || (field?.meta?.type as DataType), + label: field?.name, + isBucketed: false, + }; + } + return null; + }, + }; + }, + getDatasourceSuggestionsForField(state, draggedField) { + return []; + }, + getDatasourceSuggestionsFromCurrentState: (state) => { + return Object.entries(state.layers).map(([id, layer]) => { + const reducedState: EsSQLPrivateState = { + ...state, + cachedFieldList: { + [id]: state.cachedFieldList[id], + }, + layers: { + [id]: state.layers[id], + }, + }; + return { + state: reducedState, + table: { + changeType: 'unchanged', + isMultiRow: !state.cachedFieldList[id].singleRow, + layerId: id, + columns: layer.columns.map((column) => { + const field = state.cachedFieldList[id].fields.find( + (f) => f.name === column.fieldName + )!; + const operation = { + dataType: field?.type as DataType, + label: field?.name, + isBucketed: false, + }; + return { + columnId: column.columnId, + operation, + }; + }), + }, + keptLayerIds: [id], + }; + }); + }, + }; + + return essqlDatasource; +} + +function blankLayer(index: string) { + return { + index, + query: '', + columns: [], + }; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/essql_datasource/field_item.test.tsx new file mode 100644 index 0000000000000..6a4a2bd2ba77b --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/field_item.test.tsx @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { EuiLoadingSpinner, EuiPopover } from '@elastic/eui'; +import { FieldItem, FieldItemProps } from './field_item'; +import { coreMock } from 'src/core/public/mocks'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { IndexPattern } from './types'; + +describe('IndexPattern Field Item', () => { + let defaultProps: FieldItemProps; + let indexPattern: IndexPattern; + let core: ReturnType; + let data: DataPublicPluginStart; + + beforeEach(() => { + indexPattern = { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'unsupported', + type: 'geo', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + } as IndexPattern; + + core = coreMock.createSetup(); + data = dataPluginMock.createStartContract(); + core.http.post.mockClear(); + defaultProps = { + indexPattern, + data, + core, + highlight: '', + dateRange: { + fromDate: 'now-7d', + toDate: 'now', + }, + query: { query: '', language: 'lucene' }, + filters: [], + field: { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + exists: true, + }; + + data.fieldFormats = ({ + getDefaultInstance: jest.fn(() => ({ + convert: jest.fn((s: unknown) => JSON.stringify(s)), + })), + } as unknown) as DataPublicPluginStart['fieldFormats']; + }); + + it('should request field stats without a time field, if the index pattern has none', async () => { + indexPattern.timeFieldName = undefined; + core.http.post.mockImplementationOnce(() => { + return Promise.resolve({}); + }); + const wrapper = mountWithIntl(); + + await act(async () => { + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + }); + + expect(core.http.post).toHaveBeenCalledWith( + '/api/lens/index_stats/my-fake-index-pattern/field', + expect.anything() + ); + // Function argument types not detected correctly (https://github.com/microsoft/TypeScript/issues/26591) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { body } = (core.http.post.mock.calls[0] as any)[1]; + expect(JSON.parse(body)).not.toHaveProperty('timeFieldName'); + }); + + it('should request field stats every time the button is clicked', async () => { + let resolveFunction: (arg: unknown) => void; + + core.http.post.mockImplementation(() => { + return new Promise(resolve => { + resolveFunction = resolve; + }); + }); + + const wrapper = mountWithIntl(); + + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + + expect(core.http.post).toHaveBeenCalledWith( + `/api/lens/index_stats/my-fake-index-pattern/field`, + { + body: JSON.stringify({ + dslQuery: { + bool: { + must: [{ match_all: {} }], + filter: [], + should: [], + must_not: [], + }, + }, + fromDate: 'now-7d', + toDate: 'now', + timeFieldName: 'timestamp', + field: { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + }), + } + ); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + await act(async () => { + resolveFunction!({ + totalDocuments: 4633, + sampledDocuments: 4633, + sampledValues: 4633, + histogram: { + buckets: [{ count: 705, key: 0 }], + }, + topValues: { + buckets: [{ count: 147, key: 0 }], + }, + }); + }); + + wrapper.update(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + expect(core.http.post).toHaveBeenCalledTimes(1); + + act(() => { + const closePopover = wrapper.find(EuiPopover).prop('closePopover'); + + closePopover(); + }); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(false); + + act(() => { + wrapper.setProps({ + dateRange: { + fromDate: 'now-14d', + toDate: 'now-7d', + }, + query: { query: 'geo.src : "US"', language: 'kuery' }, + filters: [ + { + match: { phrase: { 'geo.dest': 'US' } }, + }, + ], + }); + }); + + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + + expect(core.http.post).toHaveBeenCalledTimes(2); + expect(core.http.post).toHaveBeenLastCalledWith( + `/api/lens/index_stats/my-fake-index-pattern/field`, + { + body: JSON.stringify({ + dslQuery: { + bool: { + must: [], + filter: [ + { + bool: { + should: [{ match_phrase: { 'geo.src': 'US' } }], + minimum_should_match: 1, + }, + }, + { + match: { phrase: { 'geo.dest': 'US' } }, + }, + ], + should: [], + must_not: [], + }, + }, + fromDate: 'now-14d', + toDate: 'now-7d', + timeFieldName: 'timestamp', + field: { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + }), + } + ); + }); +}); diff --git a/x-pack/plugins/lens/public/essql_datasource/field_item.tsx b/x-pack/plugins/lens/public/essql_datasource/field_item.tsx new file mode 100644 index 0000000000000..5f0fa95ad0022 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/field_item.tsx @@ -0,0 +1,538 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import DateMath from '@elastic/datemath'; +import { + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiKeyboardAccessible, + EuiLoadingSpinner, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiProgress, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; +import { + Axis, + BarSeries, + Chart, + niceTimeFormatter, + Position, + ScaleType, + Settings, + TooltipType, +} from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { + Query, + KBN_FIELD_TYPES, + ES_FIELD_TYPES, + Filter, + esQuery, + IIndexPattern, +} from '../../../../../src/plugins/data/public'; +import { DraggedField } from './indexpattern'; +import { DragDrop } from '../drag_drop'; +import { DatasourceDataPanelProps, DataType } from '../types'; +import { BucketedAggregation, FieldStatsResponse } from '../../common'; +import { IndexPattern, IndexPatternField } from './types'; +import { LensFieldIcon } from './lens_field_icon'; +import { trackUiEvent } from '../lens_ui_telemetry'; + +export interface FieldItemProps { + core: DatasourceDataPanelProps['core']; + data: DataPublicPluginStart; + field: IndexPatternField; + indexPattern: IndexPattern; + highlight?: string; + exists: boolean; + query: Query; + dateRange: DatasourceDataPanelProps['dateRange']; + filters: Filter[]; + hideDetails?: boolean; +} + +interface State { + isLoading: boolean; + totalDocuments?: number; + sampledDocuments?: number; + sampledValues?: number; + histogram?: BucketedAggregation; + topValues?: BucketedAggregation; +} + +function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; +} + +export function FieldItem(props: FieldItemProps) { + const { + core, + field, + indexPattern, + highlight, + exists, + query, + dateRange, + filters, + hideDetails, + } = props; + + const [infoIsOpen, setOpen] = useState(false); + + const [state, setState] = useState({ + isLoading: false, + }); + + const wrappableName = wrapOnDot(field.name)!; + const wrappableHighlight = wrapOnDot(highlight); + const highlightIndex = wrappableHighlight + ? wrappableName.toLowerCase().indexOf(wrappableHighlight.toLowerCase()) + : -1; + const wrappableHighlightableFieldName = + highlightIndex < 0 ? ( + wrappableName + ) : ( + + {wrappableName.substr(0, highlightIndex)} + {wrappableName.substr(highlightIndex, wrappableHighlight.length)} + {wrappableName.substr(highlightIndex + wrappableHighlight.length)} + + ); + + function fetchData() { + if ( + state.isLoading || + (field.type !== 'number' && + field.type !== 'string' && + field.type !== 'date' && + field.type !== 'boolean' && + field.type !== 'ip') + ) { + return; + } + + setState(s => ({ ...s, isLoading: true })); + + core.http + .post(`/api/lens/index_stats/${indexPattern.title}/field`, { + body: JSON.stringify({ + dslQuery: esQuery.buildEsQuery( + indexPattern as IIndexPattern, + query, + filters, + esQuery.getEsQueryConfig(core.uiSettings) + ), + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, + timeFieldName: indexPattern.timeFieldName, + field, + }), + }) + .then((results: FieldStatsResponse) => { + setState(s => ({ + ...s, + isLoading: false, + totalDocuments: results.totalDocuments, + sampledDocuments: results.sampledDocuments, + sampledValues: results.sampledValues, + histogram: results.histogram, + topValues: results.topValues, + })); + }) + .catch(() => { + setState(s => ({ ...s, isLoading: false })); + }); + } + + function togglePopover() { + if (hideDetails) { + return; + } + + setOpen(!infoIsOpen); + if (!infoIsOpen) { + trackUiEvent('indexpattern_field_info_click'); + fetchData(); + } + } + + return ( + ('.application') || undefined} + button={ + + +
{ + togglePopover(); + }} + onKeyPress={event => { + if (event.key === 'ENTER') { + togglePopover(); + } + }} + aria-label={i18n.translate('xpack.lens.indexPattern.fieldStatsButtonLabel', { + defaultMessage: 'Click for a field preview, or drag and drop to visualize.', + })} + > + + + + {wrappableHighlightableFieldName} + + + +
+
+
+ } + isOpen={infoIsOpen} + closePopover={() => setOpen(false)} + anchorPosition="rightUp" + panelClassName="lnsFieldItem__fieldPopoverPanel" + > + +
+ ); +} + +function FieldItemPopoverContents(props: State & FieldItemProps) { + const { + histogram, + topValues, + indexPattern, + field, + dateRange, + core, + sampledValues, + data: { fieldFormats }, + } = props; + + const IS_DARK_THEME = core.uiSettings.get('theme:darkMode'); + const chartTheme = IS_DARK_THEME ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; + let histogramDefault = !!props.histogram; + + const totalValuesCount = + topValues && topValues.buckets.reduce((prev, bucket) => bucket.count + prev, 0); + const otherCount = sampledValues && totalValuesCount ? sampledValues - totalValuesCount : 0; + + if ( + totalValuesCount && + histogram && + histogram.buckets.length && + topValues && + topValues.buckets.length + ) { + // Default to histogram when top values are less than 10% of total + histogramDefault = otherCount / totalValuesCount > 0.9; + } + + const [showingHistogram, setShowingHistogram] = useState(histogramDefault); + + let formatter: { convert: (data: unknown) => string }; + if (indexPattern.fieldFormatMap && indexPattern.fieldFormatMap[field.name]) { + const FormatType = fieldFormats.getType(indexPattern.fieldFormatMap[field.name].id); + if (FormatType) { + formatter = new FormatType( + indexPattern.fieldFormatMap[field.name].params, + core.uiSettings.get.bind(core.uiSettings) + ); + } else { + formatter = { convert: (data: unknown) => JSON.stringify(data) }; + } + } else { + formatter = fieldFormats.getDefaultInstance( + field.type as KBN_FIELD_TYPES, + field.esTypes as ES_FIELD_TYPES[] + ); + } + + const fromDate = DateMath.parse(dateRange.fromDate); + const toDate = DateMath.parse(dateRange.toDate); + + let title = <>; + + if (props.isLoading) { + return ; + } else if ( + (!props.histogram || props.histogram.buckets.length === 0) && + (!props.topValues || props.topValues.buckets.length === 0) + ) { + return ( + + {i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { + defaultMessage: 'No data to display.', + })} + + ); + } + + if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) { + title = ( + { + setShowingHistogram(optionId === 'histogram'); + }} + idSelected={showingHistogram ? 'histogram' : 'topValues'} + /> + ); + } else if (field.type === 'date') { + title = ( + <> + {i18n.translate('xpack.lens.indexPattern.fieldTimeDistributionLabel', { + defaultMessage: 'Time distribution', + })} + + ); + } else if (topValues && topValues.buckets.length) { + title = ( + <> + {i18n.translate('xpack.lens.indexPattern.fieldTopValuesLabel', { + defaultMessage: 'Top values', + })} + + ); + } + + function wrapInPopover(el: React.ReactElement) { + return ( + <> + {title ? {title} : <>} + {el} + + {props.totalDocuments ? ( + + + {props.sampledDocuments && ( + <> + {i18n.translate('xpack.lens.indexPattern.percentageOfLabel', { + defaultMessage: '{percentage}% of', + values: { + percentage: Math.round((props.sampledDocuments / props.totalDocuments) * 100), + }, + })} + + )}{' '} + + {fieldFormats + .getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER]) + .convert(props.totalDocuments)} + {' '} + {i18n.translate('xpack.lens.indexPattern.ofDocumentsLabel', { + defaultMessage: 'documents', + })} + + + ) : ( + <> + )} + + ); + } + + if (histogram && histogram.buckets.length) { + const specId = i18n.translate('xpack.lens.indexPattern.fieldStatsCountLabel', { + defaultMessage: 'Count', + }); + + if (field.type === 'date') { + return wrapInPopover( + + + + + + + + ); + } else if (showingHistogram || !topValues || !topValues.buckets.length) { + return wrapInPopover( + + + + formatter.convert(d)} + /> + + + + ); + } + } + + if (props.topValues && props.topValues.buckets.length) { + return wrapInPopover( +
+ {props.topValues.buckets.map(topValue => { + const formatted = formatter.convert(topValue.key); + return ( +
+ + + {formatted === '' ? ( + + + {i18n.translate('xpack.lens.indexPattern.fieldPanelEmptyStringValue', { + defaultMessage: 'Empty string', + })} + + + ) : ( + + + {formatted} + + + )} + + + + {Math.round((topValue.count / props.sampledValues!) * 100)}% + + + + + +
+ ); + })} + {otherCount ? ( + <> + + + + {i18n.translate('xpack.lens.indexPattern.otherDocsLabel', { + defaultMessage: 'Other', + })} + + + + + + {Math.round((otherCount / props.sampledValues!) * 100)}% + + + + + + + ) : ( + <> + )} +
+ ); + } + return <>; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/index.ts b/x-pack/plugins/lens/public/essql_datasource/index.ts new file mode 100644 index 0000000000000..809cf06a3217c --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/index.ts @@ -0,0 +1,47 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup } from 'kibana/public'; +import { get } from 'lodash'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { getEsSQLDatasource } from './essql'; +import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, +} from '../../../../../src/plugins/data/public'; +import { Datasource, EditorFrameSetup } from '../types'; + +export interface IndexPatternDatasourceSetupPlugins { + expressions: ExpressionsSetup; + data: DataPublicPluginSetup; + editorFrame: EditorFrameSetup; +} + +export interface IndexPatternDatasourceStartPlugins { + data: DataPublicPluginStart; +} + +export class EsSQLDatasource { + constructor() {} + + setup( + core: CoreSetup, + { expressions, editorFrame }: IndexPatternDatasourceSetupPlugins + ) { + editorFrame.registerDatasource( + core.getStartServices().then(([coreStart, { data }]) => + getEsSQLDatasource({ + core: coreStart, + storage: new Storage(localStorage), + data, + expressions, + }) + ) as Promise + ); + } +} diff --git a/x-pack/plugins/lens/public/essql_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/essql_datasource/indexpattern.test.ts new file mode 100644 index 0000000000000..5bdf389748f4b --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/indexpattern.test.ts @@ -0,0 +1,557 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { getEsSQLDatasource, IndexPatternColumn, uniqueLabels } from './indexpattern'; +import { DatasourcePublicAPI, Operation, Datasource } from '../types'; +import { coreMock } from 'src/core/public/mocks'; +import { EsSQLPersistedState, EsSQLPrivateState } from './types'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { Ast } from '@kbn/interpreter/common'; + +jest.mock('./loader'); +jest.mock('../id_generator'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + 2: { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + // Ignored in the UI + histogram: { + agg: 'histogram', + interval: 1000, + }, + avg: { + agg: 'avg', + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, +}; + +function stateFromPersistedState( + persistedState: EsSQLPersistedState +): EsSQLPrivateState { + return { + currentIndexPatternId: persistedState.currentIndexPatternId, + layers: persistedState.layers, + indexPatterns: expectedIndexPatterns, + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: true, + }; +} + +describe('IndexPattern Data Source', () => { + let persistedState: EsSQLPersistedState; + let indexPatternDatasource: Datasource; + + beforeEach(() => { + indexPatternDatasource = getEsSQLDatasource({ + storage: {} as IStorageWrapper, + core: coreMock.createStart(), + data: dataPluginMock.createStartContract(), + }); + + persistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }, + }, + }, + }; + }); + + describe('uniqueLabels', () => { + it('appends a suffix to duplicates', () => { + const col: IndexPatternColumn = { + dataType: 'number', + isBucketed: false, + label: 'Foo', + operationType: 'count', + sourceField: 'Records', + }; + const map = uniqueLabels({ + a: { + columnOrder: ['a', 'b'], + columns: { + a: col, + b: col, + }, + indexPatternId: 'foo', + }, + b: { + columnOrder: ['c', 'd'], + columns: { + c: col, + d: { + ...col, + label: 'Foo [1]', + }, + }, + indexPatternId: 'foo', + }, + }); + + expect(map).toMatchInlineSnapshot(` + Object { + "a": "Foo", + "b": "Foo [1]", + "c": "Foo [2]", + "d": "Foo [1] [1]", + } + `); + }); + }); + + describe('#getPersistedState', () => { + it('should persist from saved state', async () => { + const state = stateFromPersistedState(persistedState); + + expect(indexPatternDatasource.getPersistableState(state)).toEqual(persistedState); + }); + }); + + describe('#toExpression', () => { + it('should generate an empty expression when no columns are selected', async () => { + const state = await indexPatternDatasource.initialize(); + expect(indexPatternDatasource.toExpression(state, 'first')).toEqual(null); + }); + + it('should generate an expression for an aggregated query', async () => { + const queryPersistedState: EsSQLPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object { + "aggConfigs": Array [ + "[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]", + ], + "includeFormatHints": Array [ + true, + ], + "index": Array [ + "1", + ], + "metricsAtAllLevels": Array [ + true, + ], + "partialRows": Array [ + true, + ], + "timeFields": Array [ + "timestamp", + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "idMap": Array [ + "{\\"col--1-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-2-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", + ], + }, + "function": "lens_rename_columns", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + + it('should put all time fields used in date_histograms to the esaggs timeFields parameter', async () => { + const queryPersistedState: EsSQLPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + col3: { + label: 'Date 2', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'another_datefield', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); + }); + + it('should not put date fields used outside date_histograms to the esaggs timeFields parameter', async () => { + const queryPersistedState: EsSQLPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count of records', + dataType: 'date', + isBucketed: false, + sourceField: 'timefield', + operationType: 'cardinality', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']); + expect(ast.chain[0].arguments.timeFields).not.toContain('timefield'); + }); + }); + + describe('#insertLayer', () => { + it('should insert an empty layer into the previous state', () => { + const state = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + showEmptyFields: false, + }; + expect(indexPatternDatasource.insertLayer(state, 'newLayer')).toEqual({ + ...state, + layers: { + ...state.layers, + newLayer: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + }); + }); + }); + + describe('#removeLayer', () => { + it('should remove a layer', () => { + const state = { + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }; + expect(indexPatternDatasource.removeLayer(state, 'first')).toEqual({ + ...state, + layers: { + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + }); + }); + }); + + describe('#getLayers', () => { + it('should list the current layers', () => { + expect( + indexPatternDatasource.getLayers({ + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual(['first', 'second']); + }); + }); + + describe('#getMetadata', () => { + it('should return the title of the index patterns', () => { + expect( + indexPatternDatasource.getMetaData({ + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual({ + filterableIndexPatterns: [ + { + id: '1', + title: 'my-fake-index-pattern', + }, + { + id: '2', + title: 'my-fake-restricted-pattern', + }, + ], + }); + }); + }); + + describe('#getPublicAPI', () => { + let publicAPI: DatasourcePublicAPI; + + beforeEach(async () => { + const initialState = stateFromPersistedState(persistedState); + publicAPI = indexPatternDatasource.getPublicAPI({ + state: initialState, + layerId: 'first', + dateRange: { + fromDate: 'now-30d', + toDate: 'now', + }, + }); + }); + + describe('getTableSpec', () => { + it('should include col1', () => { + expect(publicAPI.getTableSpec()).toEqual([ + { + columnId: 'col1', + }, + ]); + }); + }); + + describe('getOperationForColumnId', () => { + it('should get an operation for col1', () => { + expect(publicAPI.getOperationForColumnId('col1')).toEqual({ + label: 'My Op', + dataType: 'string', + isBucketed: true, + } as Operation); + }); + + it('should return null for non-existant columns', () => { + expect(publicAPI.getOperationForColumnId('col2')).toBe(null); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/essql_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/essql_datasource/layerpanel.test.tsx new file mode 100644 index 0000000000000..0b5b7eae9f665 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/layerpanel.test.tsx @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EsSQLPrivateState } from './types'; +import { IndexPatternLayerPanelProps, LayerPanel } from './layerpanel'; +import { shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +import { ShallowWrapper } from 'enzyme'; +import { EuiSelectable, EuiSelectableList } from '@elastic/eui'; +import { ChangeIndexPattern } from './change_indexpattern'; + +jest.mock('./state_helpers'); + +const initialState: EsSQLPrivateState = { + indexPatternRefs: [ + { id: '1', title: 'my-fake-index-pattern' }, + { id: '2', title: 'my-fake-restricted-pattern' }, + { id: '3', title: 'my-compatible-pattern' }, + ], + existingFields: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'source', + params: { + size: 5, + orderDirection: 'asc', + orderBy: { + type: 'alphabetical', + }, + }, + }, + col2: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + operationType: 'avg', + sourceField: 'memory', + }, + }, + }, + }, + indexPatterns: { + '1': { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'unsupported', + type: 'geo', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + '2': { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + histogram: { + agg: 'histogram', + interval: 1000, + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, + '3': { + id: '3', + title: 'my-compatible-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + }, +}; +describe('Layer Data Panel', () => { + let defaultProps: IndexPatternLayerPanelProps; + + beforeEach(() => { + defaultProps = { + layerId: 'first', + state: initialState, + setState: jest.fn(), + onChangeIndexPattern: jest.fn(async () => {}), + }; + }); + + function getIndexPatternPickerList(instance: ShallowWrapper) { + return instance + .find(ChangeIndexPattern) + .first() + .dive() + .find(EuiSelectable); + } + + function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { + const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( + instance + ).map(option => + option.label === selectedLabel + ? { ...option, checked: 'on' } + : { ...option, checked: undefined } + ); + return getIndexPatternPickerList(instance).prop('onChange')!(options); + } + + function getIndexPatternPickerOptions(instance: ShallowWrapper) { + return getIndexPatternPickerList(instance) + .dive() + .find(EuiSelectableList) + .prop('options'); + } + + it('should list all index patterns', () => { + const instance = shallow(); + + expect(getIndexPatternPickerOptions(instance)!.map(option => option.label)).toEqual([ + 'my-fake-index-pattern', + 'my-fake-restricted-pattern', + 'my-compatible-pattern', + ]); + }); + + it('should switch data panel to target index pattern', () => { + const instance = shallow(); + + selectIndexPatternPickerOption(instance, 'my-compatible-pattern'); + + expect(defaultProps.onChangeIndexPattern).toHaveBeenCalledWith('3'); + }); +}); diff --git a/x-pack/plugins/lens/public/essql_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/essql_datasource/layerpanel.tsx new file mode 100644 index 0000000000000..b68013c1f214c --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/layerpanel.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { DatasourceLayerPanelProps } from '../types'; +import { EsSQLPrivateState } from './types'; +import { ChangeIndexPattern } from './change_indexpattern'; + +export interface IndexPatternLayerPanelProps + extends DatasourceLayerPanelProps { + state: EsSQLPrivateState; + onChangeIndexPattern: (newId: string) => void; +} + +export function LayerPanel({ state, layerId, onChangeIndexPattern }: IndexPatternLayerPanelProps) { + const layer = state.layers[layerId]; + + return ( + + + + ); +} diff --git a/x-pack/plugins/lens/public/essql_datasource/lens_field_icon.test.tsx b/x-pack/plugins/lens/public/essql_datasource/lens_field_icon.test.tsx new file mode 100644 index 0000000000000..317ce8f032f94 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/lens_field_icon.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; +import { LensFieldIcon } from './lens_field_icon'; + +test('LensFieldIcon renders properly', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('LensFieldIcon accepts FieldIcon props', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/lens/public/essql_datasource/lens_field_icon.tsx b/x-pack/plugins/lens/public/essql_datasource/lens_field_icon.tsx new file mode 100644 index 0000000000000..bcc83e799d889 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/lens_field_icon.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FieldIcon, FieldIconProps } from '../../../../../src/plugins/kibana_react/public'; +import { DataType } from '../types'; +import { normalizeOperationDataType } from './utils'; + +export function LensFieldIcon({ type, ...rest }: FieldIconProps & { type: DataType }) { + return ( + + ); +} diff --git a/x-pack/plugins/lens/public/essql_datasource/mocks.ts b/x-pack/plugins/lens/public/essql_datasource/mocks.ts new file mode 100644 index 0000000000000..dff3e61342a6a --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/mocks.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DragContextState } from '../drag_drop'; +import { IndexPattern } from './types'; + +export const createMockedIndexPattern = (): IndexPattern => ({ + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], +}); + +export const createMockedRestrictedIndexPattern = () => ({ + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + typeMeta: { + params: { + rollup_index: 'my-fake-index-pattern', + }, + aggs: { + terms: { + source: { + agg: 'terms', + }, + }, + date_histogram: { + timestamp: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + histogram: { + bytes: { + agg: 'histogram', + interval: 1000, + }, + }, + avg: { + bytes: { + agg: 'avg', + }, + }, + max: { + bytes: { + agg: 'max', + }, + }, + min: { + bytes: { + agg: 'min', + }, + }, + sum: { + bytes: { + agg: 'sum', + }, + }, + }, + }, +}); + +export function createMockedDragDropContext(): jest.Mocked { + return { + dragging: undefined, + setDragging: jest.fn(), + }; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/pure_helpers.test.ts b/x-pack/plugins/lens/public/essql_datasource/pure_helpers.test.ts new file mode 100644 index 0000000000000..05b00a66e8348 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/pure_helpers.test.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fieldExists } from './pure_helpers'; + +describe('fieldExists', () => { + it('returns whether or not a field exists', () => { + expect(fieldExists({ a: { b: true } }, 'a', 'b')).toBeTruthy(); + expect(fieldExists({ a: { b: true } }, 'a', 'c')).toBeFalsy(); + expect(fieldExists({ a: { b: true } }, 'b', 'b')).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/lens/public/essql_datasource/pure_helpers.ts b/x-pack/plugins/lens/public/essql_datasource/pure_helpers.ts new file mode 100644 index 0000000000000..28731a17f6c97 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/pure_helpers.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EsSQLPrivateState } from './types'; + +export function fieldExists( + existingFields: EsSQLPrivateState['existingFields'], + indexPatternTitle: string, + fieldName: string +) { + return existingFields[indexPatternTitle] && existingFields[indexPatternTitle][fieldName]; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/rename_columns.test.ts b/x-pack/plugins/lens/public/essql_datasource/rename_columns.test.ts new file mode 100644 index 0000000000000..4bfd6a4f93c75 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/rename_columns.test.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renameColumns } from './rename_columns'; +import { KibanaDatatable } from '../../../../../src/plugins/expressions/public'; +import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; + +describe('rename_columns', () => { + it('should rename columns of a given datatable', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }; + + const idMap = { + a: { + id: 'b', + label: 'Austrailia', + }, + b: { + id: 'c', + label: 'Boomerang', + }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "b", + "name": "Austrailia", + }, + Object { + "id": "c", + "name": "Boomerang", + }, + ], + "rows": Array [ + Object { + "b": 1, + "c": 2, + }, + Object { + "b": 3, + "c": 4, + }, + Object { + "b": 5, + "c": 6, + }, + Object { + "b": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", + } + `); + }); + + it('should replace "" with a visible value', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'a', name: 'A' }], + rows: [{ a: '' }], + }; + + const idMap = { + a: { + id: 'a', + label: 'Austrailia', + }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result.rows[0].a).toEqual('(empty)'); + }); + + it('should keep columns which are not mapped', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }; + + const idMap = { + b: { id: 'c', label: 'Catamaran' }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "a", + "name": "A", + }, + Object { + "id": "c", + "name": "Catamaran", + }, + ], + "rows": Array [ + Object { + "a": 1, + "c": 2, + }, + Object { + "a": 3, + "c": 4, + }, + Object { + "a": 5, + "c": 6, + }, + Object { + "a": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", + } + `); + }); + + it('should rename date histograms', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'banana per 30 seconds' }, + ], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }; + + const idMap = { + b: { id: 'c', label: 'Apple', operationType: 'date_histogram', sourceField: 'banana' }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "a", + "name": "A", + }, + Object { + "id": "c", + "name": "Apple per 30 seconds", + }, + ], + "rows": Array [ + Object { + "a": 1, + "c": 2, + }, + Object { + "a": 3, + "c": 4, + }, + Object { + "a": 5, + "c": 6, + }, + Object { + "a": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", + } + `); + }); +}); diff --git a/x-pack/plugins/lens/public/essql_datasource/rename_columns.ts b/x-pack/plugins/lens/public/essql_datasource/rename_columns.ts new file mode 100644 index 0000000000000..248eb12ec8026 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/rename_columns.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { + ExpressionFunctionDefinition, + KibanaDatatable, + KibanaDatatableColumn, +} from 'src/plugins/expressions'; +import { IndexPatternColumn } from './operations'; + +interface RemapArgs { + idMap: string; +} + +export type OriginalColumn = { id: string } & IndexPatternColumn; + +export const renameColumns: ExpressionFunctionDefinition< + 'lens_rename_columns', + KibanaDatatable, + RemapArgs, + KibanaDatatable +> = { + name: 'lens_rename_columns', + type: 'kibana_datatable', + help: i18n.translate('xpack.lens.functions.renameColumns.help', { + defaultMessage: 'A helper to rename the columns of a datatable', + }), + args: { + idMap: { + types: ['string'], + help: i18n.translate('xpack.lens.functions.renameColumns.idMap.help', { + defaultMessage: + 'A JSON encoded object in which keys are the old column ids and values are the corresponding new ones. All other columns ids are kept.', + }), + }, + }, + inputTypes: ['kibana_datatable'], + fn(data, { idMap: encodedIdMap }) { + const idMap = JSON.parse(encodedIdMap) as Record; + + return { + type: 'kibana_datatable', + rows: data.rows.map(row => { + const mappedRow: Record = {}; + Object.entries(idMap).forEach(([fromId, toId]) => { + mappedRow[toId.id] = row[fromId]; + }); + + Object.entries(row).forEach(([id, value]) => { + if (id in idMap) { + mappedRow[idMap[id].id] = sanitizeValue(value); + } else { + mappedRow[id] = sanitizeValue(value); + } + }); + + return mappedRow; + }), + columns: data.columns.map(column => { + const mappedItem = idMap[column.id]; + + if (!mappedItem) { + return column; + } + + return { + ...column, + id: mappedItem.id, + name: getColumnName(mappedItem, column), + }; + }), + }; + }, +}; + +function getColumnName(originalColumn: OriginalColumn, newColumn: KibanaDatatableColumn) { + if (originalColumn && originalColumn.operationType === 'date_histogram') { + const fieldName = originalColumn.sourceField; + + // HACK: This is a hack, and introduces some fragility into + // column naming. Eventually, this should be calculated and + // built more systematically. + return newColumn.name.replace(fieldName, originalColumn.label); + } + + return originalColumn.label; +} + +function sanitizeValue(value: unknown) { + if (value === '') { + return i18n.translate('xpack.lens.indexpattern.emptyTextColumnValue', { + defaultMessage: '(empty)', + }); + } + + return value; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/state_helpers.test.ts b/x-pack/plugins/lens/public/essql_datasource/state_helpers.test.ts new file mode 100644 index 0000000000000..9c82c504b718f --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/state_helpers.test.ts @@ -0,0 +1,732 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + updateColumnParam, + changeColumn, + getColumnOrder, + deleteColumn, + updateLayerIndexPattern, +} from './state_helpers'; +import { operationDefinitionMap } from './operations'; +import { TermsIndexPatternColumn } from './operations/definitions/terms'; +import { DateHistogramIndexPatternColumn } from './operations/definitions/date_histogram'; +import { AvgIndexPatternColumn } from './operations/definitions/metrics'; +import { IndexPattern, EsSQLPrivateState, EsSQLLayer } from './types'; + +jest.mock('./operations'); + +describe('state_helpers', () => { + describe('deleteColumn', () => { + it('should remove column', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'column', columnId: 'col2' }, + orderDirection: 'desc', + size: 5, + }, + }; + + const state: EsSQLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }; + + expect( + deleteColumn({ state, columnId: 'col2', layerId: 'first' }).layers.first.columns + ).toEqual({ + col1: { + ...termsColumn, + params: { + ...termsColumn.params, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }); + }); + + it('should adjust when deleting other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }; + + const state: EsSQLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }; + + deleteColumn({ + state, + columnId: 'col2', + layerId: 'first', + }); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + }); + }); + }); + + describe('updateColumnParam', () => { + it('should set the param for the given column', () => { + const currentColumn: DateHistogramIndexPatternColumn = { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }; + + const state: EsSQLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }, + }, + }; + + expect( + updateColumnParam({ + state, + layerId: 'first', + currentColumn, + paramName: 'interval', + value: 'M', + }).layers.first.columns.col1 + ).toEqual({ + ...currentColumn, + params: { interval: 'M' }, + }); + }); + + it('should set optional params', () => { + const currentColumn: AvgIndexPatternColumn = { + label: 'Avg of bytes', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bytes', + }; + + const state: EsSQLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }, + }, + }; + + expect( + updateColumnParam({ + state, + layerId: 'first', + currentColumn, + paramName: 'format', + value: { id: 'bytes' }, + }).layers.first.columns.col1 + ).toEqual({ + ...currentColumn, + params: { format: { id: 'bytes' } }, + }); + }); + }); + + describe('changeColumn', () => { + it('should update order on changing the column', () => { + const state: EsSQLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col2: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }, + }, + }; + expect( + changeColumn({ + state, + columnId: 'col2', + layerId: 'first', + newColumn: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }) + ).toEqual({ + ...state, + layers: { + first: expect.objectContaining({ + columnOrder: ['col2', 'col1'], + }), + }, + }); + }); + + it('should carry over params from old column if the operation type stays the same', () => { + const state: EsSQLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }, + }, + }, + }; + expect( + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn: { + label: 'Date histogram of order_date', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'order_date', + params: { + interval: 'w', + }, + }, + }).layers.first.columns.col1 + ).toEqual( + expect.objectContaining({ + params: { interval: 'h' }, + }) + ); + }); + + it('should execute adjustments for other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }; + + const newColumn: AvgIndexPatternColumn = { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }; + + const state: EsSQLPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }; + + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn, + }); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + col2: newColumn, + }); + }); + }); + + describe('getColumnOrder', () => { + it('should work for empty columns', () => { + expect(getColumnOrder({})).toEqual([]); + }); + + it('should work for one column', () => { + expect( + getColumnOrder({ + col1: { + label: 'Value of timestamp', + dataType: 'string', + isBucketed: false, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }) + ).toEqual(['col1']); + }); + + it('should put any number of aggregations before metrics', () => { + expect( + getColumnOrder({ + col1: { + label: 'Top values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + }, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col3: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col1', 'col3', 'col2']); + }); + + it('should reorder aggregations based on suggested priority', () => { + expect( + getColumnOrder({ + col1: { + label: 'Top values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + suggestedPriority: 2, + }, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + suggestedPriority: 0, + }, + col3: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + suggestedPriority: 1, + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col3', 'col1', 'col2']); + }); + }); + + describe('updateLayerIndexPattern', () => { + const indexPattern: IndexPattern = { + id: 'test', + title: '', + fields: [ + { + name: 'fieldA', + aggregatable: true, + searchable: true, + type: 'string', + }, + { + name: 'fieldB', + aggregatable: true, + searchable: true, + type: 'number', + aggregationRestrictions: { + avg: { + agg: 'avg', + }, + }, + }, + { + name: 'fieldC', + aggregatable: false, + searchable: true, + type: 'date', + }, + { + name: 'fieldD', + aggregatable: true, + searchable: true, + type: 'date', + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'CET', + calendar_interval: 'w', + }, + }, + }, + { + name: 'fieldE', + aggregatable: true, + searchable: true, + type: 'date', + }, + ], + }; + + it('should switch index pattern id in layer', () => { + const layer = { columnOrder: [], columns: {}, indexPatternId: 'original' }; + expect(updateLayerIndexPattern(layer, indexPattern)).toEqual({ + ...layer, + indexPatternId: 'test', + }); + }); + + it('should remove operations referencing unavailable fields', () => { + const layer: EsSQLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'xxx', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + + it('should remove operations referencing fields with insufficient capabilities', () => { + const layer: EsSQLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'fieldC', + params: { + interval: 'd', + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'fieldB', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col2']); + expect(updatedLayer.columns).toEqual({ + col2: layer.columns.col2, + }); + }); + + it('should rewrite column params if that is necessary due to restrictions', () => { + const layer: EsSQLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'date', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'fieldD', + params: { + interval: 'd', + }, + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: { + ...layer.columns.col1, + params: { + interval: 'w', + timeZone: 'CET', + }, + }, + }); + }); + + it('should remove operations referencing fields with wrong field types', () => { + const layer: EsSQLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'fieldD', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + + it('should remove operations referencing fields with incompatible restrictions', () => { + const layer: EsSQLLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'min', + sourceField: 'fieldC', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/essql_datasource/state_helpers.ts b/x-pack/plugins/lens/public/essql_datasource/state_helpers.ts new file mode 100644 index 0000000000000..da551f22a9618 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/state_helpers.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { isColumnTransferable } from './operations'; +import { operationDefinitionMap, IndexPatternColumn } from './operations'; +import { IndexPattern, EsSQLPrivateState, EsSQLLayer } from './types'; + +export function updateColumnParam({ + state, + layerId, + currentColumn, + paramName, + value, +}: { + state: EsSQLPrivateState; + layerId: string; + currentColumn: C; + paramName: string; + value: unknown; +}): EsSQLPrivateState { + const columnId = Object.entries(state.layers[layerId].columns).find( + ([_columnId, column]) => column === currentColumn + )![0]; + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columns: { + ...state.layers[layerId].columns, + [columnId]: { + ...currentColumn, + params: { + ...currentColumn.params, + [paramName]: value, + }, + }, + }, + }, + }, + }; +} + +function adjustColumnReferencesForChangedColumn( + columns: Record, + columnId: string +) { + const newColumns = { ...columns }; + Object.keys(newColumns).forEach(currentColumnId => { + if (currentColumnId !== columnId) { + const currentColumn = newColumns[currentColumnId]; + const operationDefinition = operationDefinitionMap[currentColumn.operationType]; + newColumns[currentColumnId] = operationDefinition.onOtherColumnChanged + ? operationDefinition.onOtherColumnChanged(currentColumn, newColumns) + : currentColumn; + } + }); + return newColumns; +} + +export function changeColumn({ + state, + layerId, + columnId, + newColumn, + keepParams = true, +}: { + state: EsSQLPrivateState; + layerId: string; + columnId: string; + newColumn: C; + keepParams?: boolean; +}): EsSQLPrivateState { + const oldColumn = state.layers[layerId].columns[columnId]; + + const updatedColumn = + keepParams && + oldColumn && + oldColumn.operationType === newColumn.operationType && + 'params' in oldColumn + ? { ...newColumn, params: oldColumn.params } + : newColumn; + + const newColumns = adjustColumnReferencesForChangedColumn( + { + ...state.layers[layerId].columns, + [columnId]: updatedColumn, + }, + columnId + ); + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }, + }, + }; +} + +export function deleteColumn({ + state, + layerId, + columnId, +}: { + state: EsSQLPrivateState; + layerId: string; + columnId: string; +}): EsSQLPrivateState { + const hypotheticalColumns = { ...state.layers[layerId].columns }; + delete hypotheticalColumns[columnId]; + + const newColumns = adjustColumnReferencesForChangedColumn(hypotheticalColumns, columnId); + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }, + }, + }; +} + +export function getColumnOrder(columns: Record): string[] { + const entries = Object.entries(columns); + + const [aggregations, metrics] = _.partition(entries, ([id, col]) => col.isBucketed); + + return aggregations + .sort(([id, col], [id2, col2]) => { + return ( + // Sort undefined orders last + (col.suggestedPriority !== undefined ? col.suggestedPriority : Number.MAX_SAFE_INTEGER) - + (col2.suggestedPriority !== undefined ? col2.suggestedPriority : Number.MAX_SAFE_INTEGER) + ); + }) + .map(([id]) => id) + .concat(metrics.map(([id]) => id)); +} + +export function updateLayerIndexPattern( + layer: EsSQLLayer, + newIndexPattern: IndexPattern +): EsSQLLayer { + const keptColumns: EsSQLLayer['columns'] = _.pick(layer.columns, column => + isColumnTransferable(column, newIndexPattern) + ); + const newColumns: EsSQLLayer['columns'] = _.mapValues(keptColumns, column => { + const operationDefinition = operationDefinitionMap[column.operationType]; + return operationDefinition.transfer + ? operationDefinition.transfer(column, newIndexPattern) + : column; + }); + const newColumnOrder = layer.columnOrder.filter(columnId => newColumns[columnId]); + + return { + ...layer, + indexPatternId: newIndexPattern.id, + columns: newColumns, + columnOrder: newColumnOrder, + }; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/to_expression.ts b/x-pack/plugins/lens/public/essql_datasource/to_expression.ts new file mode 100644 index 0000000000000..80d977af5a9db --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/to_expression.ts @@ -0,0 +1,61 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import _ from 'lodash'; +import { Ast, ExpressionFunctionAST } from '@kbn/interpreter/common'; +import { IndexPatternColumn } from './indexpattern'; +import { operationDefinitionMap } from './operations'; +import { IndexPattern, EsSQLPrivateState, EsSQLLayer } from './types'; +import { OriginalColumn } from './rename_columns'; +import { dateHistogramOperation } from './operations/definitions'; + +function getExpressionForLayer(layer: EsSQLLayer, refs: any): Ast | null { + if (layer.columns.length === 0) { + return null; + } + + const idMap = layer.columns.reduce((currentIdMap, column, index) => { + return { + ...currentIdMap, + [column.fieldName]: { + id: column.columnId, + }, + }; + }, {} as Record); + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'essql', + arguments: { + query: [layer.query], + timefield: [refs.find((r) => r.id === layer.index)!.timeField], + }, + }, + { + type: 'function', + function: 'lens_rename_columns', + arguments: { + idMap: [JSON.stringify(idMap)], + overwriteTypes: layer.overwrittenFieldTypes + ? [JSON.stringify(layer.overwrittenFieldTypes)] + : [], + }, + }, + ], + }; +} + +export function toExpression(state: EsSQLPrivateState, layerId: string) { + if (state.layers[layerId]) { + return getExpressionForLayer(state.layers[layerId], state.indexPatternRefs); + } + + return null; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/types.ts b/x-pack/plugins/lens/public/essql_datasource/types.ts new file mode 100644 index 0000000000000..5e11646ea5cae --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/types.ts @@ -0,0 +1,35 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface EsSQLLayer { + index: string; + query: string; + columns: Array<{ columnId: string; fieldName: string }>; + timeField?: string; + overwrittenFieldTypes?: Record; +} + +export interface EsSQLPersistedState { + layers: Record; +} + +export type EsSQLPrivateState = EsSQLPersistedState & { + indexPatternRefs: IndexPatternRef[]; + cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + >; + removedLayers: Array<{ + layer: EsSQLLayer; + fieldList: { fields: Array<{ name: string; type: string }>; singleRow: boolean }; + }>; +}; + +export interface IndexPatternRef { + id: string; + title: string; +} diff --git a/x-pack/plugins/lens/public/essql_datasource/utils.ts b/x-pack/plugins/lens/public/essql_datasource/utils.ts new file mode 100644 index 0000000000000..fadee01e695d5 --- /dev/null +++ b/x-pack/plugins/lens/public/essql_datasource/utils.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { DraggedField } from './indexpattern'; +import { + BaseIndexPatternColumn, + FieldBasedIndexPatternColumn, +} from './operations/definitions/column_types'; +import { DataType } from '../types'; + +/** + * Normalizes the specified operation type. (e.g. document operations + * produce 'number') + */ +export function normalizeOperationDataType(type: DataType) { + return type === 'document' ? 'number' : type; +} + +export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function sortByField(columns: C[]) { + return [...columns].sort((column1, column2) => { + if (hasField(column1) && hasField(column2)) { + return column1.sourceField.localeCompare(column2.sourceField); + } + return column1.operationType.localeCompare(column2.operationType); + }); +} + +export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { + return ( + typeof fieldCandidate === 'object' && + fieldCandidate !== null && + 'field' in fieldCandidate && + 'indexPatternId' in fieldCandidate + ); +} diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index ab1dbd3575b26..a231286d96a4f 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -85,6 +85,7 @@ import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/ import { setupExpressions } from './expressions'; import { getSearchProvider } from './search_provider'; import { EsDSLDatasource } from './esdsl_datasource'; +import { EsSQLDatasource } from './essql_datasource'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -167,6 +168,7 @@ export class LensPlugin { private editorFrameService: EditorFrameServiceType | undefined; private indexpatternDatasource: IndexPatternDatasourceType | undefined; private esdslDatasource: IndexPatternDatasourceType | undefined; + private essqlDatasource: IndexPatternDatasourceType | undefined; private xyVisualization: XyVisualizationType | undefined; private metricVisualization: MetricVisualizationType | undefined; private pieVisualization: PieVisualizationType | undefined; @@ -314,6 +316,7 @@ export class LensPlugin { this.editorFrameService = new EditorFrameService(); this.indexpatternDatasource = new IndexPatternDatasource(); this.esdslDatasource = new EsDSLDatasource(); + this.essqlDatasource = new EsSQLDatasource(); this.xyVisualization = new XyVisualization(); this.metricVisualization = new MetricVisualization(); this.pieVisualization = new PieVisualization(); @@ -335,6 +338,7 @@ export class LensPlugin { }; this.indexpatternDatasource.setup(core, dependencies); this.esdslDatasource.setup(core, dependencies); + this.essqlDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); From 2a2663fbe3d57617ca6d00a9af98984e8cc6507b Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 19 Nov 2021 10:43:09 +0100 Subject: [PATCH 04/15] force apply --- .../server/http_resources/get_apm_config.ts | 2 +- .../editor_frame/data_panel_wrapper.tsx | 11 ++ .../public/essql_datasource/datapanel.tsx | 115 +++++++++++------- x-pack/plugins/lens/public/plugin.ts | 4 +- .../public/state_management/lens_slice.ts | 41 +++++-- x-pack/plugins/lens/public/types.ts | 1 + 6 files changed, 116 insertions(+), 58 deletions(-) diff --git a/src/core/server/http_resources/get_apm_config.ts b/src/core/server/http_resources/get_apm_config.ts index 3e7be65f96652..a851bd3c43d0b 100644 --- a/src/core/server/http_resources/get_apm_config.ts +++ b/src/core/server/http_resources/get_apm_config.ts @@ -11,7 +11,7 @@ import { getConfiguration, shouldInstrumentClient } from '@kbn/apm-config-loader export const getApmConfig = (requestPath: string) => { const baseConfig = getConfiguration('kibana-frontend'); - if (!shouldInstrumentClient(baseConfig)) { + if (true || !shouldInstrumentClient(baseConfig)) { return null; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index d1732ce6eba99..b39e9c9f332c6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -25,6 +25,7 @@ import { selectDatasourceStates, } from '../../state_management'; import { initializeDatasources } from './state_helpers'; +import { getSuggestions } from './suggestion_helpers'; interface DataPanelWrapperProps { datasourceMap: DatasourceMap; @@ -83,6 +84,16 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { dragDropContext: useContext(DragContext), state: activeDatasourceId ? datasourceStates[activeDatasourceId].state : null, setState: setDatasourceState, + setStateAndForceApply: (updater) => { + dispatchLens( + updateDatasourceState({ + updater, + datasourceId: activeDatasourceId!, + clearStagedPreview: true, + forceApply: true, + }) + ); + }, core: props.core, showNoDataPopover: props.showNoDataPopover, dropOntoWorkspace: props.dropOntoWorkspace, diff --git a/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx index 406b881422f1e..368751e218613 100644 --- a/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx @@ -29,6 +29,7 @@ import { EuiCodeEditor, EuiAccordion, EuiPanel, + EuiCheckbox, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DataPublicPluginStart } from 'src/plugins/data/public'; @@ -221,6 +222,7 @@ export function EsSQLDataPanel({ export function EsSQLHorizontalDataPanel({ setState, + setStateAndForceApply, state, dragDropContext, core, @@ -230,6 +232,7 @@ export function EsSQLHorizontalDataPanel({ dateRange, expressions, }: Props) { + const [autoMap, setAutoMap] = useState(false); const [localState, setLocalState] = useState(state); useEffect(() => { @@ -291,51 +294,73 @@ export function EsSQLHorizontalDataPanel({
))} - {state !== localState && ( - - { - try { - const responses = await Promise.all( - Object.entries(localState.layers).map(([id, layer]) => { - const ast = { - type: 'expression', - chain: [ - buildExpressionFunction('essql', { - query: layer.query, - }).toAst(), - ], - }; - return expressions.run(ast, null).toPromise(); - }) - ); - const cachedFieldList: Record< - string, - { fields: Array<{ name: string; type: string }>; singleRow: boolean } - > = {}; - responses.forEach((response, index) => { - const layerId = Object.keys(localState.layers)[index]; - // @ts-expect-error this is hacky, should probably run expression instead - const { rows, columns } = response.result; - // todo hack some logic in for dates - cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; - }); - setState({ - ...localState, - cachedFieldList, - }); - } catch (e) { - core.notifications.toasts.addError(e, { - title: 'Request failed', - toastMessage: e.body?.message, - }); - } - }} - > - Apply changes - - - )} + + + + setAutoMap(!autoMap)} + /> + + {state !== localState && ( + + { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + const ast = { + type: 'expression', + chain: [ + buildExpressionFunction('essql', { + query: layer.query, + }).toAst(), + ], + }; + return expressions.run(ast, null).toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = response.result; + // todo hack some logic in for dates + cachedFieldList[layerId] = { + fields: columns, + singleRow: rows.length === 1, + }; + }); + if (autoMap) { + setStateAndForceApply({ + ...localState, + cachedFieldList, + }); + } else { + setState({ + ...localState, + cachedFieldList, + }); + } + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }} + > + Apply changes + + + )} + +
); diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index a231286d96a4f..00febb02dd835 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -337,8 +337,8 @@ export class LensPlugin { formatFactory, }; this.indexpatternDatasource.setup(core, dependencies); - this.esdslDatasource.setup(core, dependencies); - this.essqlDatasource.setup(core, dependencies); + // this.esdslDatasource.setup(core, dependencies); + // this.essqlDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index f5e53de44b5f1..34a7cfe68ffc8 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -23,6 +23,7 @@ import { Suggestion, } from '../editor_frame_service/editor_frame/suggestion_helpers'; import { FramePublicAPI, LensEditContextMapping, LensEditEvent } from '../types'; +import { getSuggestions } from '../editor_frame_service/editor_frame/suggestion_helpers'; export const initialState: LensAppState = { persistedDoc: undefined, @@ -94,6 +95,7 @@ export const updateDatasourceState = createAction<{ updater: unknown | ((prevState: unknown) => unknown); datasourceId: string; clearStagedPreview?: boolean; + forceApply?: boolean; }>('lens/updateDatasourceState'); export const updateVisualizationState = createAction<{ visualizationId: string; @@ -270,21 +272,40 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { updater: unknown | ((prevState: unknown) => unknown); datasourceId: string; clearStagedPreview?: boolean; + forceApply?: boolean; }; } ) => { + const newDatasourceState = + typeof payload.updater === 'function' + ? payload.updater(current(state).datasourceStates[payload.datasourceId].state) + : payload.updater; + const newStateMap = { + ...state.datasourceStates, + [payload.datasourceId]: { + state: newDatasourceState, + isLoading: false, + }, + }; + const activeVisualization = visualizationMap[state.visualization.activeId!]; + const visState = state.visualization; + if (payload.forceApply) { + const suggestions = getSuggestions({ + datasourceMap, + datasourceStates: newStateMap, + visualizationMap, + activeVisualization, + visualizationState: visState.state, + }); + if (suggestions.length > 0) { + visState.activeId = suggestions[0].visualizationId; + visState.state = suggestions[0].visualizationState; + } + } return { ...state, - datasourceStates: { - ...state.datasourceStates, - [payload.datasourceId]: { - state: - typeof payload.updater === 'function' - ? payload.updater(current(state).datasourceStates[payload.datasourceId].state) - : payload.updater, - isLoading: false, - }, - }, + visualization: visState, + datasourceStates: newStateMap, stagedPreview: payload.clearStagedPreview ? undefined : state.stagedPreview, }; }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 1c22aebeb71ae..3ae6e35abc69f 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -309,6 +309,7 @@ export interface DatasourceDataPanelProps { state: T; dragDropContext: DragContextState; setState: StateSetter; + setStateAndForceApply: StateSetter; showNoDataPopover: () => void; core: Pick; query: Query; From 670f46dd0073d4213d1fd14b7c6e86027e4d98c7 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 19 Nov 2021 13:09:40 +0100 Subject: [PATCH 05/15] auto map --- .../components/columns.tsx | 2 +- .../datatable_visualization/visualization.tsx | 6 +- .../public/essql_datasource/datapanel.tsx | 6 +- .../lens/public/essql_datasource/essql.tsx | 85 +++++++++++++------ .../lens/public/essql_datasource/types.ts | 1 + .../heatmap_visualization/suggestions.ts | 4 +- x-pack/plugins/lens/public/plugin.ts | 4 +- .../public/state_management/lens_slice.ts | 24 +++++- x-pack/plugins/lens/public/types.ts | 1 + .../public/xy_visualization/xy_suggestions.ts | 9 +- 10 files changed, 102 insertions(+), 40 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx index a8ba6d553b738..8a91dc96f3436 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -41,7 +41,7 @@ export const createGridColumns = ( const columnsReverseLookup = table.columns.reduce< Record >((memo, { id, name, meta }, i) => { - memo[id] = { name, index: i, meta }; + memo[id] = { name: name ?? id, index: i, meta }; return memo; }, {}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index a953da4c380f0..8da86a997ddc8 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -97,11 +97,7 @@ export const getDatatableVisualization = ({ }: SuggestionRequest): Array< VisualizationSuggestion > { - if ( - keptLayerIds.length > 1 || - (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || - (state && table.changeType === 'unchanged') - ) { + if (keptLayerIds.length > 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0])) { return []; } const oldColumnSettings: Record = {}; diff --git a/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx index 368751e218613..12b9c6f501540 100644 --- a/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx @@ -301,7 +301,11 @@ export function EsSQLHorizontalDataPanel({ id={'myId'} label="Auto map columns" checked={autoMap} - onChange={(e) => setAutoMap(!autoMap)} + onChange={(e) => { + setAutoMap(!autoMap); + setLocalState({ ...localState, autoMap: !autoMap }); + setState({ ...state, autoMap: !autoMap }); + }} /> {state !== localState && ( diff --git a/x-pack/plugins/lens/public/essql_datasource/essql.tsx b/x-pack/plugins/lens/public/essql_datasource/essql.tsx index 8cd9a6e96ba37..117ebf680a320 100644 --- a/x-pack/plugins/lens/public/essql_datasource/essql.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/essql.tsx @@ -128,7 +128,10 @@ export function getEsSQLDatasource({ ...state.layers, [newLayerId]: removedLayer ? removedLayer.layer - : blankLayer(state.indexPatternRefs[0].id), + : blankLayer( + JSON.parse(localStorage.getItem('lens-settings') || '{}').indexPatternId || + state.indexPatternRefs[0].id + ), }, removedLayers: newRemovedList, }; @@ -332,6 +335,7 @@ export function getEsSQLDatasource({ dataType: overwrite || (field?.meta?.type as DataType), label: field?.name, isBucketed: false, + noBucketInfo: true, }; } return null; @@ -352,29 +356,62 @@ export function getEsSQLDatasource({ [id]: state.layers[id], }, }; - return { - state: reducedState, - table: { - changeType: 'unchanged', - isMultiRow: !state.cachedFieldList[id].singleRow, - layerId: id, - columns: layer.columns.map((column) => { - const field = state.cachedFieldList[id].fields.find( - (f) => f.name === column.fieldName - )!; - const operation = { - dataType: field?.type as DataType, - label: field?.name, - isBucketed: false, - }; - return { - columnId: column.columnId, - operation, - }; - }), - }, - keptLayerIds: [id], - }; + return !state.autoMap + ? { + state: reducedState, + table: { + changeType: 'unchanged', + isMultiRow: !state.cachedFieldList[id].singleRow, + layerId: id, + columns: layer.columns.map((column) => { + const field = state.cachedFieldList[id].fields.find( + (f) => f.name === column.fieldName + )!; + const operation = { + dataType: field?.meta.type as DataType, + label: field?.name, + isBucketed: false, + noBucketInfo: true, + }; + return { + columnId: column.columnId, + operation, + }; + }), + }, + keptLayerIds: [id], + } + : { + state: { + ...reducedState, + layers: { + [id]: { + ...state.layers[id], + columns: state.cachedFieldList[id].fields.map((f) => ({ + columnId: f.name, + fieldName: f.name, + })), + }, + }, + }, + table: { + changeType: 'unchanged', + isMultiRow: !state.cachedFieldList[id].singleRow, + layerId: id, + columns: state.cachedFieldList[id].fields.map((f) => { + return { + columnId: f.name, + operation: { + dataType: f.meta.type, + label: f.name, + isBucketed: false, + noBucketInfo: true, + }, + }; + }), + }, + keptLayerIds: [id], + }; }); }, }; diff --git a/x-pack/plugins/lens/public/essql_datasource/types.ts b/x-pack/plugins/lens/public/essql_datasource/types.ts index 5e11646ea5cae..e90f543d2b8b2 100644 --- a/x-pack/plugins/lens/public/essql_datasource/types.ts +++ b/x-pack/plugins/lens/public/essql_datasource/types.ts @@ -19,6 +19,7 @@ export interface EsSQLPersistedState { export type EsSQLPrivateState = EsSQLPersistedState & { indexPatternRefs: IndexPatternRef[]; + autoMap?: boolean; cachedFieldList: Record< string, { fields: Array<{ name: string; type: string }>; singleRow: boolean } diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts index ebe93419edce6..eecfde649f2f2 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts @@ -41,7 +41,9 @@ export const getSuggestions: Visualization['getSugges */ let score = 0; - const [groups, metrics] = partition(table.columns, (col) => col.operation.isBucketed); + const [groups, metrics] = partition(table.columns, (col) => + col.operation.noBucketInfo ? col.operation.dataType !== 'number' : col.operation.isBucketed + ); if (groups.length >= 3) { return []; diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 00febb02dd835..a231286d96a4f 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -337,8 +337,8 @@ export class LensPlugin { formatFactory, }; this.indexpatternDatasource.setup(core, dependencies); - // this.esdslDatasource.setup(core, dependencies); - // this.essqlDatasource.setup(core, dependencies); + this.esdslDatasource.setup(core, dependencies); + this.essqlDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 34a7cfe68ffc8..f53c5823859bb 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -288,7 +288,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { }, }; const activeVisualization = visualizationMap[state.visualization.activeId!]; - const visState = state.visualization; + const visState = { ...state.visualization }; if (payload.forceApply) { const suggestions = getSuggestions({ datasourceMap, @@ -297,9 +297,25 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { activeVisualization, visualizationState: visState.state, }); - if (suggestions.length > 0) { - visState.activeId = suggestions[0].visualizationId; - visState.state = suggestions[0].visualizationState; + const sameTypeVisAndSubVis = suggestions.find( + (s) => + s.visualizationId === state.visualization.activeId && + visualizationMap[s.visualizationId].getVisualizationTypeId(s.visualizationState) === + visualizationMap[s.visualizationId].getVisualizationTypeId(state.visualization.state) + ); + const sameTypeVis = suggestions.find( + (s) => s.visualizationId === state.visualization.activeId + ); + const mainSuggestion = sameTypeVisAndSubVis ?? sameTypeVis ?? suggestions[0]; + if (mainSuggestion) { + visState.activeId = mainSuggestion.visualizationId; + visState.state = mainSuggestion.visualizationState; + if (mainSuggestion.datasourceState) { + newStateMap[payload.datasourceId] = { + isLoading: false, + state: mainSuggestion.datasourceState!, + }; + } } } return { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 3ae6e35abc69f..f97acab5bf1e5 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -423,6 +423,7 @@ export interface OperationMetadata { // A bucketed operation is grouped by duplicate values, otherwise each row is // treated as unique isBucketed: boolean; + noBucketInfo?: boolean; /** * ordinal: Each name is a unique value, but the names are in sorted order, like "Top values" * interval: Histogram data, like date or number histograms diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 2e275c455a4d0..dcc9e45ee5cb7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -98,7 +98,10 @@ function getSuggestionForColumns( seriesType?: SeriesType, mainPalette?: PaletteOutput ): VisualizationSuggestion | Array> | undefined { - const [buckets, values] = partition(table.columns, (col) => col.operation.isBucketed); + const noBucketInfo = table.columns.some((c) => c.operation.noBucketInfo); + const [buckets, values] = partition(table.columns, (col) => + noBucketInfo ? col.operation.dataType !== 'number' : col.operation.isBucketed + ); if (buckets.length === 1 || buckets.length === 2) { const [x, splitBy] = getBucketMappings(table, currentState); @@ -155,7 +158,9 @@ function getBucketMappings(table: TableSuggestion, currentState?: State) { const currentLayer = currentState && currentState.layers.find(({ layerId }) => layerId === table.layerId); - const buckets = table.columns.filter((col) => col.operation.isBucketed); + const buckets = table.columns.filter((col) => + col.operation.noBucketInfo ? col.operation.dataType !== 'number' : col.operation.isBucketed + ); // reverse the buckets before prioritization to always use the most inner // bucket of the highest-prioritized group as x value (don't use nested // buckets as split series) From edd1fd585fe1f262df7f624b66b171bdef17e6c7 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 22 Nov 2021 09:54:47 +0100 Subject: [PATCH 06/15] do drag and drop --- .../public/essql_datasource/datapanel.tsx | 461 ++++++++++-------- .../lens/public/essql_datasource/essql.tsx | 35 +- 2 files changed, 291 insertions(+), 205 deletions(-) diff --git a/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx index 12b9c6f501540..0107831656ec5 100644 --- a/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx @@ -40,6 +40,10 @@ import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; import { IndexPattern, EsSQLPrivateState, IndexPatternField, IndexPatternRef } from './types'; import { esRawResponse } from '../../../../../src/plugins/data/common'; import { ChangeIndexPattern } from './change_indexpattern'; +import { FieldButton } from '@kbn/react-field/field_button'; +import { DragDrop, DragDropIdentifier } from '../drag_drop'; +import { LensFieldIcon } from '../indexpattern_datasource/lens_field_icon'; +import { ChildDragDropProvider, DragContextState } from '../drag_drop'; export type Props = DatasourceDataPanelProps & { data: DataPublicPluginStart; @@ -65,6 +69,8 @@ export function EsSQLDataPanel({ setLocalState(state); }, [state]); + const [openPopover, setOpenPopover] = useState(''); + const { layers, removedLayers } = localState; return ( @@ -74,33 +80,100 @@ export function EsSQLDataPanel({ ...core, }} > - - - {Object.entries(layers).map(([id, layer]) => { - const ref = state.indexPatternRefs.find((r) => r.id === layer.index); - return ( - - - {localState.cachedFieldList[id]?.fields.length > 0 && - localState.cachedFieldList[id].fields.map((field) => ( -
- - {field.name} ({field.type}){' '} - - + + + {Object.entries(layers).map(([id, layer]) => { + const ref = state.indexPatternRefs.find((r) => r.id === layer.index); + return ( + + +
    + {localState.cachedFieldList[id]?.fields.length > 0 && + localState.cachedFieldList[id].fields.map((field, index) => ( +
  • + + { + if (openPopover === field.name) { + setOpenPopover(''); + } else { + setOpenPopover(field.name); + } + }} + buttonProps={{ + ['aria-label']: i18n.translate( + 'xpack.lens.indexPattern.fieldStatsButtonAriaLabel', + { + defaultMessage: 'Preview {fieldName}: {fieldType}', + values: { + fieldName: field?.name, + fieldType: field?.meta.type, + }, + } + ), + }} + fieldIcon={} + fieldName={field?.name} + /> + + } + isOpen={openPopover === field.name} + closePopover={() => setOpenPopover('')} + anchorPosition="rightUp" + panelClassName="lnsFieldItem__fieldPanel" + initialFocus=".lnsFieldItem__fieldPanel" + > + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + overwrittenFieldTypes: { + ...(layer.overwrittenFieldTypes || {}), + [field.name]: e.target.value, + }, + }, + }, + }); + }} + /> + +
  • + ))} +
+ +
+ { setLocalState({ ...state, @@ -108,114 +181,92 @@ export function EsSQLDataPanel({ ...state.layers, [id]: { ...layer, - overwrittenFieldTypes: { - ...(layer.overwrittenFieldTypes || {}), - [field.name]: e.target.value, - }, + timeField: e.target.value, }, }, }); }} /> -
- ))} - -
- { - setLocalState({ - ...state, - layers: { - ...state.layers, - [id]: { - ...layer, - timeField: e.target.value, - }, - }, - }); - }} - /> - - - { - setState({ - ...state, - layers: { - ...state.layers, - [id]: { - ...layer, - index: newId, + + + { + setState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + index: newId, + }, }, - }, - }); - }} - /> - - -
-
-
+ }); + }} + /> + +
+
+ +
+
+ ); + })} + {state !== localState && ( + + { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + const ast = { + type: 'expression', + chain: [ + buildExpressionFunction('essql', { + query: layer.query, + }).toAst(), + ], + }; + return expressions.run(ast, null).toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = response.result; + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; + }); + setState({ + ...localState, + cachedFieldList, + }); + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }} + > + Apply changes + - ); - })} - {state !== localState && ( - - { - try { - const responses = await Promise.all( - Object.entries(localState.layers).map(([id, layer]) => { - const ast = { - type: 'expression', - chain: [ - buildExpressionFunction('essql', { - query: layer.query, - }).toAst(), - ], - }; - return expressions.run(ast, null).toPromise(); - }) - ); - const cachedFieldList: Record< - string, - { fields: Array<{ name: string; type: string }>; singleRow: boolean } - > = {}; - responses.forEach((response, index) => { - const layerId = Object.keys(localState.layers)[index]; - // @ts-expect-error this is hacky, should probably run expression instead - const { rows, columns } = response.result; - // todo hack some logic in for dates - cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; - }); - setState({ - ...localState, - cachedFieldList, - }); - } catch (e) { - core.notifications.toasts.addError(e, { - title: 'Request failed', - toastMessage: e.body?.message, - }); - } - }} - > - Apply changes - - - )} -
+ )} + + ); } @@ -241,6 +292,54 @@ export function EsSQLHorizontalDataPanel({ const { layers, removedLayers } = localState; + const onSubmit = async () => { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + const ast = { + type: 'expression', + chain: [ + buildExpressionFunction('essql', { + query: layer.query, + }).toAst(), + ], + }; + return expressions.run(ast, null).toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = response.result; + // todo hack some logic in for dates + cachedFieldList[layerId] = { + fields: columns, + singleRow: rows.length === 1, + }; + }); + if (autoMap) { + setStateAndForceApply({ + ...localState, + cachedFieldList, + }); + } else { + setState({ + ...localState, + cachedFieldList, + }); + } + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }; + return ( - { - setLocalState({ - ...state, - layers: { - ...state.layers, - [id]: { - ...layer, - query: val, - }, - }, - }); +
{ + if ((event.keyCode == 13 || event.which == 13) && event.metaKey) { + onSubmit(); + } }} - /> + > + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + query: val, + }, + }, + }); + }} + /> +
); @@ -304,65 +411,15 @@ export function EsSQLHorizontalDataPanel({ onChange={(e) => { setAutoMap(!autoMap); setLocalState({ ...localState, autoMap: !autoMap }); - setState({ ...state, autoMap: !autoMap }); + setState({ ...localState, autoMap: !autoMap }); }} /> - {state !== localState && ( - - { - try { - const responses = await Promise.all( - Object.entries(localState.layers).map(([id, layer]) => { - const ast = { - type: 'expression', - chain: [ - buildExpressionFunction('essql', { - query: layer.query, - }).toAst(), - ], - }; - return expressions.run(ast, null).toPromise(); - }) - ); - const cachedFieldList: Record< - string, - { fields: Array<{ name: string; type: string }>; singleRow: boolean } - > = {}; - responses.forEach((response, index) => { - const layerId = Object.keys(localState.layers)[index]; - // @ts-expect-error this is hacky, should probably run expression instead - const { rows, columns } = response.result; - // todo hack some logic in for dates - cachedFieldList[layerId] = { - fields: columns, - singleRow: rows.length === 1, - }; - }); - if (autoMap) { - setStateAndForceApply({ - ...localState, - cachedFieldList, - }); - } else { - setState({ - ...localState, - cachedFieldList, - }); - } - } catch (e) { - core.notifications.toasts.addError(e, { - title: 'Request failed', - toastMessage: e.body?.message, - }); - } - }} - > - Apply changes - - - )} + + + Apply changes + + diff --git a/x-pack/plugins/lens/public/essql_datasource/essql.tsx b/x-pack/plugins/lens/public/essql_datasource/essql.tsx index 117ebf680a320..a1d95dc54e4c7 100644 --- a/x-pack/plugins/lens/public/essql_datasource/essql.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/essql.tsx @@ -293,8 +293,33 @@ export function getEsSQLDatasource({ ); }, - canHandleDrop: () => false, - onDrop: () => false, + onDrop: (props) => { + const { droppedItem, dropType } = props; + + if (dropType === 'field_add') { + const currentLayer = props.state.layers[props.layerId]; + const columnExists = currentLayer.columns.some((c) => c.columnId === props.columnId); + props.setState({ + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: columnExists + ? currentLayer.columns.map((c) => + c.columnId !== props.columnId ? c : { ...c, fieldName: droppedItem.field } + ) + : [ + ...props.state.layers[props.layerId].columns, + { columnId: props.columnId, fieldName: droppedItem.field }, + ], + }, + }, + }); + return true; + } + return false; + }, uniqueLabels(state: EsSQLPrivateState) { const layers = state.layers; const columnLabelMap = {} as Record; @@ -311,7 +336,11 @@ export function getEsSQLDatasource({ return columnLabelMap; }, - getDropProps: () => undefined, + getDropProps: (props) => { + if (!props.dragging?.isSqlField) return undefined; + + return { dropTypes: ['field_add'], nextLabel: props.dragging?.field }; + }, getPublicAPI({ state, layerId }: PublicAPIProps) { return { From e7e8308ba861fe853b1eb2c7b08ec6b2c502a620 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 22 Nov 2021 10:55:18 +0100 Subject: [PATCH 07/15] add expression datasource --- .../public/esdsl_datasource/datapanel.tsx | 508 +++--- .../lens/public/esdsl_datasource/esdsl.tsx | 35 +- .../expression_datasource/_datapanel.scss | 67 + .../expression_datasource/_field_item.scss | 87 + .../public/expression_datasource/_index.scss | 4 + .../change_indexpattern.tsx | 118 ++ .../expression_datasource/datapanel.tsx | 420 +++++ .../dimension_panel/_field_select.scss | 7 + .../dimension_panel/_index.scss | 2 + .../dimension_panel/_popover.scss | 38 + .../bucket_nesting_editor.test.tsx | 262 +++ .../dimension_panel/bucket_nesting_editor.tsx | 138 ++ .../dimension_panel/dimension_panel.test.tsx | 1422 +++++++++++++++++ .../dimension_panel/dimension_panel.tsx | 234 +++ .../dimension_panel/field_select.tsx | 183 +++ .../dimension_panel/format_selector.tsx | 136 ++ .../dimension_panel/index.ts | 7 + .../dimension_panel/popover_editor.tsx | 374 +++++ .../expression_datasource/expressionbased.tsx | 457 ++++++ .../expression_datasource/field_item.test.tsx | 237 +++ .../expression_datasource/field_item.tsx | 538 +++++++ .../public/expression_datasource/index.ts | 47 + .../indexpattern.test.ts | 557 +++++++ .../expression_datasource/layerpanel.test.tsx | 228 +++ .../expression_datasource/layerpanel.tsx | 39 + .../lens_field_icon.test.tsx | 24 + .../expression_datasource/lens_field_icon.tsx | 20 + .../public/expression_datasource/mocks.ts | 131 ++ .../pure_helpers.test.ts | 15 + .../expression_datasource/pure_helpers.ts | 15 + .../rename_columns.test.ts | 221 +++ .../expression_datasource/rename_columns.ts | 101 ++ .../state_helpers.test.ts | 732 +++++++++ .../expression_datasource/state_helpers.ts | 176 ++ .../expression_datasource/to_expression.ts | 55 + .../public/expression_datasource/types.ts | 36 + .../public/expression_datasource/utils.ts | 43 + x-pack/plugins/lens/public/plugin.ts | 4 + 38 files changed, 7491 insertions(+), 227 deletions(-) create mode 100644 x-pack/plugins/lens/public/expression_datasource/_datapanel.scss create mode 100644 x-pack/plugins/lens/public/expression_datasource/_field_item.scss create mode 100644 x-pack/plugins/lens/public/expression_datasource/_index.scss create mode 100644 x-pack/plugins/lens/public/expression_datasource/change_indexpattern.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/datapanel.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/_field_select.scss create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/_index.scss create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/_popover.scss create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/bucket_nesting_editor.test.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/bucket_nesting_editor.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/dimension_panel.test.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/dimension_panel.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/field_select.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/format_selector.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/index.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/dimension_panel/popover_editor.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/field_item.test.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/field_item.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/index.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/indexpattern.test.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/layerpanel.test.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/layerpanel.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/lens_field_icon.test.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/lens_field_icon.tsx create mode 100644 x-pack/plugins/lens/public/expression_datasource/mocks.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/pure_helpers.test.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/pure_helpers.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/rename_columns.test.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/rename_columns.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/state_helpers.test.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/state_helpers.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/to_expression.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/types.ts create mode 100644 x-pack/plugins/lens/public/expression_datasource/utils.ts diff --git a/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx index 19d622b3d8aa3..2cc6a2eabc175 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx @@ -37,6 +37,10 @@ import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; import { IndexPattern, EsDSLPrivateState, IndexPatternField, IndexPatternRef } from './types'; import { esRawResponse } from '../../../../../src/plugins/data/common'; import { ChangeIndexPattern } from './change_indexpattern'; +import { FieldButton } from '@kbn/react-field/field_button'; +import { DragDrop, DragDropIdentifier } from '../drag_drop'; +import { LensFieldIcon } from '../indexpattern_datasource/lens_field_icon'; +import { ChildDragDropProvider, DragContextState } from '../drag_drop'; export type Props = DatasourceDataPanelProps & { data: DataPublicPluginStart; @@ -59,6 +63,7 @@ export function EsDSLDataPanel({ useEffect(() => { setLocalState(state); }, [state]); + const [openPopover, setOpenPopover] = useState(''); const { layers, removedLayers } = localState; @@ -69,118 +74,163 @@ export function EsDSLDataPanel({ ...core, }} > - - - {Object.entries(layers).map(([id, layer]) => ( - - - {localState.cachedFieldList[id]?.fields.length > 0 && - localState.cachedFieldList[id].fields.map((field) => ( -
- - {field.name} ({field.type}){' '} - - { - setLocalState({ - ...state, - layers: { - ...state.layers, - [id]: { - ...layer, - overwrittenFieldTypes: { - ...(layer.overwrittenFieldTypes || {}), - [field.name]: e.target.value, - }, - }, + + + + {Object.entries(layers).map(([id, layer]) => ( + + +
    + {localState.cachedFieldList[id]?.fields.length > 0 && + localState.cachedFieldList[id].fields.map((field, index) => ( +
  • + + { + if (openPopover === field.name) { + setOpenPopover(''); + } else { + setOpenPopover(field.name); + } + }} + buttonProps={{ + ['aria-label']: i18n.translate( + 'xpack.lens.indexPattern.fieldStatsButtonAriaLabel', + { + defaultMessage: 'Preview {fieldName}: {fieldType}', + values: { + fieldName: field?.name, + fieldType: field?.meta.type, + }, + } + ), + }} + fieldIcon={} + fieldName={field?.name} + /> + + } + isOpen={openPopover === field.name} + closePopover={() => setOpenPopover('')} + anchorPosition="rightUp" + panelClassName="lnsFieldItem__fieldPanel" + initialFocus=".lnsFieldItem__fieldPanel" + > + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + overwrittenFieldTypes: { + ...(layer.overwrittenFieldTypes || {}), + [field.name]: e.target.value, + }, + }, + }, + }); + }} + /> + +
  • + ))} +
+ + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + timeField: e.target.value, }, - }); - }} - /> -
- ))} - - { - setLocalState({ - ...state, - layers: { - ...state.layers, - [id]: { - ...layer, - timeField: e.target.value, }, - }, + }); + }} + /> + +
+
+ ))} + {state !== localState && ( + + { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + return data.search + .search({ + params: { + size: 0, + index: layer.index, + body: JSON.parse(layer.query), + }, + }) + .toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = esRawResponse.to!.datatable({ + body: response.rawResponse, + }); + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; }); - }} - /> - - - - ))} - {state !== localState && ( - - { - try { - const responses = await Promise.all( - Object.entries(localState.layers).map(([id, layer]) => { - return data.search - .search({ - params: { - size: 0, - index: layer.index, - body: JSON.parse(layer.query), - }, - }) - .toPromise(); - }) - ); - const cachedFieldList: Record< - string, - { fields: Array<{ name: string; type: string }>; singleRow: boolean } - > = {}; - responses.forEach((response, index) => { - const layerId = Object.keys(localState.layers)[index]; - // @ts-expect-error this is hacky, should probably run expression instead - const { rows, columns } = esRawResponse.to!.datatable({ - body: response.rawResponse, + setState({ + ...localState, + cachedFieldList, }); - // todo hack some logic in for dates - cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; - }); - setState({ - ...localState, - cachedFieldList, - }); - } catch (e) { - core.notifications.toasts.addError(e, { - title: 'Request failed', - toastMessage: e.body?.message, - }); - } - }} - > - Apply changes - - - )} -
+ } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }} + > + Apply changes + + + )} + +
); } @@ -210,136 +260,146 @@ export function EsDSLHorizontalDataPanel({ ...core, }} > - - - {Object.entries(layers).map(([id, layer]) => { - const ref = state.indexPatternRefs.find((r) => r.id === layer.index); - return ( + + + + {Object.entries(layers).map(([id, layer]) => { + const ref = state.indexPatternRefs.find((r) => r.id === layer.index); + return ( + + + { + setState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + index: newId, + }, + }, + }); + }} + /> + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + query: val, + }, + }, + }); + }} + /> + + + ); + })} + {Object.entries(removedLayers).map(([id, { layer }]) => ( - { - setState({ - ...state, - layers: { - ...state.layers, - [id]: { - ...layer, - index: newId, - }, - }, - }); - }} - /> + Currently detached. Add new layers to your visualization to use. + { - setLocalState({ - ...state, - layers: { - ...state.layers, - [id]: { - ...layer, - query: val, - }, - }, - }); - }} + readOnly /> - ); - })} - {Object.entries(removedLayers).map(([id, { layer }]) => ( - - - Currently detached. Add new layers to your visualization to use. - - - - - ))} - {state !== localState && ( + ))} - { - try { - const responses = await Promise.all( - Object.entries(localState.layers).map(([id, layer]) => { - return data.search - .search({ - params: { - size: 0, - index: [ - state.indexPatternRefs.find((r) => r.id === layer.index)!.title, - ], - body: JSON.parse(layer.query), - }, - }) - .toPromise(); - }) - ); - const cachedFieldList: Record< - string, - { fields: Array<{ name: string; type: string }>; singleRow: boolean } - > = {}; - responses.forEach((response, index) => { - const layerId = Object.keys(localState.layers)[index]; - // @ts-expect-error this is hacky, should probably run expression instead - const { rows, columns } = esRawResponse.to!.datatable({ - body: response.rawResponse, - }); - columns.forEach((col) => { - const testVal = rows[0][col.id]; - if (typeof testVal === 'number' && Math.log10(testVal) > 11) { - // col.meta.type = 'date'; - // col.meta.params = { id: 'date' }; - localState.layers[layerId].overwrittenFieldTypes = { - ...(localState.layers[layerId].overwrittenFieldTypes || {}), - [col.id]: 'date', - }; + + + + { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + return data.search + .search({ + params: { + size: 0, + index: [ + state.indexPatternRefs.find((r) => r.id === layer.index)!.title, + ], + body: JSON.parse(layer.query), + }, + }) + .toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = esRawResponse.to!.datatable({ + body: response.rawResponse, + }); + columns.forEach((col) => { + const testVal = rows[0][col.id]; + if (typeof testVal === 'number' && Math.log10(testVal) > 11) { + // col.meta.type = 'date'; + // col.meta.params = { id: 'date' }; + localState.layers[layerId].overwrittenFieldTypes = { + ...(localState.layers[layerId].overwrittenFieldTypes || {}), + [col.id]: 'date', + }; + } + }); + // todo hack some logic in for dates + cachedFieldList[layerId] = { + fields: columns, + singleRow: rows.length === 1, + }; + }); + setState({ + ...localState, + cachedFieldList, + }); + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); } - }); - // todo hack some logic in for dates - cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; - }); - setState({ - ...localState, - cachedFieldList, - }); - } catch (e) { - core.notifications.toasts.addError(e, { - title: 'Request failed', - toastMessage: e.body?.message, - }); - } - }} - > - Apply changes - + }} + > + Apply changes + + + + - )} - + + ); } diff --git a/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx index ae25647cb8af4..e42d1ad251df0 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx +++ b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx @@ -287,8 +287,33 @@ export function getEsDSLDatasource({ ); }, - canHandleDrop: () => false, - onDrop: () => false, + onDrop: (props) => { + const { droppedItem, dropType } = props; + + if (dropType === 'field_add') { + const currentLayer = props.state.layers[props.layerId]; + const columnExists = currentLayer.columns.some((c) => c.columnId === props.columnId); + props.setState({ + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: columnExists + ? currentLayer.columns.map((c) => + c.columnId !== props.columnId ? c : { ...c, fieldName: droppedItem.field } + ) + : [ + ...props.state.layers[props.layerId].columns, + { columnId: props.columnId, fieldName: droppedItem.field }, + ], + }, + }, + }); + return true; + } + return false; + }, uniqueLabels(state: EsDSLPrivateState) { const layers = state.layers; const columnLabelMap = {} as Record; @@ -305,7 +330,11 @@ export function getEsDSLDatasource({ return columnLabelMap; }, - getDropProps: () => undefined, + getDropProps: (props) => { + if (!props.dragging?.isSqlField) return undefined; + + return { dropTypes: ['field_add'], nextLabel: props.dragging?.field }; + }, getPublicAPI({ state, layerId }: PublicAPIProps) { return { diff --git a/x-pack/plugins/lens/public/expression_datasource/_datapanel.scss b/x-pack/plugins/lens/public/expression_datasource/_datapanel.scss new file mode 100644 index 0000000000000..77d4b41a0413c --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/_datapanel.scss @@ -0,0 +1,67 @@ +.lnsInnerIndexPatternDataPanel { + width: 100%; + height: 100%; + padding: $euiSize $euiSize 0; +} + +.lnsInnerIndexPatternDataPanel__header { + display: flex; + align-items: center; + height: $euiSize * 3; + margin-top: -$euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__triggerButton { + @include euiTitle('xs'); + line-height: $euiSizeXXL; +} + +.lnsInnerIndexPatternDataPanel__filterWrapper { + flex-grow: 0; +} + +/** + * 1. Don't cut off the shadow of the field items + */ + +.lnsInnerIndexPatternDataPanel__listWrapper { + @include euiOverflowShadow; + @include euiScrollBar; + margin-left: -$euiSize; /* 1 */ + position: relative; + flex-grow: 1; + overflow: auto; +} + +.lnsInnerIndexPatternDataPanel__list { + padding-top: $euiSizeS; + position: absolute; + top: 0; + left: $euiSize; /* 1 */ + right: $euiSizeXS; /* 1 */ +} + +.lnsInnerIndexPatternDataPanel__filterButton { + width: 100%; + color: $euiColorPrimary; + padding-left: $euiSizeS; + padding-right: $euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__textField { + @include euiFormControlLayoutPadding(1, 'right'); + @include euiFormControlLayoutPadding(1, 'left'); +} + +.lnsInnerIndexPatternDataPanel__filterType { + padding: $euiSizeS; +} + +.lnsInnerIndexPatternDataPanel__filterTypeInner { + display: flex; + align-items: center; + + .lnsFieldListPanel__fieldIcon { + margin-right: $euiSizeS; + } +} diff --git a/x-pack/plugins/lens/public/expression_datasource/_field_item.scss b/x-pack/plugins/lens/public/expression_datasource/_field_item.scss new file mode 100644 index 0000000000000..41919b900c71f --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/_field_item.scss @@ -0,0 +1,87 @@ +.lnsFieldItem { + @include euiFontSizeS; + background: lightOrDarkTheme($euiColorEmptyShade, $euiColorLightestShade); + border-radius: $euiBorderRadius; + margin-bottom: $euiSizeXS; +} + +.lnsFieldItem__popoverAnchor:hover, +.lnsFieldItem__popoverAnchor:focus, +.lnsFieldItem__popoverAnchor:focus-within { + @include euiBottomShadowMedium; + border-radius: $euiBorderRadius; + z-index: 2; +} + +.lnsFieldItem--missing { + background: lightOrDarkTheme(transparentize($euiColorMediumShade, .9), $euiColorEmptyShade); + color: $euiColorDarkShade; +} + +.lnsFieldItem__info { + border-radius: $euiBorderRadius - 1px; + padding: $euiSizeS; + display: flex; + align-items: flex-start; + transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance, + background-color $euiAnimSpeedFast $euiAnimSlightResistance; // sass-lint:disable-line indentation + + .lnsFieldItem__name { + margin-left: $euiSizeS; + flex-grow: 1; + } + + .lnsFieldListPanel__fieldIcon, + .lnsFieldItem__infoIcon { + flex-shrink: 0; + } + + .lnsFieldListPanel__fieldIcon { + margin-top: $euiSizeXS / 2; + margin-right: $euiSizeXS / 2; + } + + .lnsFieldItem__infoIcon { + visibility: hidden; + } + + &:hover, + &:focus { + cursor: grab; + + .lnsFieldItem__infoIcon { + visibility: visible; + } + } +} + +.lnsFieldItem__info-isOpen { + @include euiFocusRing; +} + +.lnsFieldItem__topValue { + margin-bottom: $euiSizeS; + + &:last-of-type { + margin-bottom: 0; + } +} + +.lnsFieldItem__topValueProgress { + background-color: $euiColorLightestShade; + + // sass-lint:disable-block no-vendor-prefixes + &::-webkit-progress-bar { + background-color: $euiColorLightestShade; + } +} + +.lnsFieldItem__fieldPopoverPanel { + min-width: 260px; + max-width: 300px; +} + +.lnsFieldItem__popoverButtonGroup { + // Enforce lowercase for buttons or else some browsers inherit all caps from popover title + text-transform: none; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/_index.scss b/x-pack/plugins/lens/public/expression_datasource/_index.scss new file mode 100644 index 0000000000000..e5d8b408e33e5 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/_index.scss @@ -0,0 +1,4 @@ +@import 'datapanel'; +@import 'field_item'; + +@import 'dimension_panel/index'; diff --git a/x-pack/plugins/lens/public/expression_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/expression_datasource/change_indexpattern.tsx new file mode 100644 index 0000000000000..d5fabb9d7ef80 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/change_indexpattern.tsx @@ -0,0 +1,118 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { EuiPopover, EuiPopoverTitle, EuiSelectable, EuiSelectableProps } from '@elastic/eui'; +import { IndexPatternRef } from './types'; +import { trackUiEvent } from '../lens_ui_telemetry'; +import { ToolbarButton, ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; + +export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & { + label: string; + title?: string; +}; + +export function ChangeIndexPattern({ + indexPatternRefs, + isMissingCurrent, + indexPatternId, + onChangeIndexPattern, + trigger, + selectableProps, +}: { + trigger: ChangeIndexPatternTriggerProps; + indexPatternRefs: IndexPatternRef[]; + isMissingCurrent?: boolean; + onChangeIndexPattern: (newId: string) => void; + indexPatternId?: string; + selectableProps?: EuiSelectableProps; +}) { + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + + // be careful to only add color with a value, otherwise it will fallbacks to "primary" + const colorProp = isMissingCurrent + ? { + color: 'danger' as const, + } + : {}; + + const createTrigger = function () { + const { label, title, ...rest } = trigger; + return ( + setPopoverIsOpen(!isPopoverOpen)} + fullWidth + {...colorProp} + {...rest} + > + {label} + + ); + }; + + return ( + <> + setPopoverIsOpen(false)} + display="block" + panelPaddingSize="s" + ownFocus + > +
+ + {i18n.translate('xpack.lens.indexPattern.changeDataViewTitle', { + defaultMessage: 'Data view', + })} + + + {...selectableProps} + searchable + singleSelection="always" + options={indexPatternRefs.map(({ title, id }) => ({ + key: id, + label: title, + value: id, + checked: id === indexPatternId ? 'on' : undefined, + }))} + onChange={(choices) => { + const choice = choices.find(({ checked }) => checked) as unknown as { + value: string; + }; + trackUiEvent('indexpattern_changed'); + onChangeIndexPattern(choice.value); + setPopoverIsOpen(false); + }} + searchProps={{ + compressed: true, + ...(selectableProps ? selectableProps.searchProps : undefined), + }} + > + {(list, search) => ( + <> + {search} + {list} + + )} + +
+
+ + ); +} diff --git a/x-pack/plugins/lens/public/expression_datasource/datapanel.tsx b/x-pack/plugins/lens/public/expression_datasource/datapanel.tsx new file mode 100644 index 0000000000000..71840c2bed2ea --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/datapanel.tsx @@ -0,0 +1,420 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniq, indexBy } from 'lodash'; +import React, { useState, useEffect, memo, useCallback } from 'react'; +import { + // @ts-ignore + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiContextMenuPanelProps, + EuiPopover, + EuiPopoverTitle, + EuiPopoverFooter, + EuiCallOut, + EuiFormControlLayout, + EuiSwitch, + EuiFacetButton, + EuiIcon, + EuiSpacer, + EuiFormLabel, + EuiButton, + EuiCodeEditor, + EuiAccordion, + EuiPanel, + EuiCheckbox, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { EuiFieldText, EuiSelect } from '@elastic/eui'; +import { ExpressionsStart } from 'src/plugins/expressions/public'; +import { buildExpressionFunction } from '../../../../../src/plugins/expressions/public'; +import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; +import { IndexPattern, ExpressionBasedPrivateState, IndexPatternField, IndexPatternRef } from './types'; +import { esRawResponse } from '../../../../../src/plugins/data/common'; +import { ChangeIndexPattern } from './change_indexpattern'; +import { FieldButton } from '@kbn/react-field/field_button'; +import { DragDrop, DragDropIdentifier } from '../drag_drop'; +import { LensFieldIcon } from '../indexpattern_datasource/lens_field_icon'; +import { ChildDragDropProvider, DragContextState } from '../drag_drop'; + +export type Props = DatasourceDataPanelProps & { + data: DataPublicPluginStart; + expressions: ExpressionsStart; +}; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { flatten } from './flatten'; + +export function ExpressionBasedDataPanel({ + setState, + state, + dragDropContext, + core, + data, + query, + filters, + dateRange, + expressions, +}: Props) { + const [localState, setLocalState] = useState(state); + + useEffect(() => { + setLocalState(state); + }, [state]); + + const [openPopover, setOpenPopover] = useState(''); + + const { layers, removedLayers } = localState; + + return ( + + + + + {Object.entries(layers).map(([id, layer]) => { + const ref = state.indexPatternRefs.find((r) => r.id === layer.index); + return ( + + +
    + {localState.cachedFieldList[id]?.fields.length > 0 && + localState.cachedFieldList[id].fields.map((field, index) => ( +
  • + + { + if (openPopover === field.name) { + setOpenPopover(''); + } else { + setOpenPopover(field.name); + } + }} + buttonProps={{ + ['aria-label']: i18n.translate( + 'xpack.lens.indexPattern.fieldStatsButtonAriaLabel', + { + defaultMessage: 'Preview {fieldName}: {fieldType}', + values: { + fieldName: field?.name, + fieldType: field?.meta.type, + }, + } + ), + }} + fieldIcon={} + fieldName={field?.name} + /> + + } + isOpen={openPopover === field.name} + closePopover={() => setOpenPopover('')} + anchorPosition="rightUp" + panelClassName="lnsFieldItem__fieldPanel" + initialFocus=".lnsFieldItem__fieldPanel" + > + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + overwrittenFieldTypes: { + ...(layer.overwrittenFieldTypes || {}), + [field.name]: e.target.value, + }, + }, + }, + }); + }} + /> + +
  • + ))} +
+ +
+ { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + timeField: e.target.value, + }, + }, + }); + }} + /> + + + { + setState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + index: newId, + }, + }, + }); + }} + /> + + +
+
+
+
+ ); + })} + {state !== localState && ( + + { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + const ast = { + type: 'expression', + chain: [ + buildExpressionFunction('expressionbased', { + query: layer.query, + }).toAst(), + ], + }; + return expressions.run(ast, null).toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = response.result; + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; + }); + setState({ + ...localState, + cachedFieldList, + }); + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }} + > + Apply changes + + + )} +
+
+
+ ); +} + +export function ExpressionBasedHorizontalDataPanel({ + setState, + setStateAndForceApply, + state, + dragDropContext, + core, + data, + query, + filters, + dateRange, + expressions, +}: Props) { + const [autoMap, setAutoMap] = useState(false); + const [localState, setLocalState] = useState(state); + + useEffect(() => { + setLocalState(state); + }, [state]); + + const { layers, removedLayers } = localState; + + const onSubmit = async () => { + try { + const responses = await Promise.all( + Object.entries(localState.layers).map(([id, layer]) => { + return expressions.run(layer.query, null).toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(localState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = response.result; + // todo hack some logic in for dates + cachedFieldList[layerId] = { + fields: columns, + singleRow: rows.length === 1, + }; + }); + if (autoMap) { + setStateAndForceApply({ + ...localState, + cachedFieldList, + }); + } else { + setState({ + ...localState, + cachedFieldList, + }); + } + } catch (e) { + core.notifications.toasts.addError(e, { + title: 'Request failed', + toastMessage: e.body?.message, + }); + } + }; + + return ( + + + + {Object.entries(layers).map(([id, layer]) => { + const ref = state.indexPatternRefs.find((r) => r.id === layer.index); + return ( + + +
{ + if ((event.keyCode == 13 || event.which == 13) && event.metaKey) { + onSubmit(); + } + }} + > + { + setLocalState({ + ...state, + layers: { + ...state.layers, + [id]: { + ...layer, + query: val, + }, + }, + }); + }} + /> +
+
+
+ ); + })} + {Object.entries(removedLayers).map(([id, { layer }]) => ( + + + Currently detached. Add new layers to your visualization to use. + + + + + ))} + + + + { + setAutoMap(!autoMap); + setLocalState({ ...localState, autoMap: !autoMap }); + setState({ ...localState, autoMap: !autoMap }); + }} + /> + + + + Apply changes + + + + +
+
+ ); +} diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/_field_select.scss b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/_field_select.scss new file mode 100644 index 0000000000000..993174f3e6223 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/_field_select.scss @@ -0,0 +1,7 @@ +.lnFieldSelect__option--incompatible { + color: $euiColorLightShade; +} + +.lnFieldSelect__option--nonExistant { + background-color: $euiColorLightestShade; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/_index.scss b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/_index.scss new file mode 100644 index 0000000000000..085a00a2c33c5 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/_index.scss @@ -0,0 +1,2 @@ +@import 'field_select'; +@import 'popover'; diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/_popover.scss b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/_popover.scss new file mode 100644 index 0000000000000..07a72ee1f66fc --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/_popover.scss @@ -0,0 +1,38 @@ +.lnsIndexPatternDimensionEditor { + flex-grow: 1; + line-height: 0; + overflow: hidden; +} + +.lnsIndexPatternDimensionEditor__left, +.lnsIndexPatternDimensionEditor__right { + padding: $euiSizeS; +} + +.lnsIndexPatternDimensionEditor__left { + padding-top: 0; + background-color: $euiPageBackgroundColor; +} + +.lnsIndexPatternDimensionEditor__right { + width: $euiSize * 20; +} + +.lnsIndexPatternDimensionEditor__operation { + @include euiFontSizeS; + color: $euiColorPrimary; + + // TODO: Fix in EUI or don't use EuiSideNav + .euiSideNavItemButton__label { + color: inherit; + } +} + +.lnsIndexPatternDimensionEditor__operation--selected { + font-weight: bold; + color: $euiTextColor; +} + +.lnsIndexPatternDimensionEditor__operation--incompatible { + color: $euiColorMediumShade; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/bucket_nesting_editor.test.tsx b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/bucket_nesting_editor.test.tsx new file mode 100644 index 0000000000000..c6dbb6f617acf --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/bucket_nesting_editor.test.tsx @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { BucketNestingEditor } from './bucket_nesting_editor'; +import { IndexPatternColumn } from '../indexpattern'; + +describe('BucketNestingEditor', () => { + function mockCol(col: Partial = {}): IndexPatternColumn { + const result = { + dataType: 'string', + isBucketed: true, + label: 'a', + operationType: 'terms', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + sourceField: 'a', + suggestedPriority: 0, + ...col, + }; + + return result as IndexPatternColumn; + } + + it('should display the top level grouping when at the root', () => { + const component = mount( + + ); + const control1 = component.find('[data-test-subj="indexPattern-nesting-topLevel"]').first(); + const control2 = component.find('[data-test-subj="indexPattern-nesting-bottomLevel"]').first(); + + expect(control1.prop('checked')).toBeTruthy(); + expect(control2.prop('checked')).toBeFalsy(); + }); + + it('should display the bottom level grouping when appropriate', () => { + const component = mount( + + ); + + const control1 = component.find('[data-test-subj="indexPattern-nesting-topLevel"]').first(); + const control2 = component.find('[data-test-subj="indexPattern-nesting-bottomLevel"]').first(); + + expect(control1.prop('checked')).toBeFalsy(); + expect(control2.prop('checked')).toBeTruthy(); + }); + + it('should reorder the columns when toggled', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + const control1 = component.find('[data-test-subj="indexPattern-nesting-topLevel"]').first(); + + (control1.prop('onChange') as () => {})(); + + expect(setColumns).toHaveBeenCalledTimes(1); + expect(setColumns).toHaveBeenCalledWith(['a', 'b', 'c']); + + component.setProps({ + layer: { + columnOrder: ['a', 'b', 'c'], + columns: { + a: mockCol({ suggestedPriority: 0 }), + b: mockCol({ suggestedPriority: 1 }), + c: mockCol({ suggestedPriority: 2, operationType: 'min', isBucketed: false }), + }, + indexPatternId: 'foo', + }, + }); + + const control2 = component.find('[data-test-subj="indexPattern-nesting-bottomLevel"]').first(); + + (control2.prop('onChange') as () => {})(); + + expect(setColumns).toHaveBeenCalledTimes(2); + expect(setColumns).toHaveBeenLastCalledWith(['b', 'a', 'c']); + }); + + it('should display nothing if there are no buckets', () => { + const component = mount( + + ); + + expect(component.children().length).toBe(0); + }); + + it('should display nothing if there is one bucket', () => { + const component = mount( + + ); + + expect(component.children().length).toBe(0); + }); + + it('should display a dropdown with the parent column selected if 3+ buckets', () => { + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + + expect(control.prop('value')).toEqual('c'); + }); + + it('should reorder the columns when a column is selected in the dropdown', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: 'b' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['c', 'b', 'a']); + }); + + it('should move to root if the first dropdown item is selected', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: '' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['a', 'c', 'b']); + }); + + it('should allow the last bucket to be moved', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: '' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['b', 'c', 'a']); + }); +}); diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/bucket_nesting_editor.tsx new file mode 100644 index 0000000000000..6f6e9dc9e1b99 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/bucket_nesting_editor.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiHorizontalRule, EuiRadio, EuiSelect, htmlIdGenerator } from '@elastic/eui'; +import { ExpressionBasedLayer } from '../types'; +import { hasField } from '../utils'; + +const generator = htmlIdGenerator('lens-nesting'); + +function nestColumn(columnOrder: string[], outer: string, inner: string) { + const result = columnOrder.filter(c => c !== inner); + const outerPosition = result.indexOf(outer); + + result.splice(outerPosition + 1, 0, inner); + + return result; +} + +export function BucketNestingEditor({ + columnId, + layer, + setColumns, +}: { + columnId: string; + layer: ExpressionBasedLayer; + setColumns: (columns: string[]) => void; +}) { + const column = layer.columns[columnId]; + const columns = Object.entries(layer.columns); + const aggColumns = columns + .filter(([id, c]) => id !== columnId && c.isBucketed) + .map(([value, c]) => ({ + value, + text: c.label, + fieldName: hasField(c) ? c.sourceField : '', + })); + + if (!column || !column.isBucketed || !aggColumns.length) { + return null; + } + + const fieldName = hasField(column) ? column.sourceField : ''; + + const prevColumn = layer.columnOrder[layer.columnOrder.indexOf(columnId) - 1]; + + if (aggColumns.length === 1) { + const [target] = aggColumns; + + function toggleNesting() { + if (prevColumn) { + setColumns(nestColumn(layer.columnOrder, columnId, target.value)); + } else { + setColumns(nestColumn(layer.columnOrder, target.value, columnId)); + } + } + + return ( + <> + + + <> + + + + + + ); + } + + return ( + <> + + + ({ value, text })), + ]} + value={prevColumn} + onChange={e => setColumns(nestColumn(layer.columnOrder, e.target.value, columnId))} + /> + + + ); +} diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/dimension_panel.test.tsx new file mode 100644 index 0000000000000..fb897d3e68edd --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/dimension_panel.test.tsx @@ -0,0 +1,1422 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper, ShallowWrapper } from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { EuiComboBox, EuiSideNav, EuiSideNavItemType, EuiFieldNumber } from '@elastic/eui'; +import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { changeColumn } from '../state_helpers'; +import { + IndexPatternDimensionEditorComponent, + IndexPatternDimensionEditorProps, + onDrop, + canHandleDrop, +} from './dimension_panel'; +import { DragContextState } from '../../drag_drop'; +import { createMockedDragDropContext } from '../mocks'; +import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { ExpressionBasedPrivateState } from '../types'; +import { documentField } from '../document_field'; +import { OperationMetadata } from '../../types'; + +jest.mock('../loader'); +jest.mock('../state_helpers'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + hasExistence: true, + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + documentField, + ], + }, +}; + +describe('IndexPatternDimensionEditorPanel', () => { + let state: ExpressionBasedPrivateState; + let setState: jest.Mock; + let defaultProps: IndexPatternDimensionEditorProps; + let dragDropContext: DragContextState; + + beforeEach(() => { + state = { + indexPatternRefs: [], + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: '1', + showEmptyFields: false, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + + setState = jest.fn(); + + dragDropContext = createMockedDragDropContext(); + + defaultProps = { + state, + setState, + dateRange: { fromDate: 'now-1d', toDate: 'now' }, + columnId: 'col1', + layerId: 'first', + uniqueLabel: 'stuff', + filterOperations: () => true, + storage: {} as IStorageWrapper, + uiSettings: {} as IUiSettingsClient, + savedObjectsClient: {} as SavedObjectsClientContract, + http: {} as HttpSetup, + data: ({ + fieldFormats: ({ + getType: jest.fn().mockReturnValue({ + id: 'number', + title: 'Number', + }), + getDefaultType: jest.fn().mockReturnValue({ + id: 'bytes', + title: 'Bytes', + }), + } as unknown) as DataPublicPluginStart['fieldFormats'], + } as unknown) as DataPublicPluginStart, + core: {} as CoreSetup, + }; + + jest.clearAllMocks(); + }); + + describe('Editor component', () => { + let wrapper: ReactWrapper | ShallowWrapper; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + it('should call the filterOperations function', () => { + const filterOperations = jest.fn().mockReturnValue(true); + + wrapper = shallow( + + ); + + expect(filterOperations).toBeCalled(); + }); + + it('should show field select combo box on click', () => { + wrapper = mount(); + + expect( + wrapper.find(EuiComboBox).filter('[data-test-subj="indexPattern-dimension-field"]') + ).toHaveLength(1); + }); + + it('should not show any choices if the filter returns false', () => { + wrapper = mount( + false} + /> + ); + + expect( + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')! + .prop('options')! + ).toHaveLength(0); + }); + + it('should list all field names and document as a whole in prioritized order', () => { + wrapper = mount(); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options).toHaveLength(2); + + expect(options![0].label).toEqual('Records'); + + expect(options![1].options!.map(({ label }) => label)).toEqual([ + 'timestamp', + 'bytes', + 'memory', + 'source', + ]); + }); + + it('should hide fields that have no data', () => { + const props = { + ...defaultProps, + state: { + ...defaultProps.state, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + source: true, + }, + }, + }, + }; + wrapper = mount(); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![1].options!.map(({ label }) => label)).toEqual(['timestamp', 'source']); + }); + + it('should indicate fields which are incompatible for the operation of the current column', () => { + wrapper = mount( + + ); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-Records'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate operations which are incompatible for the field of the current column', () => { + wrapper = mount( + + ); + + interface ItemType { + name: string; + 'data-test-subj': string; + } + const items: Array> = wrapper.find(EuiSideNav).prop('items'); + const options = (items[0].items as unknown) as ItemType[]; + + expect(options.find(({ name }) => name === 'Minimum')!['data-test-subj']).not.toContain( + 'Incompatible' + ); + + expect(options.find(({ name }) => name === 'Date histogram')!['data-test-subj']).toContain( + 'Incompatible' + ); + }); + + it('should keep the operation when switching to another field compatible with this operation', () => { + const initialState: ExpressionBasedPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + params: { format: { id: 'bytes' } }, + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'max', + sourceField: 'memory', + params: { format: { id: 'bytes' } }, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should switch operations when selecting a field that requires another operation', () => { + wrapper = mount(); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should keep the field when switching to another operation compatible for this field', () => { + wrapper = mount( + + ); + + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'min', + sourceField: 'bytes', + params: { format: { id: 'bytes' } }, + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should not set the state if selecting the currently active operation', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should update label on label input changes', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('input[data-test-subj="indexPattern-label-edit"]') + .simulate('change', { target: { value: 'New Label' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + label: 'New Label', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + describe('transient invalid state', () => { + it('should not set the state if selecting an operation incompatible with the current field', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should show error message in invalid state', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).not.toHaveLength( + 0 + ); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should leave error state if a compatible operation is selected', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); + }); + + it('should indicate fields compatible with selected operation', () => { + wrapper = mount(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should select compatible operation if field not compatible with selected operation', () => { + wrapper = mount( + + ); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]'); + const options = comboBox.prop('options'); + + // options[1][2] is a `source` field of type `string` which doesn't support `avg` operation + act(() => { + comboBox.prop('onChange')!([options![1].options![2]]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'source', + operationType: 'terms', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select the Records field when count is selected', () => { + const initialState: ExpressionBasedPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'bytes', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-count"]') + .simulate('click'); + + const newColumnState = setState.mock.calls[0][0].layers.first.columns.col2; + expect(newColumnState.operationType).toEqual('count'); + expect(newColumnState.sourceField).toEqual('Records'); + }); + + it('should indicate document and field compatibility with selected document operation', () => { + const initialState: ExpressionBasedPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'count', + sourceField: 'Records', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should set datasource state if compatible field is selected for operation', () => { + wrapper = mount(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + }); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox + .prop('options')![1] + .options!.find(({ label }) => label === 'source')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + sourceField: 'source', + operationType: 'terms', + }), + }, + }, + }, + }); + }); + }); + + it('should support selecting the operation before the field', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]'); + const options = comboBox.prop('options'); + + act(() => { + comboBox.prop('onChange')!([options![1].options![0]]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select operation directly if only one field is possible', () => { + const initialState = { + ...state, + indexPatterns: { + 1: { + ...state.indexPatterns['1'], + fields: state.indexPatterns['1'].fields.filter(field => field.name !== 'memory'), + }, + }, + }; + + wrapper = mount( + + ); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...initialState.layers.first, + columns: { + ...initialState.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select operation directly if only document is possible', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-count"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + operationType: 'count', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should indicate compatible fields when selecting the operation first', () => { + wrapper = mount(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'bytes')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate document compatibility when document operation is selected', () => { + const initialState: ExpressionBasedPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'count', + sourceField: 'Records', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + const options = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('options'); + + expect(options![0]['data-test-subj']).not.toContain('Incompatible'); + + options![1].options!.map(operation => + expect(operation['data-test-subj']).toContain('Incompatible') + ); + }); + + it('should show all operations that are not filtered out', () => { + wrapper = mount( + !op.isBucketed && op.dataType === 'number'} + /> + ); + + interface ItemType { + name: React.ReactNode; + } + const items: Array> = wrapper.find(EuiSideNav).prop('items'); + const options = (items[0].items as unknown) as ItemType[]; + + expect(options.map(({ name }: { name: React.ReactNode }) => name)).toEqual([ + 'Unique count', + 'Average', + 'Count', + 'Maximum', + 'Minimum', + 'Sum', + ]); + }); + + it('should add a column on selection of a field', () => { + wrapper = mount(); + + const comboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]')!; + const option = comboBox.prop('options')![1].options![0]; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should use helper function when changing the function', () => { + const initialState: ExpressionBasedPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + act(() => { + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); + }); + + expect(changeColumn).toHaveBeenCalledWith({ + state: initialState, + columnId: 'col1', + layerId: 'first', + newColumn: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'min', + }), + }); + }); + + it('should clear the dimension when removing the selection in field combobox', () => { + wrapper = mount(); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-field"]') + .prop('onChange')!([]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + it('allows custom format', () => { + const stateWithNumberCol: ExpressionBasedPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: 'bytes', label: 'Bytes' }]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }), + }, + }, + }, + }); + }); + + it('keeps decimal places while switching', () => { + const stateWithNumberCol: ExpressionBasedPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + params: { + format: { id: 'bytes', params: { decimals: 0 } }, + }, + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: '', label: 'Default' }]); + }); + + act(() => { + wrapper + .find(EuiComboBox) + .filter('[data-test-subj="indexPattern-dimension-format"]') + .prop('onChange')!([{ value: 'number', label: 'Number' }]); + }); + + expect( + wrapper + .find(EuiFieldNumber) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('value') + ).toEqual(0); + }); + + it('allows custom format with number of decimal places', () => { + const stateWithNumberCol: ExpressionBasedPrivateState = { + ...state, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Average of bar', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bar', + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }, + }, + }, + }, + }; + + wrapper = mount( + + ); + + act(() => { + wrapper + .find(EuiFieldNumber) + .filter('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .prop('onChange')!({ target: { value: '0' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + params: { + format: { id: 'bytes', params: { decimals: 0 } }, + }, + }), + }, + }, + }, + }); + }); + }); + + describe('Drag and drop', () => { + function dragDropState(): ExpressionBasedPrivateState { + return { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: { + foo: { + id: 'foo', + title: 'Foo pattern', + fields: [ + { + aggregatable: true, + name: 'bar', + searchable: true, + type: 'number', + }, + { + aggregatable: true, + name: 'mystring', + searchable: true, + type: 'string', + }, + ], + }, + }, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + myLayer: { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + } + + it('is not droppable if no drag is happening', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext, + state: dragDropState(), + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is not droppable if the dragged item has no field', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { name: 'bar' }, + }, + }) + ).toBe(false); + }); + + it('is not droppable if field is not supported by filterOperations', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + indexPatternId: 'foo', + field: { type: 'string', name: 'mystring', aggregatable: true }, + }, + }, + state: dragDropState(), + filterOperations: () => false, + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is droppable if the field is supported by filterOperations', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }, + }, + state: dragDropState(), + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(true); + }); + + it('is not droppable if the field belongs to another index pattern', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo2', + }, + }, + state: dragDropState(), + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('appends the dropped column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }, + }, + }, + }); + }); + + it('selects the specific operation that was valid on drop', () => { + const dragging = { + field: { type: 'string', name: 'mystring', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + dataType: 'string', + sourceField: 'mystring', + }), + }, + }, + }, + }); + }); + + it('updates a column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }), + }), + }, + }); + }); + + it('does not set the size of the terms aggregation', () => { + const dragging = { + field: { type: 'string', name: 'mystring', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + operationType: 'terms', + params: expect.objectContaining({ size: 3 }), + }), + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/dimension_panel.tsx new file mode 100644 index 0000000000000..12c9f19842483 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/dimension_panel.tsx @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiLink } from '@elastic/eui'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { + DatasourceDimensionTriggerProps, + DatasourceDimensionEditorProps, + DatasourceDimensionDropProps, + DatasourceDimensionDropHandlerProps, +} from '../../types'; +import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +import { IndexPatternColumn, OperationType } from '../indexpattern'; +import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations'; +import { PopoverEditor } from './popover_editor'; +import { changeColumn } from '../state_helpers'; +import { isDraggedField, hasField } from '../utils'; +import { ExpressionBasedPrivateState, IndexPatternField } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { DateRange } from '../../../common'; + +export type IndexPatternDimensionTriggerProps = DatasourceDimensionTriggerProps< + ExpressionBasedPrivateState +> & { + uniqueLabel: string; +}; + +export type IndexPatternDimensionEditorProps = DatasourceDimensionEditorProps< + ExpressionBasedPrivateState +> & { + uiSettings: IUiSettingsClient; + storage: IStorageWrapper; + savedObjectsClient: SavedObjectsClientContract; + layerId: string; + http: HttpSetup; + data: DataPublicPluginStart; + uniqueLabel: string; + dateRange: DateRange; +}; + +export interface OperationFieldSupportMatrix { + operationByField: Partial>; + fieldByOperation: Partial>; +} + +type Props = Pick< + DatasourceDimensionDropProps, + 'layerId' | 'columnId' | 'state' | 'filterOperations' +>; + +// TODO: This code has historically been memoized, as a potentially performance +// sensitive task. If we can add memoization without breaking the behavior, we should. +const getOperationFieldSupportMatrix = (props: Props): OperationFieldSupportMatrix => { + const layerId = props.layerId; + const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId]; + + const filteredOperationsByMetadata = getAvailableOperationsByMetadata( + currentIndexPattern + ).filter(operation => props.filterOperations(operation.operationMetaData)); + + const supportedOperationsByField: Partial> = {}; + const supportedFieldsByOperation: Partial> = {}; + + filteredOperationsByMetadata.forEach(({ operations }) => { + operations.forEach(operation => { + if (supportedOperationsByField[operation.field]) { + supportedOperationsByField[operation.field]!.push(operation.operationType); + } else { + supportedOperationsByField[operation.field] = [operation.operationType]; + } + + if (supportedFieldsByOperation[operation.operationType]) { + supportedFieldsByOperation[operation.operationType]!.push(operation.field); + } else { + supportedFieldsByOperation[operation.operationType] = [operation.field]; + } + }); + }); + return { + operationByField: _.mapValues(supportedOperationsByField, _.uniq), + fieldByOperation: _.mapValues(supportedFieldsByOperation, _.uniq), + }; +}; + +export function canHandleDrop(props: DatasourceDimensionDropProps) { + const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); + + const { dragging } = props.dragDropContext; + const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; + + function hasOperationForField(field: IndexPatternField) { + return Boolean(operationFieldSupportMatrix.operationByField[field.name]); + } + + return ( + isDraggedField(dragging) && + layerIndexPatternId === dragging.indexPatternId && + Boolean(hasOperationForField(dragging.field)) + ); +} + +export function onDrop( + props: DatasourceDimensionDropHandlerProps +): boolean { + const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); + const droppedItem = props.droppedItem; + + function hasOperationForField(field: IndexPatternField) { + return Boolean(operationFieldSupportMatrix.operationByField[field.name]); + } + + if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { + // TODO: What do we do if we couldn't find a column? + return false; + } + + const operationsForNewField = + operationFieldSupportMatrix.operationByField[droppedItem.field.name]; + + const layerId = props.layerId; + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + const currentIndexPattern = + props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId]; + + // We need to check if dragging in a new field, was just a field change on the same + // index pattern and on the same operations (therefore checking if the new field supports + // our previous operation) + const hasFieldChanged = + selectedColumn && + hasField(selectedColumn) && + selectedColumn.sourceField !== droppedItem.field.name && + operationsForNewField && + operationsForNewField.includes(selectedColumn.operationType); + + if (!operationsForNewField || operationsForNewField.length === 0) { + return false; + } + + // If only the field has changed use the onFieldChange method on the operation to get the + // new column, otherwise use the regular buildColumn to get a new column. + const newColumn = hasFieldChanged + ? changeField(selectedColumn, currentIndexPattern, droppedItem.field) + : buildColumn({ + op: operationsForNewField[0], + columns: props.state.layers[props.layerId].columns, + indexPattern: currentIndexPattern, + layerId, + suggestedPriority: props.suggestedPriority, + field: droppedItem.field, + previousColumn: selectedColumn, + }); + + trackUiEvent('drop_onto_dimension'); + const hasData = Object.values(props.state.layers).some(({ columns }) => columns.length); + trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); + + props.setState( + changeColumn({ + state: props.state, + layerId, + columnId: props.columnId, + newColumn, + // If the field has changed, the onFieldChange method needs to take care of everything including moving + // over params. If we create a new column above we want changeColumn to move over params. + keepParams: !hasFieldChanged, + }) + ); + + return true; +} + +export const IndexPatternDimensionTriggerComponent = function IndexPatternDimensionTrigger( + props: IndexPatternDimensionTriggerProps +) { + const layerId = props.layerId; + + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + + const { columnId, uniqueLabel } = props; + if (!selectedColumn) { + return null; + } + return ( + { + props.togglePopover(); + }} + data-test-subj="lns-dimensionTrigger" + aria-label={i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit configuration', + })} + title={i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit configuration', + })} + > + {uniqueLabel} + + ); +}; + +export const IndexPatternDimensionEditorComponent = function IndexPatternDimensionPanel( + props: IndexPatternDimensionEditorProps +) { + const layerId = props.layerId; + const currentIndexPattern = + props.state.indexPatterns[props.state.layers[layerId]?.indexPatternId]; + const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); + + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + + return ( + + ); +}; + +export const IndexPatternDimensionTrigger = memo(IndexPatternDimensionTriggerComponent); +export const IndexPatternDimensionEditor = memo(IndexPatternDimensionEditorComponent); diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/field_select.tsx new file mode 100644 index 0000000000000..1e469b9c926a5 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/field_select.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiComboBoxOptionOption } from '@elastic/eui'; +import classNames from 'classnames'; +import { EuiHighlight } from '@elastic/eui'; +import { OperationType } from '../indexpattern'; +import { LensFieldIcon } from '../lens_field_icon'; +import { DataType } from '../../types'; +import { OperationFieldSupportMatrix } from './dimension_panel'; +import { IndexPattern, IndexPatternField, ExpressionBasedPrivateState } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { fieldExists } from '../pure_helpers'; + +export interface FieldChoice { + type: 'field'; + field: string; + operationType?: OperationType; +} + +export interface FieldSelectProps { + currentIndexPattern: IndexPattern; + showEmptyFields: boolean; + fieldMap: Record; + incompatibleSelectedOperationType: OperationType | null; + selectedColumnOperationType?: OperationType; + selectedColumnSourceField?: string; + operationFieldSupportMatrix: OperationFieldSupportMatrix; + onChoose: (choice: FieldChoice) => void; + onDeleteColumn: () => void; + existingFields: ExpressionBasedPrivateState['existingFields']; +} + +export function FieldSelect({ + currentIndexPattern, + showEmptyFields, + fieldMap, + incompatibleSelectedOperationType, + selectedColumnOperationType, + selectedColumnSourceField, + operationFieldSupportMatrix, + onChoose, + onDeleteColumn, + existingFields, +}: FieldSelectProps) { + const { operationByField } = operationFieldSupportMatrix; + const memoizedFieldOptions = useMemo(() => { + const fields = Object.keys(operationByField).sort(); + + function isCompatibleWithCurrentOperation(fieldName: string) { + if (incompatibleSelectedOperationType) { + return operationByField[fieldName]!.includes(incompatibleSelectedOperationType); + } + return ( + !selectedColumnOperationType || + operationByField[fieldName]!.includes(selectedColumnOperationType) + ); + } + + const [specialFields, normalFields] = _.partition( + fields, + field => fieldMap[field].type === 'document' + ); + + function fieldNamesToOptions(items: string[]) { + return items + .map(field => ({ + label: field, + value: { + type: 'field', + field, + dataType: fieldMap[field].type, + operationType: + selectedColumnOperationType && isCompatibleWithCurrentOperation(field) + ? selectedColumnOperationType + : undefined, + }, + exists: + fieldMap[field].type === 'document' || + fieldExists(existingFields, currentIndexPattern.title, field), + compatible: isCompatibleWithCurrentOperation(field), + })) + .filter(field => showEmptyFields || field.exists) + .sort((a, b) => { + if (a.compatible && !b.compatible) { + return -1; + } + if (!a.compatible && b.compatible) { + return 1; + } + return 0; + }) + .map(({ label, value, compatible, exists }) => ({ + label, + value, + className: classNames({ + 'lnFieldSelect__option--incompatible': !compatible, + 'lnFieldSelect__option--nonExistant': !exists, + }), + 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${label}`, + })); + } + + const fieldOptions: unknown[] = fieldNamesToOptions(specialFields); + + if (fields.length > 0) { + fieldOptions.push({ + label: i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', { + defaultMessage: 'Individual fields', + }), + options: fieldNamesToOptions(normalFields), + }); + } + + return fieldOptions; + }, [ + incompatibleSelectedOperationType, + selectedColumnOperationType, + selectedColumnSourceField, + operationFieldSupportMatrix, + currentIndexPattern, + fieldMap, + showEmptyFields, + ]); + + return ( + { + if (choices.length === 0) { + onDeleteColumn(); + return; + } + + trackUiEvent('indexpattern_dimension_field_changed'); + + onChoose((choices[0].value as unknown) as FieldChoice); + }} + renderOption={(option, searchValue) => { + return ( + + + + + + {option.label} + + + ); + }} + /> + ); +} diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/format_selector.tsx b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/format_selector.tsx new file mode 100644 index 0000000000000..ed68a93c51ca2 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/format_selector.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiFieldNumber, EuiComboBox } from '@elastic/eui'; +import { IndexPatternColumn } from '../indexpattern'; + +const supportedFormats: Record = { + number: { + title: i18n.translate('xpack.lens.indexPattern.numberFormatLabel', { + defaultMessage: 'Number', + }), + }, + percent: { + title: i18n.translate('xpack.lens.indexPattern.percentFormatLabel', { + defaultMessage: 'Percent', + }), + }, + bytes: { + title: i18n.translate('xpack.lens.indexPattern.bytesFormatLabel', { + defaultMessage: 'Bytes (1024)', + }), + }, +}; + +interface FormatSelectorProps { + selectedColumn: IndexPatternColumn; + onChange: (newFormat?: { id: string; params?: Record }) => void; +} + +interface State { + decimalPlaces: number; +} + +export function FormatSelector(props: FormatSelectorProps) { + const { selectedColumn, onChange } = props; + + const currentFormat = + 'params' in selectedColumn && selectedColumn.params && 'format' in selectedColumn.params + ? selectedColumn.params.format + : undefined; + const [state, setState] = useState({ + decimalPlaces: + typeof currentFormat?.params?.decimals === 'number' ? currentFormat.params.decimals : 2, + }); + + const selectedFormat = currentFormat?.id ? supportedFormats[currentFormat.id] : undefined; + + const defaultOption = { + value: '', + label: i18n.translate('xpack.lens.indexPattern.defaultFormatLabel', { + defaultMessage: 'Default', + }), + }; + + return ( + <> + + ({ + value: id, + label: format.title ?? id, + })), + ]} + selectedOptions={ + currentFormat + ? [ + { + value: currentFormat.id, + label: selectedFormat?.title ?? currentFormat.id, + }, + ] + : [defaultOption] + } + onChange={choices => { + if (choices.length === 0) { + return; + } + + if (!choices[0].value) { + onChange(); + return; + } + onChange({ + id: choices[0].value, + params: { decimals: state.decimalPlaces }, + }); + }} + /> + + + {currentFormat ? ( + + { + setState({ decimalPlaces: Number(e.target.value) }); + onChange({ + id: (selectedColumn.params as { format: { id: string } }).format.id, + params: { + decimals: Number(e.target.value), + }, + }); + }} + compressed + fullWidth + /> + + ) : null} + + ); +} diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/index.ts b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/index.ts new file mode 100644 index 0000000000000..88e5588ce0e01 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dimension_panel'; diff --git a/x-pack/plugins/lens/public/expression_datasource/dimension_panel/popover_editor.tsx b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/popover_editor.tsx new file mode 100644 index 0000000000000..e26c338b6e240 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/dimension_panel/popover_editor.tsx @@ -0,0 +1,374 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiSideNav, + EuiCallOut, + EuiFormRow, + EuiFieldText, + EuiSpacer, +} from '@elastic/eui'; +import classNames from 'classnames'; +import { IndexPatternColumn, OperationType } from '../indexpattern'; +import { IndexPatternDimensionEditorProps, OperationFieldSupportMatrix } from './dimension_panel'; +import { + operationDefinitionMap, + getOperationDisplay, + buildColumn, + changeField, +} from '../operations'; +import { deleteColumn, changeColumn, updateColumnParam } from '../state_helpers'; +import { FieldSelect } from './field_select'; +import { hasField } from '../utils'; +import { BucketNestingEditor } from './bucket_nesting_editor'; +import { IndexPattern, IndexPatternField } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { FormatSelector } from './format_selector'; + +const operationPanels = getOperationDisplay(); + +export interface PopoverEditorProps extends IndexPatternDimensionEditorProps { + selectedColumn?: IndexPatternColumn; + operationFieldSupportMatrix: OperationFieldSupportMatrix; + currentIndexPattern: IndexPattern; +} + +function asOperationOptions(operationTypes: OperationType[], compatibleWithCurrentField: boolean) { + return [...operationTypes] + .sort((opType1, opType2) => { + return operationPanels[opType1].displayName.localeCompare( + operationPanels[opType2].displayName + ); + }) + .map(operationType => ({ + operationType, + compatibleWithCurrentField, + })); +} + +export function PopoverEditor(props: PopoverEditorProps) { + const { + selectedColumn, + operationFieldSupportMatrix, + state, + columnId, + setState, + layerId, + currentIndexPattern, + hideGrouping, + } = props; + const { operationByField, fieldByOperation } = operationFieldSupportMatrix; + const [ + incompatibleSelectedOperationType, + setInvalidOperationType, + ] = useState(null); + + const ParamEditor = + selectedColumn && operationDefinitionMap[selectedColumn.operationType].paramEditor; + + const fieldMap: Record = useMemo(() => { + const fields: Record = {}; + currentIndexPattern.fields.forEach(field => { + fields[field.name] = field; + }); + return fields; + }, [currentIndexPattern]); + + function getOperationTypes() { + const possibleOperationTypes = Object.keys(fieldByOperation) as OperationType[]; + const validOperationTypes: OperationType[] = []; + + if (!selectedColumn) { + validOperationTypes.push(...(Object.keys(fieldByOperation) as OperationType[])); + } else if (hasField(selectedColumn) && operationByField[selectedColumn.sourceField]) { + validOperationTypes.push(...operationByField[selectedColumn.sourceField]!); + } + + return _.uniq( + [ + ...asOperationOptions(validOperationTypes, true), + ...asOperationOptions(possibleOperationTypes, false), + ], + 'operationType' + ); + } + + function getSideNavItems() { + return [ + { + name: '', + id: '0', + items: getOperationTypes().map(({ operationType, compatibleWithCurrentField }) => ({ + name: operationPanels[operationType].displayName, + id: operationType as string, + className: classNames('lnsIndexPatternDimensionEditor__operation', { + 'lnsIndexPatternDimensionEditor__operation--selected': Boolean( + incompatibleSelectedOperationType === operationType || + (!incompatibleSelectedOperationType && + selectedColumn && + selectedColumn.operationType === operationType) + ), + 'lnsIndexPatternDimensionEditor__operation--incompatible': !compatibleWithCurrentField, + }), + 'data-test-subj': `lns-indexPatternDimension${ + compatibleWithCurrentField ? '' : 'Incompatible' + }-${operationType}`, + onClick() { + if (!selectedColumn || !compatibleWithCurrentField) { + const possibleFields = fieldByOperation[operationType] || []; + + if (possibleFields.length === 1) { + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: buildColumn({ + columns: props.state.layers[props.layerId].columns, + suggestedPriority: props.suggestedPriority, + layerId: props.layerId, + op: operationType, + indexPattern: currentIndexPattern, + field: fieldMap[possibleFields[0]], + previousColumn: selectedColumn, + }), + }) + ); + } else { + setInvalidOperationType(operationType); + } + trackUiEvent(`indexpattern_dimension_operation_${operationType}`); + return; + } + if (incompatibleSelectedOperationType) { + setInvalidOperationType(null); + } + if (selectedColumn.operationType === operationType) { + return; + } + const newColumn: IndexPatternColumn = buildColumn({ + columns: props.state.layers[props.layerId].columns, + suggestedPriority: props.suggestedPriority, + layerId: props.layerId, + op: operationType, + indexPattern: currentIndexPattern, + field: fieldMap[selectedColumn.sourceField], + previousColumn: selectedColumn, + }); + + trackUiEvent( + `indexpattern_dimension_operation_from_${selectedColumn.operationType}_to_${operationType}` + ); + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn, + }) + ); + }, + })), + }, + ]; + } + + return ( +
+ + + { + setState( + deleteColumn({ + state, + layerId, + columnId, + }) + ); + }} + onChoose={choice => { + let column: IndexPatternColumn; + if ( + !incompatibleSelectedOperationType && + selectedColumn && + 'field' in choice && + choice.operationType === selectedColumn.operationType + ) { + // If we just changed the field are not in an error state and the operation didn't change, + // we use the operations onFieldChange method to calculate the new column. + column = changeField(selectedColumn, currentIndexPattern, fieldMap[choice.field]); + } else { + // Otherwise we'll use the buildColumn method to calculate a new column + const compatibleOperations = + ('field' in choice && + operationFieldSupportMatrix.operationByField[choice.field]) || + []; + let operation; + if (compatibleOperations.length > 0) { + operation = + incompatibleSelectedOperationType && + compatibleOperations.includes(incompatibleSelectedOperationType) + ? incompatibleSelectedOperationType + : compatibleOperations[0]; + } else if ('field' in choice) { + operation = choice.operationType; + } + column = buildColumn({ + columns: props.state.layers[props.layerId].columns, + field: fieldMap[choice.field], + indexPattern: currentIndexPattern, + layerId: props.layerId, + suggestedPriority: props.suggestedPriority, + op: operation as OperationType, + previousColumn: selectedColumn, + }); + } + + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: column, + keepParams: false, + }) + ); + setInvalidOperationType(null); + }} + /> + + + + + + + + {incompatibleSelectedOperationType && selectedColumn && ( + + )} + {incompatibleSelectedOperationType && !selectedColumn && ( + + )} + {!incompatibleSelectedOperationType && ParamEditor && ( + <> + + + + )} + {!incompatibleSelectedOperationType && selectedColumn && ( + + { + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: { + ...selectedColumn, + label: e.target.value, + }, + }) + ); + }} + /> + + )} + + {!hideGrouping && ( + { + setState({ + ...state, + layers: { + ...state.layers, + [props.layerId]: { + ...state.layers[props.layerId], + columnOrder, + }, + }, + }); + }} + /> + )} + + {selectedColumn && selectedColumn.dataType === 'number' ? ( + { + setState( + updateColumnParam({ + state, + layerId, + currentColumn: selectedColumn, + paramName: 'format', + value: newFormat, + }) + ); + }} + /> + ) : null} + + + + +
+ ); +} diff --git a/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx b/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx new file mode 100644 index 0000000000000..49d4e162a3c70 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx @@ -0,0 +1,457 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import _ from 'lodash'; +import React from 'react'; +import { render } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { EuiButton, EuiSelect } from '@elastic/eui'; +import { + DatasourceDimensionEditorProps, + DatasourceDimensionTriggerProps, + DatasourceDataPanelProps, + Operation, + DatasourceLayerPanelProps, + PublicAPIProps, + DataType, +} from '../types'; +import { toExpression } from './to_expression'; +import { ExpressionBasedDataPanel, ExpressionBasedHorizontalDataPanel } from './datapanel'; + +import type { DataViewsService } from '../../../../../src/plugins/data_views/common'; +import { ExpressionBasedLayer, ExpressionBasedPrivateState, ExpressionBasedPersistedState } from './types'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { Datasource } from '../types'; +import { esRawResponse } from '../../../../../src/plugins/data/common'; +import { ExpressionsStart } from 'src/plugins/expressions/public'; + +async function loadIndexPatternRefs( + indexPatternsService: DataViewsService +): Promise { + const indexPatterns = await indexPatternsService.getIdsWithTitle(); + + const timefields = await Promise.all( + indexPatterns.map((p) => indexPatternsService.get(p.id).then((pat) => pat.timeFieldName)) + ); + + return indexPatterns + .map((p, i) => ({ ...p, timeField: timefields[i] })) + .sort((a, b) => { + return a.title.localeCompare(b.title); + }); +} + +export function getExpressionBasedDatasource({ + core, + storage, + data, + expressions, +}: { + core: CoreStart; + storage: IStorageWrapper; + data: DataPublicPluginStart; + expressions: ExpressionsStart; +}) { + // Not stateful. State is persisted to the frame + const expressionbasedDatasource: Datasource = { + id: 'expressionbased', + + checkIntegrity: () => { + return []; + }, + getErrorMessages: () => { + return []; + }, + async initialize(state?: ExpressionBasedPersistedState) { + const initState = state || { layers: {} }; + const indexPatternRefs: IndexPatternRef[] = await loadIndexPatternRefs(data.dataViews); + const responses = await Promise.all( + Object.entries(initState.layers).map(([id, layer]) => { + return data.search + .search({ + params: { + size: 0, + index: layer.index, + body: JSON.parse(layer.query), + }, + }) + .toPromise(); + }) + ); + const cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + > = {}; + responses.forEach((response, index) => { + const layerId = Object.keys(initState.layers)[index]; + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = esRawResponse.to!.datatable({ body: response.rawResponse }); + // todo hack some logic in for dates + cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; + }); + return { + ...initState, + cachedFieldList, + removedLayers: [], + indexPatternRefs, + }; + }, + + getPersistableState({ layers }: ExpressionBasedPrivateState) { + return { state: { layers }, savedObjectReferences: [] }; + }, + isValidColumn() { + return true; + }, + insertLayer(state: ExpressionBasedPrivateState, newLayerId: string) { + const removedLayer = state.removedLayers[0]; + const newRemovedList = removedLayer ? state.removedLayers.slice(1) : state.removedLayers; + return { + ...state, + cachedFieldList: { + ...state.cachedFieldList, + [newLayerId]: removedLayer + ? removedLayer.fieldList + : { + fields: [], + singleRow: false, + }, + }, + layers: { + ...state.layers, + [newLayerId]: removedLayer + ? removedLayer.layer + : blankLayer( + JSON.parse(localStorage.getItem('lens-settings') || '{}').indexPatternId || + state.indexPatternRefs[0].id + ), + }, + removedLayers: newRemovedList, + }; + }, + + removeLayer(state: ExpressionBasedPrivateState, layerId: string) { + const deletedLayer = state.layers[layerId]; + const newLayers = { ...state.layers }; + delete newLayers[layerId]; + + const deletedFieldList = state.cachedFieldList[layerId]; + const newFieldList = { ...state.cachedFieldList }; + delete newFieldList[layerId]; + + return { + ...state, + layers: newLayers, + cachedFieldList: newFieldList, + removedLayers: deletedLayer.query + ? [ + { layer: { ...deletedLayer, columns: [] }, fieldList: deletedFieldList }, + ...state.removedLayers, + ] + : state.removedLayers, + }; + }, + + clearLayer(state: ExpressionBasedPrivateState, layerId: string) { + return { + ...state, + layers: { + ...state.layers, + [layerId]: { ...state.layers[layerId], columns: [] }, + }, + }; + }, + + getLayers(state: ExpressionBasedPrivateState) { + return Object.keys(state.layers); + }, + + removeColumn({ prevState, layerId, columnId }) { + return { + ...prevState, + layers: { + ...prevState.layers, + [layerId]: { + ...prevState.layers[layerId], + columns: prevState.layers[layerId].columns.filter((col) => col.columnId !== columnId), + }, + }, + }; + }, + + toExpression, + + getMetaData(state: ExpressionBasedPrivateState) { + return { + filterableIndexPatterns: [], + }; + }, + + renderDataPanel(domElement: Element, props: DatasourceDataPanelProps) { + render( + + + , + domElement + ); + }, + + renderHorizontalDataPanel( + domElement: Element, + props: DatasourceDataPanelProps + ) { + render( + + + , + domElement + ); + }, + + renderDimensionTrigger: ( + domElement: Element, + props: DatasourceDimensionTriggerProps + ) => { + const selectedField = props.state.layers[props.layerId].columns.find( + (column) => column.columnId === props.columnId + )!; + render( {}}>{selectedField.fieldName}, domElement); + }, + + renderDimensionEditor: ( + domElement: Element, + props: DatasourceDimensionEditorProps + ) => { + const fields = props.state.cachedFieldList[props.layerId].fields; + const selectedField = props.state.layers[props.layerId].columns.find( + (column) => column.columnId === props.columnId + ); + render( + ({ value: field.name, text: field.name })), + ]} + onChange={(e) => { + props.setState( + !selectedField + ? { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: [ + ...props.state.layers[props.layerId].columns, + { columnId: props.columnId, fieldName: e.target.value }, + ], + }, + }, + } + : { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: props.state.layers[props.layerId].columns.map((col) => + col.columnId !== props.columnId + ? col + : { ...col, fieldName: e.target.value } + ), + }, + }, + } + ); + }} + />, + domElement + ); + }, + + renderLayerPanel: ( + domElement: Element, + props: DatasourceLayerPanelProps + ) => { + render( + + { + props.state.indexPatternRefs.find( + (r) => r.id === props.state.layers[props.layerId].index + )!.title + } + , + domElement + ); + }, + + onDrop: (props) => { + const { droppedItem, dropType } = props; + + if (dropType === 'field_add') { + const currentLayer = props.state.layers[props.layerId]; + const columnExists = currentLayer.columns.some((c) => c.columnId === props.columnId); + props.setState({ + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: columnExists + ? currentLayer.columns.map((c) => + c.columnId !== props.columnId ? c : { ...c, fieldName: droppedItem.field } + ) + : [ + ...props.state.layers[props.layerId].columns, + { columnId: props.columnId, fieldName: droppedItem.field }, + ], + }, + }, + }); + return true; + } + return false; + }, + uniqueLabels(state: ExpressionBasedPrivateState) { + const layers = state.layers; + const columnLabelMap = {} as Record; + + Object.values(layers).forEach((layer) => { + if (!layer.columns) { + return; + } + Object.entries(layer.columns).forEach(([columnId, column]) => { + columnLabelMap[columnId] = columnId; + }); + }); + + return columnLabelMap; + }, + + getDropProps: (props) => { + if (!props.dragging?.isSqlField) return undefined; + + return { dropTypes: ['field_add'], nextLabel: props.dragging?.field }; + }, + + getPublicAPI({ state, layerId }: PublicAPIProps) { + return { + datasourceId: 'expressionbased', + + getTableSpec: () => { + return ( + state.layers[layerId]?.columns.map((column) => ({ columnId: column.columnId })) || [] + ); + }, + getOperationForColumnId: (columnId: string) => { + const layer = state.layers[layerId]; + const column = layer?.columns.find((c) => c.columnId === columnId); + + if (column) { + const field = state.cachedFieldList[layerId].fields.find( + (f) => f.name === column.fieldName + )!; + const overwrite = layer.overwrittenFieldTypes?.[column.fieldName]; + return { + dataType: overwrite || (field?.meta?.type as DataType), + label: field?.name, + isBucketed: false, + noBucketInfo: true, + }; + } + return null; + }, + }; + }, + getDatasourceSuggestionsForField(state, draggedField) { + return []; + }, + getDatasourceSuggestionsFromCurrentState: (state) => { + return Object.entries(state.layers).map(([id, layer]) => { + const reducedState: ExpressionBasedPrivateState = { + ...state, + cachedFieldList: { + [id]: state.cachedFieldList[id], + }, + layers: { + [id]: state.layers[id], + }, + }; + return !state.autoMap + ? { + state: reducedState, + table: { + changeType: 'unchanged', + isMultiRow: !state.cachedFieldList[id].singleRow, + layerId: id, + columns: layer.columns.map((column) => { + const field = state.cachedFieldList[id].fields.find( + (f) => f.name === column.fieldName + )!; + const operation = { + dataType: field?.meta.type as DataType, + label: field?.name, + isBucketed: false, + noBucketInfo: true, + }; + return { + columnId: column.columnId, + operation, + }; + }), + }, + keptLayerIds: [id], + } + : { + state: { + ...reducedState, + layers: { + [id]: { + ...state.layers[id], + columns: state.cachedFieldList[id].fields.map((f) => ({ + columnId: f.name, + fieldName: f.name, + })), + }, + }, + }, + table: { + changeType: 'unchanged', + isMultiRow: !state.cachedFieldList[id].singleRow, + layerId: id, + columns: state.cachedFieldList[id].fields.map((f) => { + return { + columnId: f.name, + operation: { + dataType: f.meta.type, + label: f.name, + isBucketed: false, + noBucketInfo: true, + }, + }; + }), + }, + keptLayerIds: [id], + }; + }); + }, + }; + + return expressionbasedDatasource; +} + +function blankLayer(index: string) { + return { + index, + query: '', + columns: [], + }; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/expression_datasource/field_item.test.tsx new file mode 100644 index 0000000000000..6a4a2bd2ba77b --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/field_item.test.tsx @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { EuiLoadingSpinner, EuiPopover } from '@elastic/eui'; +import { FieldItem, FieldItemProps } from './field_item'; +import { coreMock } from 'src/core/public/mocks'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { IndexPattern } from './types'; + +describe('IndexPattern Field Item', () => { + let defaultProps: FieldItemProps; + let indexPattern: IndexPattern; + let core: ReturnType; + let data: DataPublicPluginStart; + + beforeEach(() => { + indexPattern = { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'unsupported', + type: 'geo', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + } as IndexPattern; + + core = coreMock.createSetup(); + data = dataPluginMock.createStartContract(); + core.http.post.mockClear(); + defaultProps = { + indexPattern, + data, + core, + highlight: '', + dateRange: { + fromDate: 'now-7d', + toDate: 'now', + }, + query: { query: '', language: 'lucene' }, + filters: [], + field: { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + exists: true, + }; + + data.fieldFormats = ({ + getDefaultInstance: jest.fn(() => ({ + convert: jest.fn((s: unknown) => JSON.stringify(s)), + })), + } as unknown) as DataPublicPluginStart['fieldFormats']; + }); + + it('should request field stats without a time field, if the index pattern has none', async () => { + indexPattern.timeFieldName = undefined; + core.http.post.mockImplementationOnce(() => { + return Promise.resolve({}); + }); + const wrapper = mountWithIntl(); + + await act(async () => { + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + }); + + expect(core.http.post).toHaveBeenCalledWith( + '/api/lens/index_stats/my-fake-index-pattern/field', + expect.anything() + ); + // Function argument types not detected correctly (https://github.com/microsoft/TypeScript/issues/26591) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { body } = (core.http.post.mock.calls[0] as any)[1]; + expect(JSON.parse(body)).not.toHaveProperty('timeFieldName'); + }); + + it('should request field stats every time the button is clicked', async () => { + let resolveFunction: (arg: unknown) => void; + + core.http.post.mockImplementation(() => { + return new Promise(resolve => { + resolveFunction = resolve; + }); + }); + + const wrapper = mountWithIntl(); + + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + + expect(core.http.post).toHaveBeenCalledWith( + `/api/lens/index_stats/my-fake-index-pattern/field`, + { + body: JSON.stringify({ + dslQuery: { + bool: { + must: [{ match_all: {} }], + filter: [], + should: [], + must_not: [], + }, + }, + fromDate: 'now-7d', + toDate: 'now', + timeFieldName: 'timestamp', + field: { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + }), + } + ); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(true); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + await act(async () => { + resolveFunction!({ + totalDocuments: 4633, + sampledDocuments: 4633, + sampledValues: 4633, + histogram: { + buckets: [{ count: 705, key: 0 }], + }, + topValues: { + buckets: [{ count: 147, key: 0 }], + }, + }); + }); + + wrapper.update(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + expect(core.http.post).toHaveBeenCalledTimes(1); + + act(() => { + const closePopover = wrapper.find(EuiPopover).prop('closePopover'); + + closePopover(); + }); + + expect(wrapper.find(EuiPopover).prop('isOpen')).toEqual(false); + + act(() => { + wrapper.setProps({ + dateRange: { + fromDate: 'now-14d', + toDate: 'now-7d', + }, + query: { query: 'geo.src : "US"', language: 'kuery' }, + filters: [ + { + match: { phrase: { 'geo.dest': 'US' } }, + }, + ], + }); + }); + + wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + + expect(core.http.post).toHaveBeenCalledTimes(2); + expect(core.http.post).toHaveBeenLastCalledWith( + `/api/lens/index_stats/my-fake-index-pattern/field`, + { + body: JSON.stringify({ + dslQuery: { + bool: { + must: [], + filter: [ + { + bool: { + should: [{ match_phrase: { 'geo.src': 'US' } }], + minimum_should_match: 1, + }, + }, + { + match: { phrase: { 'geo.dest': 'US' } }, + }, + ], + should: [], + must_not: [], + }, + }, + fromDate: 'now-14d', + toDate: 'now-7d', + timeFieldName: 'timestamp', + field: { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + }), + } + ); + }); +}); diff --git a/x-pack/plugins/lens/public/expression_datasource/field_item.tsx b/x-pack/plugins/lens/public/expression_datasource/field_item.tsx new file mode 100644 index 0000000000000..5f0fa95ad0022 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/field_item.tsx @@ -0,0 +1,538 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import DateMath from '@elastic/datemath'; +import { + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiKeyboardAccessible, + EuiLoadingSpinner, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiProgress, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; +import { + Axis, + BarSeries, + Chart, + niceTimeFormatter, + Position, + ScaleType, + Settings, + TooltipType, +} from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { + Query, + KBN_FIELD_TYPES, + ES_FIELD_TYPES, + Filter, + esQuery, + IIndexPattern, +} from '../../../../../src/plugins/data/public'; +import { DraggedField } from './indexpattern'; +import { DragDrop } from '../drag_drop'; +import { DatasourceDataPanelProps, DataType } from '../types'; +import { BucketedAggregation, FieldStatsResponse } from '../../common'; +import { IndexPattern, IndexPatternField } from './types'; +import { LensFieldIcon } from './lens_field_icon'; +import { trackUiEvent } from '../lens_ui_telemetry'; + +export interface FieldItemProps { + core: DatasourceDataPanelProps['core']; + data: DataPublicPluginStart; + field: IndexPatternField; + indexPattern: IndexPattern; + highlight?: string; + exists: boolean; + query: Query; + dateRange: DatasourceDataPanelProps['dateRange']; + filters: Filter[]; + hideDetails?: boolean; +} + +interface State { + isLoading: boolean; + totalDocuments?: number; + sampledDocuments?: number; + sampledValues?: number; + histogram?: BucketedAggregation; + topValues?: BucketedAggregation; +} + +function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; +} + +export function FieldItem(props: FieldItemProps) { + const { + core, + field, + indexPattern, + highlight, + exists, + query, + dateRange, + filters, + hideDetails, + } = props; + + const [infoIsOpen, setOpen] = useState(false); + + const [state, setState] = useState({ + isLoading: false, + }); + + const wrappableName = wrapOnDot(field.name)!; + const wrappableHighlight = wrapOnDot(highlight); + const highlightIndex = wrappableHighlight + ? wrappableName.toLowerCase().indexOf(wrappableHighlight.toLowerCase()) + : -1; + const wrappableHighlightableFieldName = + highlightIndex < 0 ? ( + wrappableName + ) : ( + + {wrappableName.substr(0, highlightIndex)} + {wrappableName.substr(highlightIndex, wrappableHighlight.length)} + {wrappableName.substr(highlightIndex + wrappableHighlight.length)} + + ); + + function fetchData() { + if ( + state.isLoading || + (field.type !== 'number' && + field.type !== 'string' && + field.type !== 'date' && + field.type !== 'boolean' && + field.type !== 'ip') + ) { + return; + } + + setState(s => ({ ...s, isLoading: true })); + + core.http + .post(`/api/lens/index_stats/${indexPattern.title}/field`, { + body: JSON.stringify({ + dslQuery: esQuery.buildEsQuery( + indexPattern as IIndexPattern, + query, + filters, + esQuery.getEsQueryConfig(core.uiSettings) + ), + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, + timeFieldName: indexPattern.timeFieldName, + field, + }), + }) + .then((results: FieldStatsResponse) => { + setState(s => ({ + ...s, + isLoading: false, + totalDocuments: results.totalDocuments, + sampledDocuments: results.sampledDocuments, + sampledValues: results.sampledValues, + histogram: results.histogram, + topValues: results.topValues, + })); + }) + .catch(() => { + setState(s => ({ ...s, isLoading: false })); + }); + } + + function togglePopover() { + if (hideDetails) { + return; + } + + setOpen(!infoIsOpen); + if (!infoIsOpen) { + trackUiEvent('indexpattern_field_info_click'); + fetchData(); + } + } + + return ( + ('.application') || undefined} + button={ + + +
{ + togglePopover(); + }} + onKeyPress={event => { + if (event.key === 'ENTER') { + togglePopover(); + } + }} + aria-label={i18n.translate('xpack.lens.indexPattern.fieldStatsButtonLabel', { + defaultMessage: 'Click for a field preview, or drag and drop to visualize.', + })} + > + + + + {wrappableHighlightableFieldName} + + + +
+
+
+ } + isOpen={infoIsOpen} + closePopover={() => setOpen(false)} + anchorPosition="rightUp" + panelClassName="lnsFieldItem__fieldPopoverPanel" + > + +
+ ); +} + +function FieldItemPopoverContents(props: State & FieldItemProps) { + const { + histogram, + topValues, + indexPattern, + field, + dateRange, + core, + sampledValues, + data: { fieldFormats }, + } = props; + + const IS_DARK_THEME = core.uiSettings.get('theme:darkMode'); + const chartTheme = IS_DARK_THEME ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; + let histogramDefault = !!props.histogram; + + const totalValuesCount = + topValues && topValues.buckets.reduce((prev, bucket) => bucket.count + prev, 0); + const otherCount = sampledValues && totalValuesCount ? sampledValues - totalValuesCount : 0; + + if ( + totalValuesCount && + histogram && + histogram.buckets.length && + topValues && + topValues.buckets.length + ) { + // Default to histogram when top values are less than 10% of total + histogramDefault = otherCount / totalValuesCount > 0.9; + } + + const [showingHistogram, setShowingHistogram] = useState(histogramDefault); + + let formatter: { convert: (data: unknown) => string }; + if (indexPattern.fieldFormatMap && indexPattern.fieldFormatMap[field.name]) { + const FormatType = fieldFormats.getType(indexPattern.fieldFormatMap[field.name].id); + if (FormatType) { + formatter = new FormatType( + indexPattern.fieldFormatMap[field.name].params, + core.uiSettings.get.bind(core.uiSettings) + ); + } else { + formatter = { convert: (data: unknown) => JSON.stringify(data) }; + } + } else { + formatter = fieldFormats.getDefaultInstance( + field.type as KBN_FIELD_TYPES, + field.esTypes as ES_FIELD_TYPES[] + ); + } + + const fromDate = DateMath.parse(dateRange.fromDate); + const toDate = DateMath.parse(dateRange.toDate); + + let title = <>; + + if (props.isLoading) { + return ; + } else if ( + (!props.histogram || props.histogram.buckets.length === 0) && + (!props.topValues || props.topValues.buckets.length === 0) + ) { + return ( + + {i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { + defaultMessage: 'No data to display.', + })} + + ); + } + + if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) { + title = ( + { + setShowingHistogram(optionId === 'histogram'); + }} + idSelected={showingHistogram ? 'histogram' : 'topValues'} + /> + ); + } else if (field.type === 'date') { + title = ( + <> + {i18n.translate('xpack.lens.indexPattern.fieldTimeDistributionLabel', { + defaultMessage: 'Time distribution', + })} + + ); + } else if (topValues && topValues.buckets.length) { + title = ( + <> + {i18n.translate('xpack.lens.indexPattern.fieldTopValuesLabel', { + defaultMessage: 'Top values', + })} + + ); + } + + function wrapInPopover(el: React.ReactElement) { + return ( + <> + {title ? {title} : <>} + {el} + + {props.totalDocuments ? ( + + + {props.sampledDocuments && ( + <> + {i18n.translate('xpack.lens.indexPattern.percentageOfLabel', { + defaultMessage: '{percentage}% of', + values: { + percentage: Math.round((props.sampledDocuments / props.totalDocuments) * 100), + }, + })} + + )}{' '} + + {fieldFormats + .getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER]) + .convert(props.totalDocuments)} + {' '} + {i18n.translate('xpack.lens.indexPattern.ofDocumentsLabel', { + defaultMessage: 'documents', + })} + + + ) : ( + <> + )} + + ); + } + + if (histogram && histogram.buckets.length) { + const specId = i18n.translate('xpack.lens.indexPattern.fieldStatsCountLabel', { + defaultMessage: 'Count', + }); + + if (field.type === 'date') { + return wrapInPopover( + + + + + + + + ); + } else if (showingHistogram || !topValues || !topValues.buckets.length) { + return wrapInPopover( + + + + formatter.convert(d)} + /> + + + + ); + } + } + + if (props.topValues && props.topValues.buckets.length) { + return wrapInPopover( +
+ {props.topValues.buckets.map(topValue => { + const formatted = formatter.convert(topValue.key); + return ( +
+ + + {formatted === '' ? ( + + + {i18n.translate('xpack.lens.indexPattern.fieldPanelEmptyStringValue', { + defaultMessage: 'Empty string', + })} + + + ) : ( + + + {formatted} + + + )} + + + + {Math.round((topValue.count / props.sampledValues!) * 100)}% + + + + + +
+ ); + })} + {otherCount ? ( + <> + + + + {i18n.translate('xpack.lens.indexPattern.otherDocsLabel', { + defaultMessage: 'Other', + })} + + + + + + {Math.round((otherCount / props.sampledValues!) * 100)}% + + + + + + + ) : ( + <> + )} +
+ ); + } + return <>; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/index.ts b/x-pack/plugins/lens/public/expression_datasource/index.ts new file mode 100644 index 0000000000000..1de66b176b2e8 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/index.ts @@ -0,0 +1,47 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup } from 'kibana/public'; +import { get } from 'lodash'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { getExpressionBasedDatasource } from './expressionbased'; +import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, +} from '../../../../../src/plugins/data/public'; +import { Datasource, EditorFrameSetup } from '../types'; + +export interface IndexPatternDatasourceSetupPlugins { + expressions: ExpressionsSetup; + data: DataPublicPluginSetup; + editorFrame: EditorFrameSetup; +} + +export interface IndexPatternDatasourceStartPlugins { + data: DataPublicPluginStart; +} + +export class ExpressionBasedDatasource { + constructor() {} + + setup( + core: CoreSetup, + { expressions, editorFrame }: IndexPatternDatasourceSetupPlugins + ) { + editorFrame.registerDatasource( + core.getStartServices().then(([coreStart, { data }]) => + getExpressionBasedDatasource({ + core: coreStart, + storage: new Storage(localStorage), + data, + expressions, + }) + ) as Promise + ); + } +} diff --git a/x-pack/plugins/lens/public/expression_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/expression_datasource/indexpattern.test.ts new file mode 100644 index 0000000000000..6307cbc3c91ba --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/indexpattern.test.ts @@ -0,0 +1,557 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { getExpressionBasedDatasource, IndexPatternColumn, uniqueLabels } from './indexpattern'; +import { DatasourcePublicAPI, Operation, Datasource } from '../types'; +import { coreMock } from 'src/core/public/mocks'; +import { ExpressionBasedPersistedState, ExpressionBasedPrivateState } from './types'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { Ast } from '@kbn/interpreter/common'; + +jest.mock('./loader'); +jest.mock('../id_generator'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + 2: { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + // Ignored in the UI + histogram: { + agg: 'histogram', + interval: 1000, + }, + avg: { + agg: 'avg', + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, +}; + +function stateFromPersistedState( + persistedState: ExpressionBasedPersistedState +): ExpressionBasedPrivateState { + return { + currentIndexPatternId: persistedState.currentIndexPatternId, + layers: persistedState.layers, + indexPatterns: expectedIndexPatterns, + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: true, + }; +} + +describe('IndexPattern Data Source', () => { + let persistedState: ExpressionBasedPersistedState; + let indexPatternDatasource: Datasource; + + beforeEach(() => { + indexPatternDatasource = getExpressionBasedDatasource({ + storage: {} as IStorageWrapper, + core: coreMock.createStart(), + data: dataPluginMock.createStartContract(), + }); + + persistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }, + }, + }, + }; + }); + + describe('uniqueLabels', () => { + it('appends a suffix to duplicates', () => { + const col: IndexPatternColumn = { + dataType: 'number', + isBucketed: false, + label: 'Foo', + operationType: 'count', + sourceField: 'Records', + }; + const map = uniqueLabels({ + a: { + columnOrder: ['a', 'b'], + columns: { + a: col, + b: col, + }, + indexPatternId: 'foo', + }, + b: { + columnOrder: ['c', 'd'], + columns: { + c: col, + d: { + ...col, + label: 'Foo [1]', + }, + }, + indexPatternId: 'foo', + }, + }); + + expect(map).toMatchInlineSnapshot(` + Object { + "a": "Foo", + "b": "Foo [1]", + "c": "Foo [2]", + "d": "Foo [1] [1]", + } + `); + }); + }); + + describe('#getPersistedState', () => { + it('should persist from saved state', async () => { + const state = stateFromPersistedState(persistedState); + + expect(indexPatternDatasource.getPersistableState(state)).toEqual(persistedState); + }); + }); + + describe('#toExpression', () => { + it('should generate an empty expression when no columns are selected', async () => { + const state = await indexPatternDatasource.initialize(); + expect(indexPatternDatasource.toExpression(state, 'first')).toEqual(null); + }); + + it('should generate an expression for an aggregated query', async () => { + const queryPersistedState: ExpressionBasedPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object { + "aggConfigs": Array [ + "[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]", + ], + "includeFormatHints": Array [ + true, + ], + "index": Array [ + "1", + ], + "metricsAtAllLevels": Array [ + true, + ], + "partialRows": Array [ + true, + ], + "timeFields": Array [ + "timestamp", + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "idMap": Array [ + "{\\"col--1-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-2-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", + ], + }, + "function": "lens_rename_columns", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + + it('should put all time fields used in date_histograms to the esaggs timeFields parameter', async () => { + const queryPersistedState: ExpressionBasedPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + col3: { + label: 'Date 2', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'another_datefield', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); + }); + + it('should not put date fields used outside date_histograms to the esaggs timeFields parameter', async () => { + const queryPersistedState: ExpressionBasedPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count of records', + dataType: 'date', + isBucketed: false, + sourceField: 'timefield', + operationType: 'cardinality', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'auto', + }, + }, + }, + }, + }, + }; + + const state = stateFromPersistedState(queryPersistedState); + + const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; + expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']); + expect(ast.chain[0].arguments.timeFields).not.toContain('timefield'); + }); + }); + + describe('#insertLayer', () => { + it('should insert an empty layer into the previous state', () => { + const state = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + showEmptyFields: false, + }; + expect(indexPatternDatasource.insertLayer(state, 'newLayer')).toEqual({ + ...state, + layers: { + ...state.layers, + newLayer: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + }); + }); + }); + + describe('#removeLayer', () => { + it('should remove a layer', () => { + const state = { + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }; + expect(indexPatternDatasource.removeLayer(state, 'first')).toEqual({ + ...state, + layers: { + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + }); + }); + }); + + describe('#getLayers', () => { + it('should list the current layers', () => { + expect( + indexPatternDatasource.getLayers({ + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual(['first', 'second']); + }); + }); + + describe('#getMetadata', () => { + it('should return the title of the index patterns', () => { + expect( + indexPatternDatasource.getMetaData({ + indexPatternRefs: [], + existingFields: {}, + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual({ + filterableIndexPatterns: [ + { + id: '1', + title: 'my-fake-index-pattern', + }, + { + id: '2', + title: 'my-fake-restricted-pattern', + }, + ], + }); + }); + }); + + describe('#getPublicAPI', () => { + let publicAPI: DatasourcePublicAPI; + + beforeEach(async () => { + const initialState = stateFromPersistedState(persistedState); + publicAPI = indexPatternDatasource.getPublicAPI({ + state: initialState, + layerId: 'first', + dateRange: { + fromDate: 'now-30d', + toDate: 'now', + }, + }); + }); + + describe('getTableSpec', () => { + it('should include col1', () => { + expect(publicAPI.getTableSpec()).toEqual([ + { + columnId: 'col1', + }, + ]); + }); + }); + + describe('getOperationForColumnId', () => { + it('should get an operation for col1', () => { + expect(publicAPI.getOperationForColumnId('col1')).toEqual({ + label: 'My Op', + dataType: 'string', + isBucketed: true, + } as Operation); + }); + + it('should return null for non-existant columns', () => { + expect(publicAPI.getOperationForColumnId('col2')).toBe(null); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/expression_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/expression_datasource/layerpanel.test.tsx new file mode 100644 index 0000000000000..b681eaad26b5f --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/layerpanel.test.tsx @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ExpressionBasedPrivateState } from './types'; +import { IndexPatternLayerPanelProps, LayerPanel } from './layerpanel'; +import { shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +import { ShallowWrapper } from 'enzyme'; +import { EuiSelectable, EuiSelectableList } from '@elastic/eui'; +import { ChangeIndexPattern } from './change_indexpattern'; + +jest.mock('./state_helpers'); + +const initialState: ExpressionBasedPrivateState = { + indexPatternRefs: [ + { id: '1', title: 'my-fake-index-pattern' }, + { id: '2', title: 'my-fake-restricted-pattern' }, + { id: '3', title: 'my-compatible-pattern' }, + ], + existingFields: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'source', + params: { + size: 5, + orderDirection: 'asc', + orderBy: { + type: 'alphabetical', + }, + }, + }, + col2: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + operationType: 'avg', + sourceField: 'memory', + }, + }, + }, + }, + indexPatterns: { + '1': { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'unsupported', + type: 'geo', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + '2': { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + histogram: { + agg: 'histogram', + interval: 1000, + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, + '3': { + id: '3', + title: 'my-compatible-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + }, +}; +describe('Layer Data Panel', () => { + let defaultProps: IndexPatternLayerPanelProps; + + beforeEach(() => { + defaultProps = { + layerId: 'first', + state: initialState, + setState: jest.fn(), + onChangeIndexPattern: jest.fn(async () => {}), + }; + }); + + function getIndexPatternPickerList(instance: ShallowWrapper) { + return instance + .find(ChangeIndexPattern) + .first() + .dive() + .find(EuiSelectable); + } + + function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { + const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( + instance + ).map(option => + option.label === selectedLabel + ? { ...option, checked: 'on' } + : { ...option, checked: undefined } + ); + return getIndexPatternPickerList(instance).prop('onChange')!(options); + } + + function getIndexPatternPickerOptions(instance: ShallowWrapper) { + return getIndexPatternPickerList(instance) + .dive() + .find(EuiSelectableList) + .prop('options'); + } + + it('should list all index patterns', () => { + const instance = shallow(); + + expect(getIndexPatternPickerOptions(instance)!.map(option => option.label)).toEqual([ + 'my-fake-index-pattern', + 'my-fake-restricted-pattern', + 'my-compatible-pattern', + ]); + }); + + it('should switch data panel to target index pattern', () => { + const instance = shallow(); + + selectIndexPatternPickerOption(instance, 'my-compatible-pattern'); + + expect(defaultProps.onChangeIndexPattern).toHaveBeenCalledWith('3'); + }); +}); diff --git a/x-pack/plugins/lens/public/expression_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/expression_datasource/layerpanel.tsx new file mode 100644 index 0000000000000..267ffda446992 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/layerpanel.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { DatasourceLayerPanelProps } from '../types'; +import { ExpressionBasedPrivateState } from './types'; +import { ChangeIndexPattern } from './change_indexpattern'; + +export interface IndexPatternLayerPanelProps + extends DatasourceLayerPanelProps { + state: ExpressionBasedPrivateState; + onChangeIndexPattern: (newId: string) => void; +} + +export function LayerPanel({ state, layerId, onChangeIndexPattern }: IndexPatternLayerPanelProps) { + const layer = state.layers[layerId]; + + return ( + + + + ); +} diff --git a/x-pack/plugins/lens/public/expression_datasource/lens_field_icon.test.tsx b/x-pack/plugins/lens/public/expression_datasource/lens_field_icon.test.tsx new file mode 100644 index 0000000000000..317ce8f032f94 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/lens_field_icon.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; +import { LensFieldIcon } from './lens_field_icon'; + +test('LensFieldIcon renders properly', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('LensFieldIcon accepts FieldIcon props', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/lens/public/expression_datasource/lens_field_icon.tsx b/x-pack/plugins/lens/public/expression_datasource/lens_field_icon.tsx new file mode 100644 index 0000000000000..bcc83e799d889 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/lens_field_icon.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FieldIcon, FieldIconProps } from '../../../../../src/plugins/kibana_react/public'; +import { DataType } from '../types'; +import { normalizeOperationDataType } from './utils'; + +export function LensFieldIcon({ type, ...rest }: FieldIconProps & { type: DataType }) { + return ( + + ); +} diff --git a/x-pack/plugins/lens/public/expression_datasource/mocks.ts b/x-pack/plugins/lens/public/expression_datasource/mocks.ts new file mode 100644 index 0000000000000..dff3e61342a6a --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/mocks.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DragContextState } from '../drag_drop'; +import { IndexPattern } from './types'; + +export const createMockedIndexPattern = (): IndexPattern => ({ + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], +}); + +export const createMockedRestrictedIndexPattern = () => ({ + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + typeMeta: { + params: { + rollup_index: 'my-fake-index-pattern', + }, + aggs: { + terms: { + source: { + agg: 'terms', + }, + }, + date_histogram: { + timestamp: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + histogram: { + bytes: { + agg: 'histogram', + interval: 1000, + }, + }, + avg: { + bytes: { + agg: 'avg', + }, + }, + max: { + bytes: { + agg: 'max', + }, + }, + min: { + bytes: { + agg: 'min', + }, + }, + sum: { + bytes: { + agg: 'sum', + }, + }, + }, + }, +}); + +export function createMockedDragDropContext(): jest.Mocked { + return { + dragging: undefined, + setDragging: jest.fn(), + }; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/pure_helpers.test.ts b/x-pack/plugins/lens/public/expression_datasource/pure_helpers.test.ts new file mode 100644 index 0000000000000..05b00a66e8348 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/pure_helpers.test.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fieldExists } from './pure_helpers'; + +describe('fieldExists', () => { + it('returns whether or not a field exists', () => { + expect(fieldExists({ a: { b: true } }, 'a', 'b')).toBeTruthy(); + expect(fieldExists({ a: { b: true } }, 'a', 'c')).toBeFalsy(); + expect(fieldExists({ a: { b: true } }, 'b', 'b')).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/lens/public/expression_datasource/pure_helpers.ts b/x-pack/plugins/lens/public/expression_datasource/pure_helpers.ts new file mode 100644 index 0000000000000..f214d4aa20d47 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/pure_helpers.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionBasedPrivateState } from './types'; + +export function fieldExists( + existingFields: ExpressionBasedPrivateState['existingFields'], + indexPatternTitle: string, + fieldName: string +) { + return existingFields[indexPatternTitle] && existingFields[indexPatternTitle][fieldName]; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/rename_columns.test.ts b/x-pack/plugins/lens/public/expression_datasource/rename_columns.test.ts new file mode 100644 index 0000000000000..4bfd6a4f93c75 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/rename_columns.test.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renameColumns } from './rename_columns'; +import { KibanaDatatable } from '../../../../../src/plugins/expressions/public'; +import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; + +describe('rename_columns', () => { + it('should rename columns of a given datatable', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }; + + const idMap = { + a: { + id: 'b', + label: 'Austrailia', + }, + b: { + id: 'c', + label: 'Boomerang', + }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "b", + "name": "Austrailia", + }, + Object { + "id": "c", + "name": "Boomerang", + }, + ], + "rows": Array [ + Object { + "b": 1, + "c": 2, + }, + Object { + "b": 3, + "c": 4, + }, + Object { + "b": 5, + "c": 6, + }, + Object { + "b": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", + } + `); + }); + + it('should replace "" with a visible value', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'a', name: 'A' }], + rows: [{ a: '' }], + }; + + const idMap = { + a: { + id: 'a', + label: 'Austrailia', + }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result.rows[0].a).toEqual('(empty)'); + }); + + it('should keep columns which are not mapped', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }; + + const idMap = { + b: { id: 'c', label: 'Catamaran' }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "a", + "name": "A", + }, + Object { + "id": "c", + "name": "Catamaran", + }, + ], + "rows": Array [ + Object { + "a": 1, + "c": 2, + }, + Object { + "a": 3, + "c": 4, + }, + Object { + "a": 5, + "c": 6, + }, + Object { + "a": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", + } + `); + }); + + it('should rename date histograms', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'banana per 30 seconds' }, + ], + rows: [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + { a: 5, b: 6 }, + { a: 7, b: 8 }, + ], + }; + + const idMap = { + b: { id: 'c', label: 'Apple', operationType: 'date_histogram', sourceField: 'banana' }, + }; + + const result = renameColumns.fn( + input, + { idMap: JSON.stringify(idMap) }, + createMockExecutionContext() + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "id": "a", + "name": "A", + }, + Object { + "id": "c", + "name": "Apple per 30 seconds", + }, + ], + "rows": Array [ + Object { + "a": 1, + "c": 2, + }, + Object { + "a": 3, + "c": 4, + }, + Object { + "a": 5, + "c": 6, + }, + Object { + "a": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", + } + `); + }); +}); diff --git a/x-pack/plugins/lens/public/expression_datasource/rename_columns.ts b/x-pack/plugins/lens/public/expression_datasource/rename_columns.ts new file mode 100644 index 0000000000000..248eb12ec8026 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/rename_columns.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { + ExpressionFunctionDefinition, + KibanaDatatable, + KibanaDatatableColumn, +} from 'src/plugins/expressions'; +import { IndexPatternColumn } from './operations'; + +interface RemapArgs { + idMap: string; +} + +export type OriginalColumn = { id: string } & IndexPatternColumn; + +export const renameColumns: ExpressionFunctionDefinition< + 'lens_rename_columns', + KibanaDatatable, + RemapArgs, + KibanaDatatable +> = { + name: 'lens_rename_columns', + type: 'kibana_datatable', + help: i18n.translate('xpack.lens.functions.renameColumns.help', { + defaultMessage: 'A helper to rename the columns of a datatable', + }), + args: { + idMap: { + types: ['string'], + help: i18n.translate('xpack.lens.functions.renameColumns.idMap.help', { + defaultMessage: + 'A JSON encoded object in which keys are the old column ids and values are the corresponding new ones. All other columns ids are kept.', + }), + }, + }, + inputTypes: ['kibana_datatable'], + fn(data, { idMap: encodedIdMap }) { + const idMap = JSON.parse(encodedIdMap) as Record; + + return { + type: 'kibana_datatable', + rows: data.rows.map(row => { + const mappedRow: Record = {}; + Object.entries(idMap).forEach(([fromId, toId]) => { + mappedRow[toId.id] = row[fromId]; + }); + + Object.entries(row).forEach(([id, value]) => { + if (id in idMap) { + mappedRow[idMap[id].id] = sanitizeValue(value); + } else { + mappedRow[id] = sanitizeValue(value); + } + }); + + return mappedRow; + }), + columns: data.columns.map(column => { + const mappedItem = idMap[column.id]; + + if (!mappedItem) { + return column; + } + + return { + ...column, + id: mappedItem.id, + name: getColumnName(mappedItem, column), + }; + }), + }; + }, +}; + +function getColumnName(originalColumn: OriginalColumn, newColumn: KibanaDatatableColumn) { + if (originalColumn && originalColumn.operationType === 'date_histogram') { + const fieldName = originalColumn.sourceField; + + // HACK: This is a hack, and introduces some fragility into + // column naming. Eventually, this should be calculated and + // built more systematically. + return newColumn.name.replace(fieldName, originalColumn.label); + } + + return originalColumn.label; +} + +function sanitizeValue(value: unknown) { + if (value === '') { + return i18n.translate('xpack.lens.indexpattern.emptyTextColumnValue', { + defaultMessage: '(empty)', + }); + } + + return value; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/state_helpers.test.ts b/x-pack/plugins/lens/public/expression_datasource/state_helpers.test.ts new file mode 100644 index 0000000000000..f4b58ee8fb7c9 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/state_helpers.test.ts @@ -0,0 +1,732 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + updateColumnParam, + changeColumn, + getColumnOrder, + deleteColumn, + updateLayerIndexPattern, +} from './state_helpers'; +import { operationDefinitionMap } from './operations'; +import { TermsIndexPatternColumn } from './operations/definitions/terms'; +import { DateHistogramIndexPatternColumn } from './operations/definitions/date_histogram'; +import { AvgIndexPatternColumn } from './operations/definitions/metrics'; +import { IndexPattern, ExpressionBasedPrivateState, ExpressionBasedLayer } from './types'; + +jest.mock('./operations'); + +describe('state_helpers', () => { + describe('deleteColumn', () => { + it('should remove column', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'column', columnId: 'col2' }, + orderDirection: 'desc', + size: 5, + }, + }; + + const state: ExpressionBasedPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }; + + expect( + deleteColumn({ state, columnId: 'col2', layerId: 'first' }).layers.first.columns + ).toEqual({ + col1: { + ...termsColumn, + params: { + ...termsColumn.params, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }); + }); + + it('should adjust when deleting other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }; + + const state: ExpressionBasedPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }; + + deleteColumn({ + state, + columnId: 'col2', + layerId: 'first', + }); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + }); + }); + }); + + describe('updateColumnParam', () => { + it('should set the param for the given column', () => { + const currentColumn: DateHistogramIndexPatternColumn = { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }; + + const state: ExpressionBasedPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }, + }, + }; + + expect( + updateColumnParam({ + state, + layerId: 'first', + currentColumn, + paramName: 'interval', + value: 'M', + }).layers.first.columns.col1 + ).toEqual({ + ...currentColumn, + params: { interval: 'M' }, + }); + }); + + it('should set optional params', () => { + const currentColumn: AvgIndexPatternColumn = { + label: 'Avg of bytes', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'avg', + sourceField: 'bytes', + }; + + const state: ExpressionBasedPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }, + }, + }; + + expect( + updateColumnParam({ + state, + layerId: 'first', + currentColumn, + paramName: 'format', + value: { id: 'bytes' }, + }).layers.first.columns.col1 + ).toEqual({ + ...currentColumn, + params: { format: { id: 'bytes' } }, + }); + }); + }); + + describe('changeColumn', () => { + it('should update order on changing the column', () => { + const state: ExpressionBasedPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col2: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }, + }, + }; + expect( + changeColumn({ + state, + columnId: 'col2', + layerId: 'first', + newColumn: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }) + ).toEqual({ + ...state, + layers: { + first: expect.objectContaining({ + columnOrder: ['col2', 'col1'], + }), + }, + }); + }); + + it('should carry over params from old column if the operation type stays the same', () => { + const state: ExpressionBasedPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }, + }, + }, + }; + expect( + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn: { + label: 'Date histogram of order_date', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'order_date', + params: { + interval: 'w', + }, + }, + }).layers.first.columns.col1 + ).toEqual( + expect.objectContaining({ + params: { interval: 'h' }, + }) + ); + }); + + it('should execute adjustments for other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }; + + const newColumn: AvgIndexPatternColumn = { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }; + + const state: ExpressionBasedPrivateState = { + indexPatternRefs: [], + existingFields: {}, + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }; + + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn, + }); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + col2: newColumn, + }); + }); + }); + + describe('getColumnOrder', () => { + it('should work for empty columns', () => { + expect(getColumnOrder({})).toEqual([]); + }); + + it('should work for one column', () => { + expect( + getColumnOrder({ + col1: { + label: 'Value of timestamp', + dataType: 'string', + isBucketed: false, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }) + ).toEqual(['col1']); + }); + + it('should put any number of aggregations before metrics', () => { + expect( + getColumnOrder({ + col1: { + label: 'Top values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + }, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col3: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col1', 'col3', 'col2']); + }); + + it('should reorder aggregations based on suggested priority', () => { + expect( + getColumnOrder({ + col1: { + label: 'Top values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + suggestedPriority: 2, + }, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + suggestedPriority: 0, + }, + col3: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + suggestedPriority: 1, + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col3', 'col1', 'col2']); + }); + }); + + describe('updateLayerIndexPattern', () => { + const indexPattern: IndexPattern = { + id: 'test', + title: '', + fields: [ + { + name: 'fieldA', + aggregatable: true, + searchable: true, + type: 'string', + }, + { + name: 'fieldB', + aggregatable: true, + searchable: true, + type: 'number', + aggregationRestrictions: { + avg: { + agg: 'avg', + }, + }, + }, + { + name: 'fieldC', + aggregatable: false, + searchable: true, + type: 'date', + }, + { + name: 'fieldD', + aggregatable: true, + searchable: true, + type: 'date', + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'CET', + calendar_interval: 'w', + }, + }, + }, + { + name: 'fieldE', + aggregatable: true, + searchable: true, + type: 'date', + }, + ], + }; + + it('should switch index pattern id in layer', () => { + const layer = { columnOrder: [], columns: {}, indexPatternId: 'original' }; + expect(updateLayerIndexPattern(layer, indexPattern)).toEqual({ + ...layer, + indexPatternId: 'test', + }); + }); + + it('should remove operations referencing unavailable fields', () => { + const layer: ExpressionBasedLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'xxx', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + + it('should remove operations referencing fields with insufficient capabilities', () => { + const layer: ExpressionBasedLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'fieldC', + params: { + interval: 'd', + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'fieldB', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col2']); + expect(updatedLayer.columns).toEqual({ + col2: layer.columns.col2, + }); + }); + + it('should rewrite column params if that is necessary due to restrictions', () => { + const layer: ExpressionBasedLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'date', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'fieldD', + params: { + interval: 'd', + }, + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: { + ...layer.columns.col1, + params: { + interval: 'w', + timeZone: 'CET', + }, + }, + }); + }); + + it('should remove operations referencing fields with wrong field types', () => { + const layer: ExpressionBasedLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'fieldD', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + + it('should remove operations referencing fields with incompatible restrictions', () => { + const layer: ExpressionBasedLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'min', + sourceField: 'fieldC', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/expression_datasource/state_helpers.ts b/x-pack/plugins/lens/public/expression_datasource/state_helpers.ts new file mode 100644 index 0000000000000..8c152d2108fc9 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/state_helpers.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { isColumnTransferable } from './operations'; +import { operationDefinitionMap, IndexPatternColumn } from './operations'; +import { IndexPattern, ExpressionBasedPrivateState, ExpressionBasedLayer } from './types'; + +export function updateColumnParam({ + state, + layerId, + currentColumn, + paramName, + value, +}: { + state: ExpressionBasedPrivateState; + layerId: string; + currentColumn: C; + paramName: string; + value: unknown; +}): ExpressionBasedPrivateState { + const columnId = Object.entries(state.layers[layerId].columns).find( + ([_columnId, column]) => column === currentColumn + )![0]; + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columns: { + ...state.layers[layerId].columns, + [columnId]: { + ...currentColumn, + params: { + ...currentColumn.params, + [paramName]: value, + }, + }, + }, + }, + }, + }; +} + +function adjustColumnReferencesForChangedColumn( + columns: Record, + columnId: string +) { + const newColumns = { ...columns }; + Object.keys(newColumns).forEach(currentColumnId => { + if (currentColumnId !== columnId) { + const currentColumn = newColumns[currentColumnId]; + const operationDefinition = operationDefinitionMap[currentColumn.operationType]; + newColumns[currentColumnId] = operationDefinition.onOtherColumnChanged + ? operationDefinition.onOtherColumnChanged(currentColumn, newColumns) + : currentColumn; + } + }); + return newColumns; +} + +export function changeColumn({ + state, + layerId, + columnId, + newColumn, + keepParams = true, +}: { + state: ExpressionBasedPrivateState; + layerId: string; + columnId: string; + newColumn: C; + keepParams?: boolean; +}): ExpressionBasedPrivateState { + const oldColumn = state.layers[layerId].columns[columnId]; + + const updatedColumn = + keepParams && + oldColumn && + oldColumn.operationType === newColumn.operationType && + 'params' in oldColumn + ? { ...newColumn, params: oldColumn.params } + : newColumn; + + const newColumns = adjustColumnReferencesForChangedColumn( + { + ...state.layers[layerId].columns, + [columnId]: updatedColumn, + }, + columnId + ); + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }, + }, + }; +} + +export function deleteColumn({ + state, + layerId, + columnId, +}: { + state: ExpressionBasedPrivateState; + layerId: string; + columnId: string; +}): ExpressionBasedPrivateState { + const hypotheticalColumns = { ...state.layers[layerId].columns }; + delete hypotheticalColumns[columnId]; + + const newColumns = adjustColumnReferencesForChangedColumn(hypotheticalColumns, columnId); + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }, + }, + }; +} + +export function getColumnOrder(columns: Record): string[] { + const entries = Object.entries(columns); + + const [aggregations, metrics] = _.partition(entries, ([id, col]) => col.isBucketed); + + return aggregations + .sort(([id, col], [id2, col2]) => { + return ( + // Sort undefined orders last + (col.suggestedPriority !== undefined ? col.suggestedPriority : Number.MAX_SAFE_INTEGER) - + (col2.suggestedPriority !== undefined ? col2.suggestedPriority : Number.MAX_SAFE_INTEGER) + ); + }) + .map(([id]) => id) + .concat(metrics.map(([id]) => id)); +} + +export function updateLayerIndexPattern( + layer: ExpressionBasedLayer, + newIndexPattern: IndexPattern +): ExpressionBasedLayer { + const keptColumns: ExpressionBasedLayer['columns'] = _.pick(layer.columns, column => + isColumnTransferable(column, newIndexPattern) + ); + const newColumns: ExpressionBasedLayer['columns'] = _.mapValues(keptColumns, column => { + const operationDefinition = operationDefinitionMap[column.operationType]; + return operationDefinition.transfer + ? operationDefinition.transfer(column, newIndexPattern) + : column; + }); + const newColumnOrder = layer.columnOrder.filter(columnId => newColumns[columnId]); + + return { + ...layer, + indexPatternId: newIndexPattern.id, + columns: newColumns, + columnOrder: newColumnOrder, + }; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/to_expression.ts b/x-pack/plugins/lens/public/expression_datasource/to_expression.ts new file mode 100644 index 0000000000000..895cffff93cae --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/to_expression.ts @@ -0,0 +1,55 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import _ from 'lodash'; +import { Ast, ExpressionFunctionAST } from '@kbn/interpreter/common'; +import { IndexPatternColumn } from './indexpattern'; +import { operationDefinitionMap } from './operations'; +import { IndexPattern, ExpressionBasedPrivateState, ExpressionBasedLayer } from './types'; +import { OriginalColumn } from './rename_columns'; +import { dateHistogramOperation } from './operations/definitions'; +import { parseExpression } from '../../../../../src/plugins/expressions/common'; + +function getExpressionForLayer(layer: ExpressionBasedLayer, refs: any): Ast | null { + if (layer.columns.length === 0) { + return null; + } + + const idMap = layer.columns.reduce((currentIdMap, column, index) => { + return { + ...currentIdMap, + [column.fieldName]: { + id: column.columnId, + }, + }; + }, {} as Record); + + return { + type: 'expression', + chain: [ + ...parseExpression(layer.query).chain, + { + type: 'function', + function: 'lens_rename_columns', + arguments: { + idMap: [JSON.stringify(idMap)], + overwriteTypes: layer.overwrittenFieldTypes + ? [JSON.stringify(layer.overwrittenFieldTypes)] + : [], + }, + }, + ], + }; +} + +export function toExpression(state: ExpressionBasedPrivateState, layerId: string) { + if (state.layers[layerId]) { + return getExpressionForLayer(state.layers[layerId], state.indexPatternRefs); + } + + return null; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/types.ts b/x-pack/plugins/lens/public/expression_datasource/types.ts new file mode 100644 index 0000000000000..30da4e1b5e32f --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/types.ts @@ -0,0 +1,36 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface ExpressionBasedLayer { + index: string; + query: string; + columns: Array<{ columnId: string; fieldName: string }>; + timeField?: string; + overwrittenFieldTypes?: Record; +} + +export interface ExpressionBasedPersistedState { + layers: Record; +} + +export type ExpressionBasedPrivateState = ExpressionBasedPersistedState & { + indexPatternRefs: IndexPatternRef[]; + autoMap?: boolean; + cachedFieldList: Record< + string, + { fields: Array<{ name: string; type: string }>; singleRow: boolean } + >; + removedLayers: Array<{ + layer: ExpressionBasedLayer; + fieldList: { fields: Array<{ name: string; type: string }>; singleRow: boolean }; + }>; +}; + +export interface IndexPatternRef { + id: string; + title: string; +} diff --git a/x-pack/plugins/lens/public/expression_datasource/utils.ts b/x-pack/plugins/lens/public/expression_datasource/utils.ts new file mode 100644 index 0000000000000..fadee01e695d5 --- /dev/null +++ b/x-pack/plugins/lens/public/expression_datasource/utils.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { DraggedField } from './indexpattern'; +import { + BaseIndexPatternColumn, + FieldBasedIndexPatternColumn, +} from './operations/definitions/column_types'; +import { DataType } from '../types'; + +/** + * Normalizes the specified operation type. (e.g. document operations + * produce 'number') + */ +export function normalizeOperationDataType(type: DataType) { + return type === 'document' ? 'number' : type; +} + +export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function sortByField(columns: C[]) { + return [...columns].sort((column1, column2) => { + if (hasField(column1) && hasField(column2)) { + return column1.sourceField.localeCompare(column2.sourceField); + } + return column1.operationType.localeCompare(column2.operationType); + }); +} + +export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { + return ( + typeof fieldCandidate === 'object' && + fieldCandidate !== null && + 'field' in fieldCandidate && + 'indexPatternId' in fieldCandidate + ); +} diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index a231286d96a4f..6b39e536e9ef5 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -86,6 +86,7 @@ import { setupExpressions } from './expressions'; import { getSearchProvider } from './search_provider'; import { EsDSLDatasource } from './esdsl_datasource'; import { EsSQLDatasource } from './essql_datasource'; +import { ExpressionBasedDatasource } from './expression_datasource'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -169,6 +170,7 @@ export class LensPlugin { private indexpatternDatasource: IndexPatternDatasourceType | undefined; private esdslDatasource: IndexPatternDatasourceType | undefined; private essqlDatasource: IndexPatternDatasourceType | undefined; + private expressionBasedDatasource: IndexPatternDatasourceType | undefined; private xyVisualization: XyVisualizationType | undefined; private metricVisualization: MetricVisualizationType | undefined; private pieVisualization: PieVisualizationType | undefined; @@ -317,6 +319,7 @@ export class LensPlugin { this.indexpatternDatasource = new IndexPatternDatasource(); this.esdslDatasource = new EsDSLDatasource(); this.essqlDatasource = new EsSQLDatasource(); + this.expressionBasedDatasource = new ExpressionBasedDatasource(); this.xyVisualization = new XyVisualization(); this.metricVisualization = new MetricVisualization(); this.pieVisualization = new PieVisualization(); @@ -339,6 +342,7 @@ export class LensPlugin { this.indexpatternDatasource.setup(core, dependencies); this.esdslDatasource.setup(core, dependencies); this.essqlDatasource.setup(core, dependencies); + this.expressionBasedDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); From be26d8acfada8a2d9b15117dc532841ad3178038 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 22 Nov 2021 14:23:00 +0100 Subject: [PATCH 08/15] switch to code editor --- x-pack/plugins/canvas/public/plugin.tsx | 13 ++----------- .../lens/public/esdsl_datasource/datapanel.tsx | 4 ++-- .../lens/public/essql_datasource/datapanel.tsx | 4 ++-- .../lens/public/expression_datasource/datapanel.tsx | 4 ++-- 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index 4539362f4b047..52e749437787c 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -128,7 +128,7 @@ export class CanvasPlugin // Load application bundle const { renderApp, initializeCanvas, teardownCanvas } = await import('./application'); - const canvasStore = await initializeCanvas( + window.canvasStore = await initializeCanvas( coreSetup, coreStart, setupPlugins, @@ -170,16 +170,7 @@ export class CanvasPlugin // Load application bundle const { renderApp, initializeCanvas, teardownCanvas } = await import('./application'); - const canvasStore = await initializeCanvas( - coreSetup, - coreStart, - setupPlugins, - startPlugins, - registries, - this.appUpdater - ); - - const unmount = renderApp({ coreStart, startPlugins, params, canvasStore, pluginServices }); + const unmount = renderApp({ coreStart, startPlugins, params, canvasStore: window.canvasStore, pluginServices }); return () => { unmount(); diff --git a/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx index 2cc6a2eabc175..7107160eb1cc0 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/esdsl_datasource/datapanel.tsx @@ -26,7 +26,6 @@ import { EuiSpacer, EuiFormLabel, EuiButton, - EuiCodeEditor, EuiAccordion, EuiPanel, } from '@elastic/eui'; @@ -41,6 +40,7 @@ import { FieldButton } from '@kbn/react-field/field_button'; import { DragDrop, DragDropIdentifier } from '../drag_drop'; import { LensFieldIcon } from '../indexpattern_datasource/lens_field_icon'; import { ChildDragDropProvider, DragContextState } from '../drag_drop'; +import { CodeEditor } from '../../../../../src/plugins/kibana_react/public'; export type Props = DatasourceDataPanelProps & { data: DataPublicPluginStart; @@ -291,7 +291,7 @@ export function EsDSLHorizontalDataPanel({ }); }} /> - - - Date: Thu, 9 Dec 2021 11:46:00 +0100 Subject: [PATCH 09/15] hide filter bar in SQL mode by default --- .../functions/browser/essql.ts | 12 +++- .../lens/public/app_plugin/lens_top_nav.tsx | 29 +++++--- .../public/essql_datasource/datapanel.tsx | 68 +++++++++++++++++-- .../lens/public/essql_datasource/essql.tsx | 7 +- .../public/essql_datasource/to_expression.ts | 1 + .../lens/public/essql_datasource/types.ts | 1 + x-pack/plugins/lens/public/types.ts | 1 + 7 files changed, 99 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/essql.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/essql.ts index a90b2d99ab68f..dd5393a1f9b62 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/essql.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/essql.ts @@ -21,6 +21,7 @@ interface Arguments { parameter: Array; count: number; timezone: string; + discardFilters: boolean; } export function essql(): ExpressionFunctionDefinition< @@ -65,15 +66,20 @@ export function essql(): ExpressionFunctionDefinition< types: ['string'], help: '', }, + discardFilters: { + types: ['boolean'], + help: '', + default: false, + }, }, fn: (input, args, handlers) => { const search = searchService.getService().search; - const { parameter, ...restOfArgs } = args; + const { parameter, discardFilters, ...restOfArgs } = args; const req = { ...restOfArgs, params: parameter, filter: - input.type === 'kibana_context' + !args.discardFilters && input.type === 'kibana_context' ? [ { filterType: 'direct', @@ -94,7 +100,7 @@ export function essql(): ExpressionFunctionDefinition< }, }, ] - : input.and, + : input.and || [], }; return search diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 9045080f83b26..5b072442e38e4 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -408,6 +408,12 @@ export const LensTopNavMenu = ({ }); }, [data.query.filterManager, data.query.queryString, dispatchSetState]); + const hideFilterBar = Boolean( + allLoaded && + activeDatasourceId && + datasourceMap[activeDatasourceId].hideFilterBar?.(datasourceStates[activeDatasourceId].state) + ); + return ( ip.isTimeBased()) || - Boolean( - allLoaded && - activeDatasourceId && - datasourceMap[activeDatasourceId].isTimeBased( - datasourceStates[activeDatasourceId].state - ) - ) + !hideFilterBar && + (indexPatterns.some((ip) => ip.isTimeBased()) || + Boolean( + allLoaded && + activeDatasourceId && + datasourceMap[activeDatasourceId].isTimeBased( + datasourceStates[activeDatasourceId].state + ) + )) } - showQueryBar={true} - showFilterBar={true} + showQueryBar={!hideFilterBar} + showFilterBar={!hideFilterBar} data-test-subj="lnsApp_topNav" screenTitle={'lens'} appName={'lens'} diff --git a/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx index 3be244bd82188..cfef902e4d818 100644 --- a/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/datapanel.tsx @@ -30,17 +30,17 @@ import { EuiPanel, EuiCheckbox, } from '@elastic/eui'; -import { CodeEditor } from '../../../../../src/plugins/kibana_react/public'; import { i18n } from '@kbn/i18n'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { EuiFieldText, EuiSelect } from '@elastic/eui'; import { ExpressionsStart } from 'src/plugins/expressions/public'; +import { FieldButton } from '@kbn/react-field/field_button'; +import { CodeEditor } from '../../../../../src/plugins/kibana_react/public'; import { buildExpressionFunction } from '../../../../../src/plugins/expressions/public'; import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; import { IndexPattern, EsSQLPrivateState, IndexPatternField, IndexPatternRef } from './types'; import { esRawResponse } from '../../../../../src/plugins/data/common'; import { ChangeIndexPattern } from './change_indexpattern'; -import { FieldButton } from '@kbn/react-field/field_button'; import { DragDrop, DragDropIdentifier } from '../drag_drop'; import { LensFieldIcon } from '../indexpattern_datasource/lens_field_icon'; import { ChildDragDropProvider, DragContextState } from '../drag_drop'; @@ -52,6 +52,10 @@ export type Props = DatasourceDataPanelProps & { import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { flatten } from './flatten'; +function getIndexPattern(sql: string) { + return /.*FROM (\S+).*/.exec(sql)?.[1].replaceAll(/\"/g, ''); +} + export function EsSQLDataPanel({ setState, state, @@ -347,7 +351,6 @@ export function EsSQLHorizontalDataPanel({ ...core, }} > - {Object.entries(layers).map(([id, layer]) => { const ref = state.indexPatternRefs.find((r) => r.id === layer.index); @@ -375,6 +378,10 @@ export function EsSQLHorizontalDataPanel({ [id]: { ...layer, query: val, + index: layer.hideFilterBar + ? layer.index + : state.indexPatternRefs.find((r) => r.title === getIndexPattern(val)) + ?.id || layer.index, }, }, }); @@ -402,7 +409,41 @@ export function EsSQLHorizontalDataPanel({ ))} - + + + <> + r.title === getIndexPattern(Object.values(layers)[0].query) + ) + } + onChange={(e) => { + setState({ + ...localState, + layers: { + ...layers, + [Object.keys(layers)[0]]: { + ...Object.values(layers)[0], + hideFilterBar: !e.target.checked, + }, + }, + }); + }} + /> + {!Object.values(layers)[0].hideFilterBar && + !state.indexPatternRefs.some( + (r) => r.title === getIndexPattern(Object.values(layers)[0].query) + ) && ( + + Can't find data view for FROM clause, please create or change + + )} + + - + r.title === getIndexPattern(Object.values(layers)[0].query) + )) + } + color={ + !Object.values(layers)[0].hideFilterBar && + !state.indexPatternRefs.some( + (r) => r.title === getIndexPattern(Object.values(layers)[0].query) + ) + ? 'danger' + : 'primary' + } + onClick={onSubmit} + > Apply changes diff --git a/x-pack/plugins/lens/public/essql_datasource/essql.tsx b/x-pack/plugins/lens/public/essql_datasource/essql.tsx index a1d95dc54e4c7..e48d394d314a0 100644 --- a/x-pack/plugins/lens/public/essql_datasource/essql.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/essql.tsx @@ -13,6 +13,7 @@ import { CoreStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { EuiButton, EuiSelect } from '@elastic/eui'; +import { ExpressionsStart } from 'src/plugins/expressions/public'; import { DatasourceDimensionEditorProps, DatasourceDimensionTriggerProps, @@ -30,7 +31,6 @@ import { EsSQLLayer, EsSQLPrivateState, EsSQLPersistedState } from './types'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { Datasource } from '../types'; import { esRawResponse } from '../../../../../src/plugins/data/common'; -import { ExpressionsStart } from 'src/plugins/expressions/public'; async function loadIndexPatternRefs( indexPatternsService: DataViewsService @@ -69,6 +69,9 @@ export function getEsSQLDatasource({ getErrorMessages: () => { return []; }, + hideFilterBar: (state) => { + return Object.values(state.layers).some((layer) => layer.hideFilterBar); + }, async initialize(state?: EsSQLPersistedState) { const initState = state || { layers: {} }; const indexPatternRefs: IndexPatternRef[] = await loadIndexPatternRefs(data.dataViews); @@ -369,6 +372,7 @@ export function getEsSQLDatasource({ } return null; }, + getVisualDefaults: () => ({}), }; }, getDatasourceSuggestionsForField(state, draggedField) { @@ -452,6 +456,7 @@ function blankLayer(index: string) { return { index, query: '', + hideFilterBar: true, columns: [], }; } diff --git a/x-pack/plugins/lens/public/essql_datasource/to_expression.ts b/x-pack/plugins/lens/public/essql_datasource/to_expression.ts index 80d977af5a9db..65f0b4631f856 100644 --- a/x-pack/plugins/lens/public/essql_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/essql_datasource/to_expression.ts @@ -34,6 +34,7 @@ function getExpressionForLayer(layer: EsSQLLayer, refs: any): Ast | null { type: 'function', function: 'essql', arguments: { + discardFilters: [layer.hideFilterBar], query: [layer.query], timefield: [refs.find((r) => r.id === layer.index)!.timeField], }, diff --git a/x-pack/plugins/lens/public/essql_datasource/types.ts b/x-pack/plugins/lens/public/essql_datasource/types.ts index e90f543d2b8b2..5022ec08f3796 100644 --- a/x-pack/plugins/lens/public/essql_datasource/types.ts +++ b/x-pack/plugins/lens/public/essql_datasource/types.ts @@ -11,6 +11,7 @@ export interface EsSQLLayer { columns: Array<{ columnId: string; fieldName: string }>; timeField?: string; overwrittenFieldTypes?: Record; + hideFilterBar: boolean; } export interface EsSQLPersistedState { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index e064f1bdb63c7..7845fa5372fa2 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -239,6 +239,7 @@ export interface Datasource { columnId: string; state: T; }) => T | undefined; + hideFilterBar?: (state: T) => boolean; toExpression: (state: T, layerId: string) => ExpressionAstExpression | string | null; From 1db9ff3cabfb3c51750f79d4f847fe8b06a63c85 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 9 Dec 2021 13:36:02 +0100 Subject: [PATCH 10/15] fix imports --- x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx | 2 +- x-pack/plugins/lens/public/esdsl_datasource/layerpanel.tsx | 2 +- x-pack/plugins/lens/public/essql_datasource/essql.tsx | 2 +- x-pack/plugins/lens/public/essql_datasource/layerpanel.tsx | 2 +- x-pack/plugins/lens/public/expression_datasource/layerpanel.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx index e42d1ad251df0..3f8e277200587 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx +++ b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx @@ -8,7 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; -import { I18nProvider } from '@kbn/i18n/react'; +import { I18nProvider } from '@kbn/i18n-react'; import { CoreStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; diff --git a/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.tsx index 5ef73f8b31526..d83ee0de8e88f 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.tsx +++ b/x-pack/plugins/lens/public/esdsl_datasource/layerpanel.tsx @@ -6,7 +6,7 @@ import _ from 'lodash'; import React from 'react'; -import { I18nProvider } from '@kbn/i18n/react'; +import { I18nProvider } from '@kbn/i18n-react'; import { DatasourceLayerPanelProps } from '../types'; import { EsDSLPrivateState } from './types'; import { ChangeIndexPattern } from './change_indexpattern'; diff --git a/x-pack/plugins/lens/public/essql_datasource/essql.tsx b/x-pack/plugins/lens/public/essql_datasource/essql.tsx index e48d394d314a0..360066755d539 100644 --- a/x-pack/plugins/lens/public/essql_datasource/essql.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/essql.tsx @@ -8,7 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; -import { I18nProvider } from '@kbn/i18n/react'; +import { I18nProvider } from '@kbn/i18n-react'; import { CoreStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; diff --git a/x-pack/plugins/lens/public/essql_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/essql_datasource/layerpanel.tsx index b68013c1f214c..c82d04b5a6981 100644 --- a/x-pack/plugins/lens/public/essql_datasource/layerpanel.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/layerpanel.tsx @@ -6,7 +6,7 @@ import _ from 'lodash'; import React from 'react'; -import { I18nProvider } from '@kbn/i18n/react'; +import { I18nProvider } from '@kbn/i18n-react'; import { DatasourceLayerPanelProps } from '../types'; import { EsSQLPrivateState } from './types'; import { ChangeIndexPattern } from './change_indexpattern'; diff --git a/x-pack/plugins/lens/public/expression_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/expression_datasource/layerpanel.tsx index 267ffda446992..8efd7ca1f5bb7 100644 --- a/x-pack/plugins/lens/public/expression_datasource/layerpanel.tsx +++ b/x-pack/plugins/lens/public/expression_datasource/layerpanel.tsx @@ -6,7 +6,7 @@ import _ from 'lodash'; import React from 'react'; -import { I18nProvider } from '@kbn/i18n/react'; +import { I18nProvider } from '@kbn/i18n-react'; import { DatasourceLayerPanelProps } from '../types'; import { ExpressionBasedPrivateState } from './types'; import { ChangeIndexPattern } from './change_indexpattern'; From 31dbd35d3a948c19cf4730ffa50d2e80fc01866a Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 9 Dec 2021 15:08:09 +0100 Subject: [PATCH 11/15] fix imports --- .../lens/public/expression_datasource/expressionbased.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx b/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx index 49d4e162a3c70..000f430ae1707 100644 --- a/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx +++ b/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx @@ -8,7 +8,7 @@ import _ from 'lodash'; import React from 'react'; import { render } from 'react-dom'; -import { I18nProvider } from '@kbn/i18n/react'; +import { I18nProvider } from '@kbn/i18n-react'; import { CoreStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; From b824762d6796e611f5a1eac3966f130a457bf0e6 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 9 Dec 2021 17:49:41 +0100 Subject: [PATCH 12/15] fix bugs --- .../lens/public/esdsl_datasource/esdsl.tsx | 3 ++- .../lens/public/essql_datasource/essql.tsx | 22 +-------------- .../expression_datasource/expressionbased.tsx | 27 +++---------------- 3 files changed, 7 insertions(+), 45 deletions(-) diff --git a/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx index 3f8e277200587..2e2b8382e1dd6 100644 --- a/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx +++ b/x-pack/plugins/lens/public/esdsl_datasource/esdsl.tsx @@ -350,7 +350,7 @@ export function getEsDSLDatasource({ const column = layer?.columns.find((c) => c.columnId === columnId); if (column) { - const field = state.cachedFieldList[layerId].fields.find( + const field = state.cachedFieldList[layerId]?.fields?.find( (f) => f.name === column.fieldName )!; const overwrite = layer.overwrittenFieldTypes?.[column.fieldName]; @@ -362,6 +362,7 @@ export function getEsDSLDatasource({ } return null; }, + getVisualDefaults: () => ({}), }; }, getDatasourceSuggestionsForField(state, draggedField) { diff --git a/x-pack/plugins/lens/public/essql_datasource/essql.tsx b/x-pack/plugins/lens/public/essql_datasource/essql.tsx index 360066755d539..819350ab9d950 100644 --- a/x-pack/plugins/lens/public/essql_datasource/essql.tsx +++ b/x-pack/plugins/lens/public/essql_datasource/essql.tsx @@ -75,30 +75,10 @@ export function getEsSQLDatasource({ async initialize(state?: EsSQLPersistedState) { const initState = state || { layers: {} }; const indexPatternRefs: IndexPatternRef[] = await loadIndexPatternRefs(data.dataViews); - const responses = await Promise.all( - Object.entries(initState.layers).map(([id, layer]) => { - return data.search - .search({ - params: { - size: 0, - index: layer.index, - body: JSON.parse(layer.query), - }, - }) - .toPromise(); - }) - ); const cachedFieldList: Record< string, { fields: Array<{ name: string; type: string }>; singleRow: boolean } > = {}; - responses.forEach((response, index) => { - const layerId = Object.keys(initState.layers)[index]; - // @ts-expect-error this is hacky, should probably run expression instead - const { rows, columns } = esRawResponse.to!.datatable({ body: response.rawResponse }); - // todo hack some logic in for dates - cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; - }); return { ...initState, cachedFieldList, @@ -359,7 +339,7 @@ export function getEsSQLDatasource({ const column = layer?.columns.find((c) => c.columnId === columnId); if (column) { - const field = state.cachedFieldList[layerId].fields.find( + const field = state.cachedFieldList[layerId]?.fields?.find( (f) => f.name === column.fieldName )!; const overwrite = layer.overwrittenFieldTypes?.[column.fieldName]; diff --git a/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx b/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx index 000f430ae1707..48905e57e18d3 100644 --- a/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx +++ b/x-pack/plugins/lens/public/expression_datasource/expressionbased.tsx @@ -72,30 +72,10 @@ export function getExpressionBasedDatasource({ async initialize(state?: ExpressionBasedPersistedState) { const initState = state || { layers: {} }; const indexPatternRefs: IndexPatternRef[] = await loadIndexPatternRefs(data.dataViews); - const responses = await Promise.all( - Object.entries(initState.layers).map(([id, layer]) => { - return data.search - .search({ - params: { - size: 0, - index: layer.index, - body: JSON.parse(layer.query), - }, - }) - .toPromise(); - }) - ); const cachedFieldList: Record< string, { fields: Array<{ name: string; type: string }>; singleRow: boolean } > = {}; - responses.forEach((response, index) => { - const layerId = Object.keys(initState.layers)[index]; - // @ts-expect-error this is hacky, should probably run expression instead - const { rows, columns } = esRawResponse.to!.datatable({ body: response.rawResponse }); - // todo hack some logic in for dates - cachedFieldList[layerId] = { fields: columns, singleRow: rows.length === 1 }; - }); return { ...initState, cachedFieldList, @@ -356,7 +336,7 @@ export function getExpressionBasedDatasource({ const column = layer?.columns.find((c) => c.columnId === columnId); if (column) { - const field = state.cachedFieldList[layerId].fields.find( + const field = state.cachedFieldList[layerId]?.fields?.find( (f) => f.name === column.fieldName )!; const overwrite = layer.overwrittenFieldTypes?.[column.fieldName]; @@ -369,6 +349,7 @@ export function getExpressionBasedDatasource({ } return null; }, + getVisualDefaults: () => ({}), }; }, getDatasourceSuggestionsForField(state, draggedField) { @@ -393,7 +374,7 @@ export function getExpressionBasedDatasource({ isMultiRow: !state.cachedFieldList[id].singleRow, layerId: id, columns: layer.columns.map((column) => { - const field = state.cachedFieldList[id].fields.find( + const field = state.cachedFieldList[id]?.fields?.find( (f) => f.name === column.fieldName )!; const operation = { @@ -416,7 +397,7 @@ export function getExpressionBasedDatasource({ layers: { [id]: { ...state.layers[id], - columns: state.cachedFieldList[id].fields.map((f) => ({ + columns: state.cachedFieldList[id]?.fields?.map((f) => ({ columnId: f.name, fieldName: f.name, })), From 55edf6e14bfb767f4f6fb9041b9dea8b62d67668 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 4 Mar 2022 10:41:36 +0100 Subject: [PATCH 13/15] hack sql support into discover --- .../components/layout/discover_documents.tsx | 21 +++++- .../components/layout/discover_layout.tsx | 66 +++++++++++++++---- .../discover_index_pattern_management.tsx | 10 +++ .../components/sidebar/discover_sidebar.tsx | 11 +++- .../sidebar/discover_sidebar_responsive.tsx | 3 + .../lib/get_index_pattern_field_list.ts | 12 +++- .../components/top_nav/discover_topnav.tsx | 8 ++- .../application/main/discover_main_app.tsx | 2 + .../main/services/discover_state.ts | 2 + .../application/main/utils/fetch_all.ts | 57 +++++++++++++++- .../main/utils/use_discover_state.ts | 2 + .../main/utils/use_saved_search.ts | 8 +++ .../discover_grid/discover_grid.tsx | 42 ++++++++++-- .../discover_grid/discover_grid_columns.tsx | 26 +++++++- .../discover_grid/get_render_cell_value.tsx | 7 +- .../components/doc_table/actions/columns.ts | 4 +- 16 files changed, 246 insertions(+), 35 deletions(-) diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index 71033de462751..052cf865c1268 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -12,6 +12,7 @@ import { EuiText, EuiLoadingSpinner, EuiScreenReaderOnly, + EuiCallOut, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; @@ -67,7 +68,11 @@ function DiscoverDocumentsComponent({ const documentState: DataDocumentsMsg = useDataState(documents$); const isLoading = documentState.fetchStatus === FetchStatus.LOADING; - const rows = useMemo(() => documentState.result || [], [documentState.result]); + const rows = useMemo( + () => + state.sqlMode && documentState.sql ? documentState.sql.rows : documentState.result || [], + [documentState.result, documentState.sql, state.sqlMode] + ); const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useColumns({ capabilities, @@ -111,6 +116,18 @@ function DiscoverDocumentsComponent({ [uiSettings, indexPattern.timeFieldName] ); + if (state.sqlMode && documentState.sqlError) { + return ( +
+ +

+

{JSON.stringify(documentState.sqlError, null, 2)}
+

+
+
+ ); + } + if ( (!documentState.result || documentState.result.length === 0) && documentState.fetchStatus === FetchStatus.LOADING @@ -157,6 +174,8 @@ function DiscoverDocumentsComponent({ {!isLegacy && (
+ {state.sqlMode && ( + + +
+ { + setState({ ...state, sqlQuery: val }); + }} + /> +
+
+ + + + { + savedSearchRefetch$.next(); + }} + > + Apply + + + + +
+ )} - - - + {!state.sqlMode && ( + + + + )} {viewMode === VIEW_MODE.DOCUMENT_LEVEL ? ( , + , + { + props.setState({ ...props.state, sqlMode: true, columns: ['_source'] }); + }} + > + SQL + , ]} /> diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx index 6569348f99038..1d89c17539ae6 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx @@ -107,6 +107,9 @@ export function DiscoverSidebarComponent({ editField, viewMode, createNewDataView, + state, + setState, + table, }: DiscoverSidebarProps) { const { uiSettings, dataViewFieldEditor } = useDiscoverServices(); const [fields, setFields] = useState(null); @@ -120,10 +123,10 @@ export function DiscoverSidebarComponent({ useEffect(() => { if (documents) { - const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts); + const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts, state.sqlMode, table); setFields(newFields); } - }, [selectedIndexPattern, fieldCounts, documents]); + }, [selectedIndexPattern, fieldCounts, documents, state.sqlMode, table]); const scrollDimensions = useResizeObserver(scrollContainer); @@ -303,6 +306,8 @@ export function DiscoverSidebarComponent({ editField={editField} useNewFieldsApi={useNewFieldsApi} createNewDataView={createNewDataView} + state={state} + setState={setState} /> @@ -341,6 +346,8 @@ export function DiscoverSidebarComponent({ useNewFieldsApi={useNewFieldsApi} editField={editField} createNewDataView={createNewDataView} + state={state} + setState={setState} /> diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx index ce23e0a8e18f8..053aa662ac683 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx @@ -281,6 +281,7 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) + fieldCounts?: Record, + sqlMode: boolean, + table: any ) { if (!indexPattern || !fieldCounts) return []; @@ -20,6 +22,14 @@ export function getIndexPatternFieldList( const fieldNamesInIndexPattern = indexPattern.fields.getAll().map((fld) => fld.name); const unknownFields: DataViewField[] = []; + if (sqlMode && table) { + return table.columns.map((c) => ({ + displayName: c.name, + name: c.name, + type: c.type, + })); + } + difference(fieldNamesInDocs, fieldNamesInIndexPattern).forEach((unknownFieldName) => { if (isNestedFieldParent(unknownFieldName, indexPattern)) { unknownFields.push({ diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx index 63e75c74af795..24c03944395ed 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -25,6 +25,7 @@ export type DiscoverTopNavProps = Pick< updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; stateContainer: GetStateReturn; resetSavedSearch: () => void; + hideQuery?: boolean; }; export const DiscoverTopNav = ({ @@ -38,6 +39,7 @@ export const DiscoverTopNav = ({ navigateTo, savedSearch, resetSavedSearch, + hideQuery, }: DiscoverTopNavProps) => { const history = useHistory(); const showDatePicker = useMemo( @@ -110,9 +112,9 @@ export const DiscoverTopNav = ({ setMenuMountPoint={setMenuMountPoint} savedQueryId={savedQuery} screenTitle={savedSearch.title} - showDatePicker={showDatePicker} - showSaveQuery={!!services.capabilities.discover.saveQuery} - showSearchBar={true} + showDatePicker={!hideQuery && showDatePicker} + showSaveQuery={!hideQuery && !!services.capabilities.discover.saveQuery} + showSearchBar={!hideQuery && true} useDefaultBehaviors={true} /> ); diff --git a/src/plugins/discover/public/application/main/discover_main_app.tsx b/src/plugins/discover/public/application/main/discover_main_app.tsx index 846a1fe33c826..9d9846b393148 100644 --- a/src/plugins/discover/public/application/main/discover_main_app.tsx +++ b/src/plugins/discover/public/application/main/discover_main_app.tsx @@ -56,6 +56,7 @@ export function DiscoverMainApp(props: DiscoverMainProps) { resetSavedSearch, searchSource, state, + setState, stateContainer, } = useDiscoverState({ services, @@ -108,6 +109,7 @@ export function DiscoverMainApp(props: DiscoverMainProps) { savedSearchRefetch$={refetch$} searchSource={searchSource} state={state} + setState={setState} stateContainer={stateContainer} /> ); diff --git a/src/plugins/discover/public/application/main/services/discover_state.ts b/src/plugins/discover/public/application/main/services/discover_state.ts index 4a3592f848de7..ee2fdf687a435 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.ts @@ -85,6 +85,8 @@ export interface AppState { * Document explorer row height option */ rowHeight?: number; + sqlMode?: boolean; + sqlQuery?: string; } interface GetStateParams { diff --git a/src/plugins/discover/public/application/main/utils/fetch_all.ts b/src/plugins/discover/public/application/main/utils/fetch_all.ts index 4109cbb73788f..5607afe0be69d 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_all.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_all.ts @@ -59,7 +59,15 @@ export function fetchAll( reset = false, fetchDeps: FetchDeps ): Promise { - const { initialFetchStatus, appStateContainer, services, useNewFieldsApi, data } = fetchDeps; + const { + initialFetchStatus, + appStateContainer, + services, + useNewFieldsApi, + data, + sqlMode, + sqlQuery, + } = fetchDeps; /** * Method to create a an error handler that will forward the received error @@ -129,8 +137,47 @@ export function fetchAll( // Handle results of the individual queries and forward the results to the corresponding dataSubjects - documents - .then((docs) => { + Promise.all([ + documents, + (function () { + if (!sqlMode) { + return undefined; + } + return data.search + .search( + { query: sqlQuery, filter: [] }, + { + strategy: 'essql', + } + ) + .toPromise() + .then((resp: EssqlSearchStrategyResponse) => { + return { + type: 'datatable', + meta: { + type: 'essql', + }, + ...resp, + }; + }) + .catch((e) => { + let message = `Unexpected error from Elasticsearch: ${e.message}`; + if (e.err) { + const { type, reason } = e.err.attributes; + if (type === 'parsing_exception') { + message = `Couldn't parse Elasticsearch SQL query. You may need to add double quotes to names containing special characters. Check your query and try again. Error: ${reason}`; + } else { + message = `Unexpected error from Elasticsearch: ${type} - ${reason}`; + } + } + + // Re-write the error message before surfacing it up + e.message = message; + return e; + }); + })(), + ]) + .then(([docs, sql]) => { // If the total hits (or chart) query is still loading, emit a partial // hit count that's at least our retrieved document count if (dataSubjects.totalHits$.getValue().fetchStatus === FetchStatus.LOADING) { @@ -140,9 +187,13 @@ export function fetchAll( }); } + const sqlError = sql && 'message' in sql ? sql : undefined; + dataSubjects.documents$.next({ fetchStatus: FetchStatus.COMPLETE, result: docs, + sql: !sqlError && sql, + sqlError, }); checkHitCount(docs.length); diff --git a/src/plugins/discover/public/application/main/utils/use_discover_state.ts b/src/plugins/discover/public/application/main/utils/use_discover_state.ts index 4d5d911f8abf3..32056a2eff5f6 100644 --- a/src/plugins/discover/public/application/main/utils/use_discover_state.ts +++ b/src/plugins/discover/public/application/main/utils/use_discover_state.ts @@ -97,6 +97,8 @@ export function useDiscoverState({ services, stateContainer, useNewFieldsApi, + state, + setState, }); /** diff --git a/src/plugins/discover/public/application/main/utils/use_saved_search.ts b/src/plugins/discover/public/application/main/utils/use_saved_search.ts index ee44ac13e90b2..fa637b07fbeb0 100644 --- a/src/plugins/discover/public/application/main/utils/use_saved_search.ts +++ b/src/plugins/discover/public/application/main/utils/use_saved_search.ts @@ -67,6 +67,7 @@ export interface DataMainMsg extends DataMsg { export interface DataDocumentsMsg extends DataMsg { result?: ElasticSearchHit[]; + sql?: any; } export interface DataTotalHitsMsg extends DataMsg { @@ -96,6 +97,8 @@ export const useSavedSearch = ({ services, stateContainer, useNewFieldsApi, + state, + setState, }: { initialFetchStatus: FetchStatus; savedSearch: SavedSearch; @@ -193,6 +196,8 @@ export const useSavedSearch = ({ searchSessionId: searchSessionManager.getNextSearchSessionId(), services, useNewFieldsApi, + sqlMode: state.sqlMode, + sqlQuery: state.sqlQuery, }); // If the autoRefreshCallback is still the same as when we started i.e. there was no newer call @@ -224,6 +229,9 @@ export const useSavedSearch = ({ stateContainer.appStateContainer, timefilter, useNewFieldsApi, + setState, + state.sqlMode, + state.sqlQuery, ]); const reset = useCallback( diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx index 34673310f2c6e..f6d747bb51773 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx @@ -197,6 +197,8 @@ export const DiscoverGrid = ({ className, rowHeightState, onUpdateRowHeight, + sqlMode, + sql, }: DiscoverGridProps) => { const services = useDiscoverServices(); const [selectedDocs, setSelectedDocs] = useState([]); @@ -289,16 +291,19 @@ export const DiscoverGrid = ({ getRenderCellValueFn( indexPattern, displayedRows, - displayedRows + sqlMode + ? displayedRows + : displayedRows ? displayedRows.map((hit) => flattenHit(hit, indexPattern, { includeIgnoredValues: true }) ) : [], useNewFieldsApi, fieldsToShow, - services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED) + services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED), + sqlMode ), - [indexPattern, displayedRows, useNewFieldsApi, fieldsToShow, services.uiSettings] + [indexPattern, displayedRows, useNewFieldsApi, fieldsToShow, services.uiSettings, sqlMode] ); /** @@ -315,9 +320,20 @@ export const DiscoverGrid = ({ indexPattern, showTimeCol, defaultColumns, - isSortEnabled + isSortEnabled, + sqlMode, + sql ), - [displayedColumns, indexPattern, showTimeCol, settings, defaultColumns, isSortEnabled] + [ + displayedColumns, + indexPattern, + showTimeCol, + settings, + defaultColumns, + isSortEnabled, + sqlMode, + sql, + ] ); const hideTimeColumn = useMemo( @@ -327,12 +343,24 @@ export const DiscoverGrid = ({ const schemaDetectors = useMemo(() => getSchemaDetectors(), []); const columnsVisibility = useMemo( () => ({ - visibleColumns: getVisibleColumns(displayedColumns, indexPattern, showTimeCol) as string[], + visibleColumns: getVisibleColumns( + sqlMode ? euiGridColumns.map((c) => c.id) : displayedColumns, + indexPattern, + showTimeCol + ) as string[], setVisibleColumns: (newColumns: string[]) => { onSetColumns(newColumns, hideTimeColumn); }, }), - [displayedColumns, indexPattern, showTimeCol, hideTimeColumn, onSetColumns] + [ + displayedColumns, + indexPattern, + showTimeCol, + hideTimeColumn, + onSetColumns, + sqlMode, + euiGridColumns, + ] ); const sorting = useMemo(() => { if (isSortEnabled) { diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx index 2aee9ae2229c7..bc7bf2b49796c 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.tsx @@ -123,18 +123,40 @@ export function getEuiGridColumns( indexPattern: DataView, showTimeCol: boolean, defaultColumns: boolean, - isSortEnabled: boolean + isSortEnabled: boolean, + sqlMode: boolean, + sql: any ) { const timeFieldName = indexPattern.timeFieldName; const getColWidth = (column: string) => settings?.columns?.[column]?.width ?? 0; - if (showTimeCol && indexPattern.timeFieldName && !columns.find((col) => col === timeFieldName)) { + if ( + !sqlMode && + showTimeCol && + indexPattern.timeFieldName && + !columns.find((col) => col === timeFieldName) + ) { const usedColumns = [indexPattern.timeFieldName, ...columns]; return usedColumns.map((column) => buildEuiGridColumn(column, getColWidth(column), indexPattern, defaultColumns, isSortEnabled) ); } + if (columns.length === 1 && columns[0] === '_source' && sql) { + return sql.columns + .map((c) => { + const column = c.name; + return buildEuiGridColumn( + column, + getColWidth(column), + indexPattern, + defaultColumns, + isSortEnabled + ); + }) + .slice(0, 30); + } + return columns.map((column) => buildEuiGridColumn(column, getColWidth(column), indexPattern, defaultColumns, isSortEnabled) ); diff --git a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx index fe2607415ace1..e0d55bbe40b26 100644 --- a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx @@ -34,7 +34,8 @@ export const getRenderCellValueFn = rowsFlattened: Array>, useNewFieldsApi: boolean, fieldsToShow: string[], - maxDocFieldsDisplayed: number + maxDocFieldsDisplayed: number, + sqlMode: boolean ) => ({ rowIndex, columnId, isDetails, setCellProps }: EuiDataGridCellValueElementProps) => { const { uiSettings, fieldFormats } = useDiscoverServices(); @@ -94,6 +95,10 @@ export const getRenderCellValueFn = }); } + if (field?.type === '_source' && sqlMode) { + return {JSON.stringify(rowFlattened)}; + } + if (field?.type === '_source' || useTopLevelObjectColumns) { const pairs = useTopLevelObjectColumns ? getTopLevelObjectPairs(row, columnId, dataView, fieldsToShow).slice( diff --git a/src/plugins/discover/public/components/doc_table/actions/columns.ts b/src/plugins/discover/public/components/doc_table/actions/columns.ts index f522d27eb62a0..d484f07341b5b 100644 --- a/src/plugins/discover/public/components/doc_table/actions/columns.ts +++ b/src/plugins/discover/public/components/doc_table/actions/columns.ts @@ -67,7 +67,7 @@ export function getStateColumnActions({ indexPattern, indexPatterns, useNewFieldsApi, - setAppState, + setAppState: _setAppState, state, }: { capabilities: Capabilities; @@ -78,6 +78,8 @@ export function getStateColumnActions({ setAppState: DiscoverGetStateReturn['setAppState'] | ContextGetStateReturn['setAppState']; state: DiscoverState | ContextState; }) { + const setAppState = (a) => + _setAppState({ ...a, sqlMode: state.sqlMode, sqlQuery: state.sqlQuery }); function onAddColumn(columnName: string) { popularizeField(indexPattern, columnName, indexPatterns, capabilities); const columns = addColumn(state.columns || [], columnName, useNewFieldsApi); From 4d707a07e9c267e709313eaa703c0b2807fc91f4 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 4 Mar 2022 17:42:31 +0100 Subject: [PATCH 14/15] make sql in discover work --- .../components/layout/discover_layout.tsx | 12 ++ .../sidebar/lib/visualize_trigger_utils.ts | 11 +- x-pack/plugins/canvas/public/plugin.tsx | 1 + .../editor_frame/suggestion_helpers.ts | 13 +- .../public/esdsl_datasource/datapanel.tsx | 2 +- .../public/essql_datasource/datapanel.tsx | 2 +- .../lens/public/essql_datasource/essql.tsx | 168 ++++++++++-------- .../public/essql_datasource/to_expression.ts | 2 +- .../expression_datasource/datapanel.tsx | 2 +- .../indexpattern_suggestions.ts | 2 +- .../init_middleware/load_initial.ts | 2 + x-pack/plugins/lens/public/utils.ts | 2 +- .../public/xy_visualization/xy_suggestions.ts | 4 +- 13 files changed, 135 insertions(+), 88 deletions(-) diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index 0e36984c734a3..3fa5a7edbe3be 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -51,6 +51,7 @@ import { FieldStatisticsTable } from '../field_stats_table'; import { VIEW_MODE } from '../../../../components/view_mode_toggle'; import { DOCUMENTS_VIEW_CLICK, FIELD_STATISTICS_VIEW_CLICK } from '../field_stats_table/constants'; import { DataViewType, DataView } from '../../../../../../data_views/common'; +import { triggerVisualizeActions } from '../sidebar/lib/visualize_trigger_utils'; /** * Local storage key for sidebar persistence state @@ -363,6 +364,17 @@ export function DiscoverLayout({ /> )} + {state.sqlMode && ( + + { + triggerVisualizeActions(undefined, undefined, [], state.sqlQuery); + }} + > + Visualize + + + )} {viewMode === VIEW_MODE.DOCUMENT_LEVEL ? ( { + return Object.entries(state.layers).map(([id, layer]) => { + const reducedState: EsSQLPrivateState = { + ...state, + cachedFieldList: { + [id]: state.cachedFieldList[id], + }, + layers: { + [id]: state.layers[id], + }, + }; + return !state.autoMap + ? { + state: reducedState, + table: { + changeType: 'unchanged', + isMultiRow: !state.cachedFieldList[id].singleRow, + layerId: id, + columns: layer.columns.map((column) => { + const field = state.cachedFieldList[id].fields.find( + (f) => f.name === column.fieldName + )!; + const operation = { + dataType: field?.meta.type as DataType, + label: field?.name, + isBucketed: false, + noBucketInfo: true, + }; + return { + columnId: column.columnId, + operation, + }; + }), + }, + keptLayerIds: [id], + } + : { + state: { + ...reducedState, + layers: { + [id]: { + ...state.layers[id], + columns: state.cachedFieldList[id].fields.map((f) => ({ + columnId: f.name, + fieldName: f.name, + })), + }, + }, + }, + table: { + changeType: 'unchanged', + isMultiRow: !state.cachedFieldList[id].singleRow, + layerId: id, + columns: state.cachedFieldList[id].fields.map((f) => { + return { + columnId: f.name, + operation: { + dataType: f.meta.type, + label: f.name, + isBucketed: false, + noBucketInfo: true, + }, + }; + }), + }, + keptLayerIds: [id], + }; + }); + }; // Not stateful. State is persisted to the frame const essqlDatasource: Datasource = { id: 'essql', @@ -72,13 +142,36 @@ export function getEsSQLDatasource({ hideFilterBar: (state) => { return Object.values(state.layers).some((layer) => layer.hideFilterBar); }, - async initialize(state?: EsSQLPersistedState) { + async initialize(state?: EsSQLPersistedState, references, context) { const initState = state || { layers: {} }; const indexPatternRefs: IndexPatternRef[] = await loadIndexPatternRefs(data.dataViews); const cachedFieldList: Record< string, { fields: Array<{ name: string; type: string }>; singleRow: boolean } > = {}; + if (context && 'sql' in context) { + const ast = { + type: 'expression', + chain: [ + buildExpressionFunction('essql', { + query: context.sql, + }).toAst(), + ], + }; + const response = await expressions.run(ast, null).toPromise(); + // @ts-expect-error this is hacky, should probably run expression instead + const { rows, columns } = response.result; + // todo hack some logic in for dates + cachedFieldList['123'] = { + fields: columns, + singleRow: rows.length === 1, + }; + initState.layers['123'] = { + hideFilterBar: true, + query: context.sql, + columns: columns.map((c) => ({ columnId: c.id, fieldName: c.id })) + }; + } return { ...initState, cachedFieldList, @@ -269,7 +362,7 @@ export function getEsSQLDatasource({ { props.state.indexPatternRefs.find( (r) => r.id === props.state.layers[props.layerId].index - )!.title + )?.title } , domElement @@ -358,75 +451,8 @@ export function getEsSQLDatasource({ getDatasourceSuggestionsForField(state, draggedField) { return []; }, - getDatasourceSuggestionsFromCurrentState: (state) => { - return Object.entries(state.layers).map(([id, layer]) => { - const reducedState: EsSQLPrivateState = { - ...state, - cachedFieldList: { - [id]: state.cachedFieldList[id], - }, - layers: { - [id]: state.layers[id], - }, - }; - return !state.autoMap - ? { - state: reducedState, - table: { - changeType: 'unchanged', - isMultiRow: !state.cachedFieldList[id].singleRow, - layerId: id, - columns: layer.columns.map((column) => { - const field = state.cachedFieldList[id].fields.find( - (f) => f.name === column.fieldName - )!; - const operation = { - dataType: field?.meta.type as DataType, - label: field?.name, - isBucketed: false, - noBucketInfo: true, - }; - return { - columnId: column.columnId, - operation, - }; - }), - }, - keptLayerIds: [id], - } - : { - state: { - ...reducedState, - layers: { - [id]: { - ...state.layers[id], - columns: state.cachedFieldList[id].fields.map((f) => ({ - columnId: f.name, - fieldName: f.name, - })), - }, - }, - }, - table: { - changeType: 'unchanged', - isMultiRow: !state.cachedFieldList[id].singleRow, - layerId: id, - columns: state.cachedFieldList[id].fields.map((f) => { - return { - columnId: f.name, - operation: { - dataType: f.meta.type, - label: f.name, - isBucketed: false, - noBucketInfo: true, - }, - }; - }), - }, - keptLayerIds: [id], - }; - }); - }, + getDatasourceSuggestionsForVisualizeField: getSuggestionsForState, + getDatasourceSuggestionsFromCurrentState: getSuggestionsForState, }; return essqlDatasource; diff --git a/x-pack/plugins/lens/public/essql_datasource/to_expression.ts b/x-pack/plugins/lens/public/essql_datasource/to_expression.ts index 65f0b4631f856..0733071c71f55 100644 --- a/x-pack/plugins/lens/public/essql_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/essql_datasource/to_expression.ts @@ -36,7 +36,7 @@ function getExpressionForLayer(layer: EsSQLLayer, refs: any): Ast | null { arguments: { discardFilters: [layer.hideFilterBar], query: [layer.query], - timefield: [refs.find((r) => r.id === layer.index)!.timeField], + timefield: [refs.find((r) => r.id === layer.index)?.timeField].filter(Boolean), }, }, { diff --git a/x-pack/plugins/lens/public/expression_datasource/datapanel.tsx b/x-pack/plugins/lens/public/expression_datasource/datapanel.tsx index 9dc20d701f333..981a33dd2a2b2 100644 --- a/x-pack/plugins/lens/public/expression_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/expression_datasource/datapanel.tsx @@ -40,7 +40,7 @@ import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; import { IndexPattern, ExpressionBasedPrivateState, IndexPatternField, IndexPatternRef } from './types'; import { esRawResponse } from '../../../../../src/plugins/data/common'; import { ChangeIndexPattern } from './change_indexpattern'; -import { FieldButton } from '@kbn/react-field/field_button'; +import { FieldButton } from '@kbn/react-field'; import { DragDrop, DragDropIdentifier } from '../drag_drop'; import { LensFieldIcon } from '../indexpattern_datasource/lens_field_icon'; import { ChildDragDropProvider, DragContextState } from '../drag_drop'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 8e4017a583a91..d86d205b07a4d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -228,7 +228,7 @@ export function getDatasourceSuggestionsForVisualizeField( const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId); // Identify the field by the indexPatternId and the fieldName const indexPattern = state.indexPatterns[indexPatternId]; - const field = indexPattern.getFieldByName(fieldName); + const field = indexPattern?.getFieldByName(fieldName); if (layerIds.length !== 0 || !field) return []; const newId = generateId(); diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts index 372d08017ee2a..598abbc042242 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts @@ -113,6 +113,8 @@ export function loadInitial( initEmpty({ newState: { ...emptyState, + activeDatasourceId: + initialContext && 'sql' in initialContext ? 'essql' : emptyState.activeDatasourceId, searchSessionId: data.search.session.getSessionId() || data.search.session.start(), datasourceStates: Object.entries(result).reduce( (state, [datasourceId, datasourceState]) => ({ diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index f71f7a128934a..bb00824b0c077 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -82,7 +82,7 @@ export function getIndexPatternsIds({ const uniqueFilterableIndexPatternIds = uniq( references.filter(({ type }) => type === 'index-pattern').map(({ id }) => id) - ); + ).filter(Boolean); return uniqueFilterableIndexPatternIds; } diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index ef7ba30d40ffc..e95e5e2d73882 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -45,6 +45,7 @@ export function getSuggestions({ subVisualizationId, mainPalette, }: SuggestionRequest): Array> { + const noBucketInfo = table.columns.some((c) => c.operation.noBucketInfo); const incompleteTable = !table.isMultiRow || table.columns.length <= 1 || @@ -73,7 +74,8 @@ export function getSuggestions({ (incompleteTable && state && !subVisualizationId) || table.columns.some((col) => col.operation.isStaticValue) || // do not use suggestions with non-numeric metrics - table.columns.some((col) => !col.operation.isBucketed && col.operation.dataType !== 'number') + (!noBucketInfo && + table.columns.some((col) => !col.operation.isBucketed && col.operation.dataType !== 'number')) ) { // reject incomplete configurations if the sub visualization isn't specifically requested // this allows to switch chart types via switcher with incomplete configurations, but won't From 570b419a9d7cf739b42bd78fad8da2542fb103bf Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 7 Mar 2022 09:38:31 +0100 Subject: [PATCH 15/15] show all fields --- .../components/sidebar/discover_sidebar.tsx | 18 ++++++++++++++++-- .../lib/get_index_pattern_field_list.ts | 2 +- .../components/sidebar/lib/group_fields.tsx | 5 +++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx index 1d89c17539ae6..3fbdc5195f623 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx @@ -123,7 +123,12 @@ export function DiscoverSidebarComponent({ useEffect(() => { if (documents) { - const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts, state.sqlMode, table); + const newFields = getIndexPatternFieldList( + selectedIndexPattern, + fieldCounts, + state.sqlMode, + table + ); setFields(newFields); } }, [selectedIndexPattern, fieldCounts, documents, state.sqlMode, table]); @@ -151,7 +156,16 @@ export function DiscoverSidebarComponent({ popular: popularFields, unpopular: unpopularFields, } = useMemo( - () => groupFields(fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi), + () => + groupFields( + fields, + columns, + popularLimit, + fieldCounts, + fieldFilter, + useNewFieldsApi, + state.sqlMode + ), [fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi] ); diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/get_index_pattern_field_list.ts b/src/plugins/discover/public/application/main/components/sidebar/lib/get_index_pattern_field_list.ts index 20b2ffc24735d..fe026e84df24c 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/lib/get_index_pattern_field_list.ts +++ b/src/plugins/discover/public/application/main/components/sidebar/lib/get_index_pattern_field_list.ts @@ -26,7 +26,7 @@ export function getIndexPatternFieldList( return table.columns.map((c) => ({ displayName: c.name, name: c.name, - type: c.type, + type: c.meta.type, })); } diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx index 41fd49bf92ec5..da9f16707f41d 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx @@ -24,7 +24,8 @@ export function groupFields( popularLimit: number, fieldCounts: Record | undefined, fieldFilterState: FieldFilterState, - useNewFieldsApi: boolean + useNewFieldsApi: boolean, + sqlMode: boolean ): GroupedFields { const showUnmappedFields = useNewFieldsApi; const result: GroupedFields = { @@ -51,7 +52,7 @@ export function groupFields( const fieldsSorted = fields.sort(compareFn); for (const field of fieldsSorted) { - if (!isFieldFiltered(field, fieldFilterState, fieldCounts)) { + if (!sqlMode && !isFieldFiltered(field, fieldFilterState, fieldCounts)) { continue; }