From d364f237c5dd2309eb7e61c3c46399048719fa25 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 14 Feb 2022 19:10:17 +0200 Subject: [PATCH] [TSVB][Lens] Navigate to Lens with your current configuration (#114794) * [Lens][TSVB] Convert to Lens * Add logic for multiple series * Basic formula * Fix circular dependencies * Minor cleanup * Fix types * fix jest tests * Fix test * Change the schema, add more styling options, fix bugs * Supports time shift and custom date interval * Fix types * Fix some types * Move edit in lens button to top nav menu * Cleanup * Further cleanup * Add try it badge in menu, controlled by localStorage * Add go back to app button * Discard changes modal and go back to TSVB * Update by value and by reference visualizations, delete existing by ref * Fix bug * Apply some changes * get title and description only if has context * Pass originating app, title and description from the savedVis * By ref TSVB to by ref Lens * Match TSVB cardinality with Lens unique_count function * Support moving average * Fix test * Support derivative * Support cumulative_sum * Add overall functions * Support filter ratio * Refactor code for easier testing * Fix bug with auto interval * Fetch types from visualizations plugin * Pipeline aggs compatible with percentile * Add some bugs * Support nesred aggs * Mini refactor and support all aggregations to Math * Transfer terms sorting options * Transfer axis position * Fix translations keys * Revert * Fix redirectToOrigin buttion when the there is no embeddableId but comes from dashboard * Improve context identification * Support yExtents * Fix bug in formula caused by changes in the main branch * Support formatters * Support custom label * Cleaning up * Fix terms bugs * Support filter breakdown by * Fixes math bug and escapes filter ratio query * Add some unit tests * Testing triggerOptions payload * Fix console warning * Add more unit tests on TSVB function helpers * Adds a unit test on the vis top nav menu testing the new menu item * Add unit tests * Fix unsupported palette bug, clean up, add a unit test case * Add final unit tests * Support timeScale in derivative * Add functional tests * Cleanup * Fix jest test * Fix some bugs * Fix some math agg bugs * Fix more bugs * Fixes jest test * Fix the problem with the dashboard state * Hides the badge and link instead of disabling it * Changes the text * Adds menu item vertical separator * Enhance the appLeace confirm modal to change the confirm button text and color * Fixes CI * Adress code review comments * Address some of the comments * Fix more bugs * Fix more bugs * Zero decimals for formatting * fix tests * Navigate from dashboard to TSVB to Lens hides the appLeave modal * Adds support for terms on a date field * Support filter by * Move the trigger to the visualizations plugin * Minor * Fix CI * Support percentage charts * Improve the vertical separator * Fixes on the appLeave logic * Remove unecessary import * Add badge to the nav item level * Fix jest test * Fi filter ratio and filter by bug * Replace all occurences of a variable * Nest badge into the button level * Design improvements Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...ublic.appleaveconfirmaction.buttoncolor.md | 11 + ...appleaveconfirmaction.confirmbuttontext.md | 11 + ...lugin-core-public.appleaveconfirmaction.md | 2 + .../application/application_leave.test.ts | 12 + .../public/application/application_leave.tsx | 19 +- .../application/application_service.tsx | 2 + src/core/public/application/types.ts | 14 +- src/core/public/public.api.md | 5 + .../lib/build_dashboard_container.ts | 4 +- .../public/top_nav_menu/_index.scss | 13 + .../public/top_nav_menu/top_nav_menu_data.tsx | 3 +- .../public/top_nav_menu/top_nav_menu_item.tsx | 19 +- .../timeseries/common/types/panel_model.ts | 2 +- .../timeseries/public/metrics_type.ts | 7 + .../get_datasource_info.test.ts | 69 +++ .../trigger_action/get_datasource_info.ts | 46 ++ .../public/trigger_action/get_extents.test.ts | 112 +++++ .../public/trigger_action/get_extents.ts | 121 +++++ .../public/trigger_action/get_field_type.ts | 15 + .../public/trigger_action/get_series.test.ts | 369 +++++++++++++++ .../public/trigger_action/get_series.ts | 162 +++++++ .../public/trigger_action/index.test.ts | 306 +++++++++++++ .../timeseries/public/trigger_action/index.ts | 165 +++++++ .../trigger_action/metrics_helpers.test.ts | 193 ++++++++ .../public/trigger_action/metrics_helpers.ts | 306 +++++++++++++ .../trigger_action/supported_metrics.ts | 87 ++++ src/plugins/visualizations/public/index.ts | 12 +- src/plugins/visualizations/public/mocks.ts | 1 + src/plugins/visualizations/public/plugin.ts | 6 +- .../visualizations/public/triggers/index.ts | 18 + .../public/vis_types/base_vis_type.ts | 2 + .../visualizations/public/vis_types/index.ts | 8 +- .../visualizations/public/vis_types/types.ts | 85 +++- .../components/visualize_top_nav.tsx | 43 +- .../utils/get_top_nav_config.test.tsx | 143 ++++++ .../utils/get_top_nav_config.tsx | 56 +++ .../public/visualize_app/utils/utils.ts | 6 +- .../utils/visualize_navigation.scss | 19 + .../plugins/lens/public/app_plugin/app.scss | 20 + .../lens/public/app_plugin/app.test.tsx | 76 ++++ x-pack/plugins/lens/public/app_plugin/app.tsx | 111 ++++- .../lens/public/app_plugin/lens_top_nav.tsx | 66 ++- .../lens/public/app_plugin/mounter.tsx | 24 +- .../plugins/lens/public/app_plugin/types.ts | 17 +- .../editor_frame/editor_frame.tsx | 4 +- .../editor_frame/state_helpers.ts | 3 +- .../editor_frame/suggestion_helpers.test.ts | 167 ++++++- .../editor_frame/suggestion_helpers.ts | 55 ++- .../editor_frame/suggestion_panel.test.tsx | 4 +- .../workspace_panel/chart_switch.tsx | 3 +- .../workspace_panel/workspace_panel.tsx | 3 +- .../indexpattern_datasource/indexpattern.tsx | 6 +- .../indexpattern_suggestions.test.tsx | 429 +++++++++++++++++- .../indexpattern_suggestions.ts | 85 +++- .../indexpattern_datasource/loader.test.ts | 52 +++ .../public/indexpattern_datasource/loader.ts | 25 +- .../definitions/calculations/counter_rate.tsx | 4 +- .../definitions/calculations/differences.tsx | 4 +- .../calculations/moving_average.tsx | 8 +- .../operations/definitions/index.ts | 2 +- .../operations/layer_helpers.ts | 302 +++++++++++- .../lens/public/mocks/datasource_mock.ts | 1 + x-pack/plugins/lens/public/plugin.ts | 7 + .../public/state_management/lens_slice.ts | 10 +- .../lens/public/state_management/types.ts | 9 +- .../trigger_actions/visualize_tsvb_actions.ts | 40 ++ x-pack/plugins/lens/public/types.ts | 76 +++- .../lens/public/xy_visualization/types.ts | 11 +- .../xy_visualization/visualization.test.ts | 242 +++++++++- .../public/xy_visualization/visualization.tsx | 81 +++- x-pack/test/functional/apps/lens/index.ts | 1 + .../functional/apps/lens/tsvb_open_in_lens.ts | 183 ++++++++ 72 files changed, 4473 insertions(+), 132 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/get_extents.test.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/get_extents.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/index.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.test.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts create mode 100644 src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts create mode 100644 src/plugins/visualizations/public/triggers/index.ts create mode 100644 src/plugins/visualizations/public/visualize_app/utils/visualize_navigation.scss create mode 100644 x-pack/plugins/lens/public/trigger_actions/visualize_tsvb_actions.ts create mode 100644 x-pack/test/functional/apps/lens/tsvb_open_in_lens.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md new file mode 100644 index 0000000000000..6a3c790cd17a2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) > [buttonColor](./kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md) + +## AppLeaveConfirmAction.buttonColor property + +Signature: + +```typescript +buttonColor?: ButtonColor; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md new file mode 100644 index 0000000000000..10ccb6d220f3f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) > [confirmButtonText](./kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md) + +## AppLeaveConfirmAction.confirmButtonText property + +Signature: + +```typescript +confirmButtonText?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md index e44fe49c27c8c..9f18643787019 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md @@ -18,7 +18,9 @@ export interface AppLeaveConfirmAction | Property | Type | Description | | --- | --- | --- | +| [buttonColor?](./kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md) | ButtonColor | (Optional) | | [callback?](./kibana-plugin-core-public.appleaveconfirmaction.callback.md) | () => void | (Optional) | +| [confirmButtonText?](./kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md) | string | (Optional) | | [text](./kibana-plugin-core-public.appleaveconfirmaction.text.md) | string | | | [title?](./kibana-plugin-core-public.appleaveconfirmaction.title.md) | string | (Optional) | | [type](./kibana-plugin-core-public.appleaveconfirmaction.type.md) | AppLeaveActionType.confirm | | diff --git a/src/core/public/application/application_leave.test.ts b/src/core/public/application/application_leave.test.ts index 62ebb52ebc38f..6df4e0d13cc44 100644 --- a/src/core/public/application/application_leave.test.ts +++ b/src/core/public/application/application_leave.test.ts @@ -54,5 +54,17 @@ describe('getLeaveAction', () => { title: 'a title', callback, }); + expect( + getLeaveAction((actions) => + actions.confirm('another message', 'a title', callback, 'confirm button text', 'danger') + ) + ).toEqual({ + type: AppLeaveActionType.confirm, + text: 'another message', + title: 'a title', + callback, + confirmButtonText: 'confirm button text', + buttonColor: 'danger', + }); }); }); diff --git a/src/core/public/application/application_leave.tsx b/src/core/public/application/application_leave.tsx index 058b11728e907..f3f5932519a28 100644 --- a/src/core/public/application/application_leave.tsx +++ b/src/core/public/application/application_leave.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import type { ButtonColor } from '@elastic/eui'; import { AppLeaveActionFactory, AppLeaveActionType, @@ -15,8 +15,21 @@ import { } from './types'; const appLeaveActionFactory: AppLeaveActionFactory = { - confirm(text: string, title?: string, callback?: () => void) { - return { type: AppLeaveActionType.confirm, text, title, callback }; + confirm( + text: string, + title?: string, + callback?: () => void, + confirmButtonText?: string, + buttonColor?: ButtonColor + ) { + return { + type: AppLeaveActionType.confirm, + text, + title, + confirmButtonText, + buttonColor, + callback, + }; }, default() { return { type: AppLeaveActionType.default }; diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 3010a781b4e9e..1cfae598f67c8 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -365,6 +365,8 @@ export class ApplicationService { const confirmed = await overlays.openConfirm(action.text, { title: action.title, 'data-test-subj': 'appLeaveConfirmModal', + confirmButtonText: action.confirmButtonText, + buttonColor: action.buttonColor, }); if (!confirmed) { if (action.callback) { diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 187cee8d0a29a..af5fdc08e9b45 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import type { ButtonColor } from '@elastic/eui'; import { Observable } from 'rxjs'; import { History } from 'history'; import { RecursiveReadonly } from '@kbn/utility-types'; @@ -597,6 +597,8 @@ export interface AppLeaveConfirmAction { type: AppLeaveActionType.confirm; text: string; title?: string; + confirmButtonText?: string; + buttonColor?: ButtonColor; callback?: () => void; } @@ -621,9 +623,17 @@ export interface AppLeaveActionFactory { * @param text The text to display in the confirmation message * @param title (optional) title to display in the confirmation message * @param callback (optional) to know that the user want to stay on the page + * @param confirmButtonText (optional) text for the confirmation button + * @param buttonColor (optional) color for the confirmation button * so we can show to the user the right UX for him to saved his/her/their changes */ - confirm(text: string, title?: string, callback?: () => void): AppLeaveConfirmAction; + confirm( + text: string, + title?: string, + callback?: () => void, + confirmButtonText?: string, + buttonColor?: ButtonColor + ): AppLeaveConfirmAction; /** * Returns a default action, resulting on executing the default behavior when diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 6d2ee9a5dd4e1..c610c98c53646 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -8,6 +8,7 @@ import { Action } from 'history'; import Boom from '@hapi/boom'; +import type { ButtonColor } from '@elastic/eui'; import { ByteSizeValue } from '@kbn/config-schema'; import type { Client } from '@elastic/elasticsearch'; import { ConfigPath } from '@kbn/config'; @@ -115,9 +116,13 @@ export enum AppLeaveActionType { // // @public export interface AppLeaveConfirmAction { + // (undocumented) + buttonColor?: ButtonColor; // (undocumented) callback?: () => void; // (undocumented) + confirmButtonText?: string; + // (undocumented) text: string; // (undocumented) title?: string; diff --git a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts b/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts index 5752a6445d2a9..4db7eabe6d78d 100644 --- a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts +++ b/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts @@ -122,7 +122,9 @@ export const buildDashboardContainer = async ({ gridData: originalPanelState.gridData, type: incomingEmbeddable.type, explicitInput: { - ...originalPanelState.explicitInput, + ...(incomingEmbeddable.type === originalPanelState.type && { + ...originalPanelState.explicitInput, + }), ...incomingEmbeddable.input, id: incomingEmbeddable.embeddableId, }, diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index c0d5ee5a7593d..db6cf1bc3d006 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -12,3 +12,16 @@ .kbnTopNavMenu__badgeGroup { margin-right: $euiSizeM; } + +.kbnTopNavMenu__betaBadgeItem { + margin-right: $euiSizeS; + vertical-align: middle; + + button:hover &, + button:focus & { + text-decoration: underline; + } + button:hover & { + cursor: pointer; + } +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index b6b056134361a..b74fe5249e66c 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiButtonProps } from '@elastic/eui'; +import { EuiButtonProps, EuiBetaBadgeProps } from '@elastic/eui'; export type TopNavMenuAction = (anchorElement: HTMLElement) => void; @@ -19,6 +19,7 @@ export interface TopNavMenuData { className?: string; disableButton?: boolean | (() => boolean); tooltip?: string | (() => string | undefined); + badge?: EuiBetaBadgeProps; emphasize?: boolean; isLoading?: boolean; iconType?: string; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index dd542d5240d9e..721a0fae0e62f 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -8,7 +8,7 @@ import { upperFirst, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; -import { EuiToolTip, EuiButton, EuiHeaderLink } from '@elastic/eui'; +import { EuiToolTip, EuiButton, EuiHeaderLink, EuiBetaBadge } from '@elastic/eui'; import { TopNavMenuData } from './top_nav_menu_data'; export function TopNavMenuItem(props: TopNavMenuData) { @@ -22,6 +22,19 @@ export function TopNavMenuItem(props: TopNavMenuData) { return val!; } + function getButtonContainer() { + if (props.badge) { + return ( + <> + + {upperFirst(props.label || props.id!)} + + ); + } else { + return upperFirst(props.label || props.id!); + } + } + function handleClick(e: MouseEvent) { if (isDisabled()) return; props.run(e.currentTarget); @@ -39,11 +52,11 @@ export function TopNavMenuItem(props: TopNavMenuData) { const btn = props.emphasize ? ( - {upperFirst(props.label || props.id!)} + {getButtonContainer()} ) : ( - {upperFirst(props.label || props.id!)} + {getButtonContainer()} ); diff --git a/src/plugins/vis_types/timeseries/common/types/panel_model.ts b/src/plugins/vis_types/timeseries/common/types/panel_model.ts index b4b167310a194..40bd5632c3a80 100644 --- a/src/plugins/vis_types/timeseries/common/types/panel_model.ts +++ b/src/plugins/vis_types/timeseries/common/types/panel_model.ts @@ -78,7 +78,7 @@ export interface Series { chart_type: string; color: string; color_rules?: ColorRules[]; - fill?: number; + fill?: string; filter?: Query; formatter: string; hidden?: boolean; diff --git a/src/plugins/vis_types/timeseries/public/metrics_type.ts b/src/plugins/vis_types/timeseries/public/metrics_type.ts index 4695748661299..ff613c0eadb06 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_type.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_type.ts @@ -26,6 +26,7 @@ import { } from '../../../visualizations/public'; import { getDataStart } from './services'; import type { TimeseriesVisDefaultParams, TimeseriesVisParams } from './types'; +import { triggerTSVBtoLensConfiguration } from './trigger_action'; import type { IndexPatternValue, Panel } from '../common/types'; import { RequestAdapter } from '../../../inspector/public'; @@ -167,6 +168,12 @@ export const metricsVisDefinition: VisTypeDefinition< } return []; }, + navigateToLens: async (params?: VisParams) => { + const triggerConfiguration = params + ? await triggerTSVBtoLensConfiguration(params as Panel) + : null; + return triggerConfiguration; + }, inspectorAdapters: () => ({ requests: new RequestAdapter(), }), diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts new file mode 100644 index 0000000000000..5a3c545d80aa0 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts @@ -0,0 +1,69 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { DataView } from '../../../../data/common'; +import { getDataSourceInfo } from './get_datasource_info'; +const dataViewsMap: Record = { + test1: { id: 'test1', title: 'test1', timeFieldName: 'timeField1' } as DataView, + test2: { + id: 'test2', + title: 'test2', + timeFieldName: 'timeField2', + } as DataView, + test3: { id: 'test3', title: 'test3', timeFieldName: 'timeField3' } as DataView, +}; + +const getDataview = (id: string): DataView | undefined => dataViewsMap[id]; +jest.mock('../services', () => { + return { + getDataStart: jest.fn(() => { + return { + dataViews: { + getDefault: jest.fn(() => { + return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; + }), + get: getDataview, + }, + }; + }), + }; +}); + +describe('getDataSourceInfo', () => { + test('should return the default dataview if model_indexpattern is string', async () => { + const { indexPatternId, timeField } = await getDataSourceInfo( + 'test', + undefined, + false, + undefined + ); + expect(indexPatternId).toBe('12345'); + expect(timeField).toBe('@timestamp'); + }); + + test('should return the correct dataview if model_indexpattern is object', async () => { + const { indexPatternId, timeField } = await getDataSourceInfo( + { id: 'dataview-1-id' }, + 'timeField-1', + false, + undefined + ); + expect(indexPatternId).toBe('dataview-1-id'); + expect(timeField).toBe('timeField-1'); + }); + + test('should fetch the correct data if overwritten dataview is provided', async () => { + const { indexPatternId, timeField } = await getDataSourceInfo( + { id: 'dataview-1-id' }, + 'timeField-1', + true, + { id: 'test2' } + ); + expect(indexPatternId).toBe('test2'); + expect(timeField).toBe('timeField2'); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts new file mode 100644 index 0000000000000..0b4d6e6eacd3a --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { fetchIndexPattern, isStringTypeIndexPattern } from '../../common/index_patterns_utils'; +import type { IndexPatternValue } from '../../common/types'; +import { getDataStart } from '../services'; + +export const getDataSourceInfo = async ( + modelIndexPattern: IndexPatternValue, + modelTimeField: string | undefined, + isOverwritten: boolean, + overwrittenIndexPattern: IndexPatternValue | undefined +) => { + const { dataViews } = getDataStart(); + let indexPatternId = + modelIndexPattern && !isStringTypeIndexPattern(modelIndexPattern) ? modelIndexPattern.id : ''; + + let timeField = modelTimeField; + // handle override index pattern + if (isOverwritten) { + const { indexPattern } = await fetchIndexPattern(overwrittenIndexPattern, dataViews); + if (indexPattern) { + indexPatternId = indexPattern.id ?? ''; + timeField = indexPattern.timeFieldName; + } + } + + if (!indexPatternId) { + const defaultIndex = await dataViews.getDefault(); + indexPatternId = defaultIndex?.id ?? ''; + timeField = defaultIndex?.timeFieldName; + } + if (!timeField) { + const indexPattern = await dataViews.get(indexPatternId); + timeField = indexPattern.timeFieldName; + } + + return { + indexPatternId, + timeField, + }; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.test.ts new file mode 100644 index 0000000000000..67ee8a1fb290c --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.test.ts @@ -0,0 +1,112 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { Panel } from '../../common/types'; +import { getYExtents } from './get_extents'; + +const model = { + axis_position: 'left', + series: [ + { + axis_position: 'right', + chart_type: 'line', + fill: '0', + id: '85147356-c185-4636-9182-d55f3ab2b6fa', + line_width: 1, + metrics: [ + { + id: '3fa8b32f-5c38-4813-9361-1f2817ae5b18', + type: 'count', + }, + ], + override_index_pattern: 0, + separate_axis: 0, + }, + ], +} as Panel; + +describe('getYExtents', () => { + test('should return no extents if no extents are given from the user', () => { + const { yLeftExtent } = getYExtents(model); + expect(yLeftExtent).toStrictEqual({ mode: 'full' }); + }); + + test('should return the global extents, if no specific extents are given per series', () => { + const modelOnlyGlobalSettings = { + ...model, + axis_max: '10', + axis_min: '2', + }; + const { yLeftExtent } = getYExtents(modelOnlyGlobalSettings); + expect(yLeftExtent).toStrictEqual({ mode: 'custom', lowerBound: 2, upperBound: 10 }); + }); + + test('should return the series extents, if specific extents are given per series', () => { + const modelWithExtentsOnSeries = { + ...model, + axis_max: '10', + axis_min: '2', + series: [ + { + ...model.series[0], + axis_max: '14', + axis_min: '1', + separate_axis: 1, + axis_position: 'left', + }, + ], + }; + const { yLeftExtent } = getYExtents(modelWithExtentsOnSeries); + expect(yLeftExtent).toStrictEqual({ mode: 'custom', lowerBound: 1, upperBound: 14 }); + }); + + test('should not send the lowerbound for a bar chart', () => { + const modelWithExtentsOnSeries = { + ...model, + axis_max: '10', + axis_min: '2', + series: [ + { + ...model.series[0], + axis_max: '14', + axis_min: '1', + separate_axis: 1, + axis_position: 'left', + chart_type: 'bar', + }, + ], + }; + const { yLeftExtent } = getYExtents(modelWithExtentsOnSeries); + expect(yLeftExtent).toStrictEqual({ mode: 'custom', upperBound: 14 }); + }); + + test('should merge the extents for 2 series on the same axis', () => { + const modelWithExtentsOnSeries = { + ...model, + axis_max: '10', + axis_min: '2', + series: [ + { + ...model.series[0], + axis_max: '14', + axis_min: '1', + separate_axis: 1, + axis_position: 'left', + }, + { + ...model.series[0], + axis_max: '20', + axis_min: '5', + separate_axis: 1, + axis_position: 'left', + }, + ], + }; + const { yLeftExtent } = getYExtents(modelWithExtentsOnSeries); + expect(yLeftExtent).toStrictEqual({ mode: 'custom', lowerBound: 1, upperBound: 20 }); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.ts new file mode 100644 index 0000000000000..857de8390a6a3 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.ts @@ -0,0 +1,121 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Panel, Series } from '../../common/types'; + +const lowerBoundShouldBeZero = ( + lowerBound: number | null, + upperBound: number | null, + hasBarAreaChart: boolean +) => { + return (hasBarAreaChart && lowerBound && lowerBound > 0) || (upperBound && upperBound < 0); +}; + +const computeBounds = (series: Series, lowerBound: number | null, upperBound: number | null) => { + if (!lowerBound) { + lowerBound = Number(series.axis_min); + } else if (Number(series.axis_min) < lowerBound) { + lowerBound = Number(series.axis_min); + } + + if (!upperBound) { + upperBound = Number(series.axis_max); + } else if (Number(series.axis_max) > upperBound) { + upperBound = Number(series.axis_max); + } + + return { lowerBound, upperBound }; +}; + +const getLowerValue = ( + minValue: number | null, + maxValue: number | null, + hasBarOrAreaRight: boolean +) => { + return lowerBoundShouldBeZero(minValue, maxValue, hasBarOrAreaRight) ? 0 : minValue; +}; + +/* + * In TSVB the user can have different axis with different bounds. + * In Lens, we only allow 2 axis, one left and one right. We need an assumption here. + * We will transfer in Lens the "collapsed" axes with both bounds. + */ +export const getYExtents = (model: Panel) => { + let lowerBoundLeft: number | null = null; + let upperBoundLeft: number | null = null; + let lowerBoundRight: number | null = null; + let upperBoundRight: number | null = null; + let ignoreGlobalSettingsLeft = false; + let ignoreGlobalSettingsRight = false; + let hasBarOrAreaLeft = false; + let hasBarOrAreaRight = false; + + model.series.forEach((s) => { + if (s.axis_position === 'left') { + if (s.chart_type !== 'line' || (s.chart_type === 'line' && s.fill !== '0')) { + hasBarOrAreaLeft = true; + } + if (s.separate_axis) { + ignoreGlobalSettingsLeft = true; + const { lowerBound, upperBound } = computeBounds(s, lowerBoundLeft, upperBoundLeft); + lowerBoundLeft = lowerBound; + upperBoundLeft = upperBound; + } + } + if (s.axis_position === 'right' && s.separate_axis) { + if (s.chart_type !== 'line' || (s.chart_type === 'line' && s.fill !== '0')) { + hasBarOrAreaRight = true; + } + if (s.separate_axis) { + ignoreGlobalSettingsRight = true; + const { lowerBound, upperBound } = computeBounds(s, lowerBoundRight, upperBoundRight); + lowerBoundRight = lowerBound; + upperBoundRight = upperBound; + } + } + }); + + const finalLowerBoundLeft = ignoreGlobalSettingsLeft + ? getLowerValue(lowerBoundLeft, upperBoundLeft, hasBarOrAreaLeft) + : model.axis_position === 'left' + ? getLowerValue(Number(model.axis_min), Number(model.axis_max), hasBarOrAreaLeft) + : null; + + const finalUpperBoundLeft = ignoreGlobalSettingsLeft + ? upperBoundLeft + : model.axis_position === 'left' + ? model.axis_max + : null; + + const finalLowerBoundRight = ignoreGlobalSettingsRight + ? getLowerValue(lowerBoundRight, upperBoundRight, hasBarOrAreaRight) + : model.axis_position === 'right' + ? model.axis_min + : null; + const finalUpperBoundRight = ignoreGlobalSettingsRight + ? upperBoundRight + : model.axis_position === 'right' + ? getLowerValue(Number(model.axis_min), Number(model.axis_max), hasBarOrAreaRight) + : null; + return { + yLeftExtent: { + ...(finalLowerBoundLeft && { + lowerBound: Number(finalLowerBoundLeft), + }), + ...(finalUpperBoundLeft && { upperBound: Number(finalUpperBoundLeft) }), + mode: finalLowerBoundLeft || finalUpperBoundLeft ? 'custom' : 'full', + }, + yRightExtent: { + ...(finalLowerBoundRight && { + lowerBound: Number(finalUpperBoundRight), + }), + ...(finalUpperBoundRight && { upperBound: Number(finalUpperBoundRight) }), + mode: finalLowerBoundRight || finalUpperBoundRight ? 'custom' : 'full', + }, + }; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts new file mode 100644 index 0000000000000..c71955942c91c --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { getDataStart } from '../services'; + +export const getFieldType = async (indexPatternId: string, fieldName: string) => { + const { dataViews } = getDataStart(); + const dataView = await dataViews.get(indexPatternId); + const field = await dataView.getFieldByName(fieldName); + return field?.type; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts new file mode 100644 index 0000000000000..7410c95677cff --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts @@ -0,0 +1,369 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { Metric } from '../../common/types'; +import { getSeries } from './get_series'; + +describe('getSeries', () => { + test('should return the correct config for an average aggregation', () => { + const metric = [ + { + id: '12345', + type: 'avg', + field: 'day_of_week_i', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'average', + fieldName: 'day_of_week_i', + isFullReference: false, + params: {}, + }, + ]); + }); + + test('should return the correct formula config for a filter ratio aggregation', () => { + const metric = [ + { + id: '12345', + type: 'filter_ratio', + field: 'day_of_week_i', + numerator: { + query: 'category.keyword : "Men\'s Clothing" ', + language: 'kuery', + }, + denominator: { + query: 'customer_gender : "FEMALE" ', + language: 'kuery', + }, + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: + "count(kql='category.keyword : \"Men\\'s Clothing\" ') / count(kql='customer_gender : \"FEMALE\" ')", + }, + }, + ]); + }); + + test('should return the correct formula config for an overall function', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + id: '891011', + type: 'max_bucket', + field: '123456', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: 'overall_max(max(day_of_week_i))', + }, + }, + ]); + }); + + test('should return the correct config for the cumulative sum on count', () => { + const metric = [ + { + id: '123456', + type: 'count', + }, + { + id: '7891011', + type: 'cumulative_sum', + field: '123456', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'cumulative_sum', + fieldName: 'document', + isFullReference: true, + params: {}, + pipelineAggType: 'count', + }, + ]); + }); + + test('should return the correct formula config for the cumulative sum on max', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + id: '7891011', + type: 'cumulative_sum', + field: '123456', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: 'cumulative_sum(max(day_of_week_i))', + }, + }, + ]); + }); + + test('should return the correct config for the derivative aggregation', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + field: '123456', + id: '7891011', + type: 'derivative', + unit: '1m', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'differences', + fieldName: 'day_of_week_i', + isFullReference: true, + params: { + timeScale: 'm', + }, + pipelineAggType: 'max', + }, + ]); + }); + + test('should return the correct config for the moving average aggregation', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + field: '123456', + id: '7891011', + type: 'moving_average', + window: 6, + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'moving_average', + fieldName: 'day_of_week_i', + isFullReference: true, + params: { window: 6 }, + pipelineAggType: 'max', + }, + ]); + }); + + test('should return the correct formula for the math aggregation', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + field: 'day_of_week_i', + id: '7891011', + type: 'min', + }, + { + field: '123456', + id: 'fab31880-7d11-11ec-a13a-b52b40401df4', + script: 'params.max - params.min', + type: 'math', + variables: [ + { + field: '123456', + id: 'c47c7a00-7d15-11ec-a13a-b52b40401df4', + name: 'max', + }, + { + field: '7891011', + id: 'c7a38390-7d15-11ec-a13a-b52b40401df4', + name: 'min', + }, + ], + window: 6, + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: 'max(day_of_week_i) - min(day_of_week_i)', + }, + }, + ]); + }); + + test('should return the correct config for the percentiles aggregation', () => { + const metric = [ + { + field: 'day_of_week_i', + id: 'id1', + type: 'percentile', + percentiles: [ + { + value: '90', + percentile: '', + shade: 0.2, + color: 'rgba(211,96,134,1)', + id: 'id2', + mode: 'line', + }, + { + value: '85', + percentile: '', + shade: 0.2, + color: 'rgba(155,33,230,1)', + id: 'id3', + mode: 'line', + }, + { + value: '70', + percentile: '', + shade: 0.2, + color: '#68BC00', + id: 'id4', + mode: 'line', + }, + ], + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'percentile', + color: 'rgba(211,96,134,1)', + fieldName: 'day_of_week_i', + isFullReference: false, + params: { + percentile: '90', + }, + }, + { + agg: 'percentile', + color: 'rgba(155,33,230,1)', + fieldName: 'day_of_week_i', + isFullReference: false, + params: { + percentile: '85', + }, + }, + { + agg: 'percentile', + color: '#68BC00', + fieldName: 'day_of_week_i', + isFullReference: false, + params: { + percentile: '70', + }, + }, + ]); + }); + + test('should return the correct formula for the math aggregation with percentiles as variables', () => { + const metric = [ + { + field: 'day_of_week_i', + id: 'e72265d2-2106-4af9-b646-33afd9cddcad', + percentiles: [ + { + color: 'rgba(211,96,134,1)', + id: '381a6850-7d16-11ec-a13a-b52b40401df4', + mode: 'line', + percentile: '', + shade: 0.2, + value: '90', + }, + { + color: 'rgba(0,107,188,1)', + id: '52f02970-7d1c-11ec-bfa7-3798d98f8341', + mode: 'line', + percentile: '', + shade: 0.2, + value: '50', + }, + ], + type: 'percentile', + unit: '', + }, + { + field: 'day_of_week_i', + id: '6280b080-7d1c-11ec-bfa7-3798d98f8341', + type: 'avg', + }, + { + id: '23a05540-7d18-11ec-a589-45a3784fc1ce', + script: 'params.perc90 + params.perc70 + params.avg', + type: 'math', + variables: [ + { + field: 'e72265d2-2106-4af9-b646-33afd9cddcad[90.0]', + id: '25840960-7d18-11ec-a589-45a3784fc1ce', + name: 'perc90', + }, + { + field: 'e72265d2-2106-4af9-b646-33afd9cddcad[50.0]', + id: '2a440270-7d18-11ec-a589-45a3784fc1ce', + name: 'perc70', + }, + { + field: '6280b080-7d1c-11ec-bfa7-3798d98f8341', + id: '64c82f80-7d1c-11ec-bfa7-3798d98f8341', + name: 'avg', + }, + ], + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: + 'percentile(day_of_week_i, percentile=90) + percentile(day_of_week_i, percentile=50) + average(day_of_week_i)', + }, + }, + ]); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts new file mode 100644 index 0000000000000..eed1594300b92 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts @@ -0,0 +1,162 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { VisualizeEditorLayersContext } from '../../../../visualizations/public'; +import type { Metric } from '../../common/types'; +import { SUPPORTED_METRICS } from './supported_metrics'; +import { + getPercentilesSeries, + getFormulaSeries, + getParentPipelineSeries, + getSiblingPipelineSeriesFormula, + getPipelineAgg, + computeParentSeries, + getFormulaEquivalent, + getParentPipelineSeriesFormula, + getFilterRatioFormula, + getTimeScale, +} from './metrics_helpers'; + +export const getSeries = (metrics: Metric[]): VisualizeEditorLayersContext['metrics'] | null => { + const metricIdx = metrics.length - 1; + const aggregation = metrics[metricIdx].type; + const fieldName = metrics[metricIdx].field; + const aggregationMap = SUPPORTED_METRICS[aggregation]; + if (!aggregationMap) { + return null; + } + let metricsArray: VisualizeEditorLayersContext['metrics'] = []; + switch (aggregation) { + case 'percentile': { + const percentiles = metrics[metricIdx].percentiles; + if (percentiles?.length) { + const percentilesSeries = getPercentilesSeries( + percentiles, + fieldName + ) as VisualizeEditorLayersContext['metrics']; + metricsArray = [...metricsArray, ...percentilesSeries]; + } + break; + } + case 'math': { + // find the metric idx that has math expression + const mathMetricIdx = metrics.findIndex((metric) => metric.type === 'math'); + let finalScript = metrics[mathMetricIdx].script; + + const variables = metrics[mathMetricIdx].variables; + const layerMetricsArray = metrics; + if (!finalScript || !variables) return null; + + // create the script + for (let layerMetricIdx = 0; layerMetricIdx < layerMetricsArray.length; layerMetricIdx++) { + if (layerMetricsArray[layerMetricIdx].type === 'math') { + continue; + } + const currentMetric = metrics[layerMetricIdx]; + + // should treat percentiles differently + if (currentMetric.type === 'percentile') { + variables.forEach((variable) => { + const [_, meta] = variable?.field?.split('[') ?? []; + const metaValue = Number(meta?.replace(']', '')); + if (!metaValue) return; + const script = getFormulaEquivalent(currentMetric, layerMetricsArray, metaValue); + if (!script) return; + finalScript = finalScript?.replace(`params.${variable.name}`, script); + }); + } else { + const script = getFormulaEquivalent(currentMetric, layerMetricsArray); + if (!script) return null; + const variable = variables.find((v) => v.field === currentMetric.id); + finalScript = finalScript?.replaceAll(`params.${variable?.name}`, script); + } + } + const scripthasNoStaticNumber = isNaN(Number(finalScript)); + if (finalScript.includes('params') || !scripthasNoStaticNumber) return null; + metricsArray = getFormulaSeries(finalScript); + break; + } + case 'moving_average': + case 'derivative': { + metricsArray = getParentPipelineSeries( + aggregation, + metricIdx, + metrics + ) as VisualizeEditorLayersContext['metrics']; + break; + } + case 'cumulative_sum': { + // percentile value is derived from the field Id. It has the format xxx-xxx-xxx-xxx[percentile] + const [fieldId, meta] = metrics[metricIdx]?.field?.split('[') ?? []; + const subFunctionMetric = metrics.find((metric) => metric.id === fieldId); + if (!subFunctionMetric) { + return null; + } + const pipelineAgg = getPipelineAgg(subFunctionMetric); + if (!pipelineAgg) { + return null; + } + // lens supports cumulative sum for count and sum as quick function + // and everything else as formula + if (pipelineAgg !== 'count' && pipelineAgg !== 'sum') { + const metaValue = Number(meta?.replace(']', '')); + const formula = getParentPipelineSeriesFormula( + metrics, + subFunctionMetric, + pipelineAgg, + aggregation, + metaValue + ); + if (!formula) return null; + metricsArray = getFormulaSeries(formula); + } else { + const series = computeParentSeries( + aggregation, + metrics[metricIdx], + subFunctionMetric, + pipelineAgg + ); + if (!series) return null; + metricsArray = series; + } + break; + } + case 'avg_bucket': + case 'max_bucket': + case 'min_bucket': + case 'sum_bucket': { + const formula = getSiblingPipelineSeriesFormula(aggregation, metrics[metricIdx], metrics); + if (!formula) { + return null; + } + metricsArray = getFormulaSeries(formula) as VisualizeEditorLayersContext['metrics']; + break; + } + case 'filter_ratio': { + const formula = getFilterRatioFormula(metrics[metricIdx]); + if (!formula) { + return null; + } + metricsArray = getFormulaSeries(formula); + break; + } + default: { + const timeScale = getTimeScale(metrics[metricIdx]); + metricsArray = [ + { + agg: aggregationMap.name, + isFullReference: aggregationMap.isFullReference, + fieldName: aggregation !== 'count' && fieldName ? fieldName : 'document', + params: { + ...(timeScale && { timeScale }), + }, + }, + ]; + } + } + return metricsArray; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts new file mode 100644 index 0000000000000..2fad7f1d3d70f --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts @@ -0,0 +1,306 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { DataView } from '../../../../data/common'; +import type { Panel, Series } from '../../common/types'; +import { triggerTSVBtoLensConfiguration } from './'; + +const dataViewsMap: Record = { + test1: { id: 'test1', title: 'test1', timeFieldName: 'timeField1' } as DataView, + test2: { + id: 'test2', + title: 'test2', + timeFieldName: 'timeField2', + } as DataView, + test3: { id: 'test3', title: 'test3', timeFieldName: 'timeField3' } as DataView, +}; + +const getDataview = (id: string): DataView | undefined => dataViewsMap[id]; +jest.mock('../services', () => { + return { + getDataStart: jest.fn(() => { + return { + dataViews: { + getDefault: jest.fn(() => { + return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; + }), + get: getDataview, + }, + }; + }), + }; +}); + +const model = { + axis_position: 'left', + type: 'timeseries', + index_pattern: { id: 'test2' }, + use_kibana_indexes: true, + series: [ + { + color: '#000000', + chart_type: 'line', + fill: '0', + id: '85147356-c185-4636-9182-d55f3ab2b6fa', + palette: { + name: 'default', + type: 'palette', + }, + split_mode: 'everything', + metrics: [ + { + id: '3fa8b32f-5c38-4813-9361-1f2817ae5b18', + type: 'count', + }, + ], + override_index_pattern: 0, + }, + ], +} as Panel; + +describe('triggerTSVBtoLensConfiguration', () => { + test('should return null for a non timeseries chart', async () => { + const metricModel = { + ...model, + type: 'metric', + } as Panel; + const triggerOptions = await triggerTSVBtoLensConfiguration(metricModel); + expect(triggerOptions).toBeNull(); + }); + + test('should return null for a string index pattern', async () => { + const stringIndexPatternModel = { + ...model, + use_kibana_indexes: false, + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(stringIndexPatternModel); + expect(triggerOptions).toBeNull(); + }); + + test('should return null for a non supported aggregation', async () => { + const nonSupportedAggModel = { + ...model, + series: [ + { + ...model.series[0], + metrics: [ + { + type: 'percentile_rank', + }, + ] as Series['metrics'], + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(nonSupportedAggModel); + expect(triggerOptions).toBeNull(); + }); + + test('should return options for a supported aggregation', async () => { + const triggerOptions = await triggerTSVBtoLensConfiguration(model); + expect(triggerOptions).toStrictEqual({ + configuration: { + extents: { yLeftExtent: { mode: 'full' }, yRightExtent: { mode: 'full' } }, + fill: '0', + gridLinesVisibility: { x: false, yLeft: false, yRight: false }, + legend: { + isVisible: false, + maxLines: 1, + position: 'right', + shouldTruncate: false, + showSingleSeries: false, + }, + }, + type: 'lnsXY', + layers: { + '0': { + axisPosition: 'left', + chartType: 'line', + indexPatternId: 'test2', + metrics: [ + { + agg: 'count', + color: '#000000', + fieldName: 'document', + isFullReference: false, + params: {}, + }, + ], + palette: { + name: 'default', + type: 'palette', + }, + splitWithDateHistogram: false, + timeFieldName: 'timeField2', + timeInterval: 'auto', + }, + }, + }); + }); + + test('should return area for timeseries line chart with fill > 0', async () => { + const modelWithFill = { + ...model, + series: [ + { + ...model.series[0], + fill: '0.3', + stacked: 'none', + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithFill); + expect(triggerOptions?.layers[0].chartType).toBe('area'); + }); + + test('should return timeShift in the params if it is provided', async () => { + const modelWithFill = { + ...model, + series: [ + { + ...model.series[0], + offset_time: '1h', + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithFill); + expect(triggerOptions?.layers[0]?.metrics?.[0]?.params?.shift).toBe('1h'); + }); + + test('should return filter in the params if it is provided', async () => { + const modelWithFill = { + ...model, + series: [ + { + ...model.series[0], + filter: { + language: 'kuery', + query: 'test', + }, + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithFill); + expect(triggerOptions?.layers[0]?.metrics?.[0]?.params?.kql).toBe('test'); + }); + + test('should return splitFilters information if the chart is broken down by filters', async () => { + const modelWithSplitFilters = { + ...model, + series: [ + { + ...model.series[0], + split_mode: 'filters', + split_filters: [ + { + color: 'rgba(188,0,85,1)', + filter: { + language: 'kuery', + query: '', + }, + id: '89afac60-7d2b-11ec-917c-c18cd38d60b5', + }, + ], + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithSplitFilters); + expect(triggerOptions?.layers[0]?.splitFilters).toStrictEqual([ + { + color: 'rgba(188,0,85,1)', + filter: { + language: 'kuery', + query: '', + }, + id: '89afac60-7d2b-11ec-917c-c18cd38d60b5', + }, + ]); + }); + + test('should return termsParams information if the chart is broken down by terms', async () => { + const modelWithTerms = { + ...model, + series: [ + { + ...model.series[0], + split_mode: 'terms', + terms_size: 6, + terms_direction: 'desc', + terms_order_by: '_key', + }, + ] as unknown as Series[], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithTerms); + expect(triggerOptions?.layers[0]?.termsParams).toStrictEqual({ + size: 6, + otherBucket: false, + orderDirection: 'desc', + orderBy: { type: 'alphabetical' }, + parentFormat: { + id: 'terms', + }, + }); + }); + + test('should return custom time interval if it is given', async () => { + const modelWithTerms = { + ...model, + interval: '1h', + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithTerms); + expect(triggerOptions?.layers[0]?.timeInterval).toBe('1h'); + }); + + test('should return the correct chart configuration', async () => { + const modelWithConfig = { + ...model, + show_legend: 1, + legend_position: 'bottom', + truncate_legend: 0, + show_grid: 1, + series: [{ ...model.series[0], fill: '0.3', separate_axis: 1, axis_position: 'right' }], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithConfig); + expect(triggerOptions).toStrictEqual({ + configuration: { + extents: { yLeftExtent: { mode: 'full' }, yRightExtent: { mode: 'full' } }, + fill: '0.3', + gridLinesVisibility: { x: true, yLeft: true, yRight: true }, + legend: { + isVisible: true, + maxLines: 1, + position: 'bottom', + shouldTruncate: false, + showSingleSeries: true, + }, + }, + type: 'lnsXY', + layers: { + '0': { + axisPosition: 'right', + chartType: 'area_stacked', + indexPatternId: 'test2', + metrics: [ + { + agg: 'count', + color: '#000000', + fieldName: 'document', + isFullReference: false, + params: {}, + }, + ], + palette: { + name: 'default', + type: 'palette', + }, + splitWithDateHistogram: false, + timeFieldName: 'timeField2', + timeInterval: 'auto', + }, + }, + }); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/index.ts b/src/plugins/vis_types/timeseries/public/trigger_action/index.ts new file mode 100644 index 0000000000000..d3329bee803a1 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/index.ts @@ -0,0 +1,165 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { PaletteOutput } from '../../../../charts/public'; +import type { + NavigateToLensContext, + VisualizeEditorLayersContext, +} from '../../../../visualizations/public'; +import type { Panel } from '../../common/types'; +import { PANEL_TYPES } from '../../common/enums'; +import { getDataSourceInfo } from './get_datasource_info'; +import { getFieldType } from './get_field_type'; +import { getSeries } from './get_series'; +import { getYExtents } from './get_extents'; + +const SUPPORTED_FORMATTERS = ['bytes', 'percent', 'number']; + +/* + * This function is used to convert the TSVB model to compatible Lens model. + * Returns the Lens model, only if it is supported. If not, it returns null. + * In case of null, the menu item is disabled and the user can't navigate to Lens. + */ +export const triggerTSVBtoLensConfiguration = async ( + model: Panel +): Promise => { + // Disables the option for not timeseries charts, for the string mode and for series with annotations + if ( + model.type !== PANEL_TYPES.TIMESERIES || + !model.use_kibana_indexes || + (model.annotations && model.annotations.length > 0) + ) { + return null; + } + const layersConfiguration: { [key: string]: VisualizeEditorLayersContext } = {}; + + // handle multiple layers/series + for (let layerIdx = 0; layerIdx < model.series.length; layerIdx++) { + const layer = model.series[layerIdx]; + if (layer.hidden) continue; + + const { indexPatternId, timeField } = await getDataSourceInfo( + model.index_pattern, + model.time_field, + Boolean(layer.override_index_pattern), + layer.series_index_pattern + ); + + const timeShift = layer.offset_time; + // translate to Lens seriesType + const layerChartType = + layer.chart_type === 'line' && layer.fill !== '0' ? 'area' : layer.chart_type; + let chartType = layerChartType; + + if (layer.stacked !== 'none' && layer.stacked !== 'percent') { + chartType = layerChartType !== 'line' ? `${layerChartType}_stacked` : 'line'; + } + if (layer.stacked === 'percent') { + chartType = layerChartType !== 'line' ? `${layerChartType}_percentage_stacked` : 'line'; + } + + // handle multiple metrics + let metricsArray = getSeries(layer.metrics); + if (!metricsArray) { + return null; + } + let filter: { + kql?: string | { [key: string]: any } | undefined; + lucene?: string | { [key: string]: any } | undefined; + }; + if (layer.filter) { + if (layer.filter.language === 'kuery') { + filter = { kql: layer.filter.query }; + } else if (layer.filter.language === 'lucene') { + filter = { lucene: layer.filter.query }; + } + } + + metricsArray = metricsArray.map((metric) => { + return { + ...metric, + color: metric.color ?? layer.color, + params: { + ...metric.params, + ...(timeShift && { shift: timeShift }), + ...(filter && filter), + }, + }; + }); + const splitFilters: VisualizeEditorLayersContext['splitFilters'] = []; + if (layer.split_mode === 'filter' && layer.filter) { + splitFilters.push({ filter: layer.filter }); + } + if (layer.split_filters) { + splitFilters.push(...layer.split_filters); + } + + const palette = layer.palette as PaletteOutput; + + // in case of terms in a date field, we want to apply the date_histogram + let splitWithDateHistogram = false; + if (layer.terms_field && layer.split_mode === 'terms') { + const fieldType = await getFieldType(indexPatternId, layer.terms_field); + if (fieldType === 'date') { + splitWithDateHistogram = true; + } + } + + const layerConfiguration: VisualizeEditorLayersContext = { + indexPatternId, + timeFieldName: timeField, + chartType, + axisPosition: layer.separate_axis ? layer.axis_position : model.axis_position, + ...(layer.terms_field && { splitField: layer.terms_field }), + splitWithDateHistogram, + ...(layer.split_mode !== 'everything' && { splitMode: layer.split_mode }), + ...(splitFilters.length > 0 && { splitFilters }), + // for non supported palettes, we will use the default palette + palette: + !palette || palette.name === 'gradient' || palette.name === 'rainbow' + ? { name: 'default', type: 'palette' } + : palette, + ...(layer.split_mode === 'terms' && { + termsParams: { + size: layer.terms_size ?? 10, + otherBucket: false, + orderDirection: layer.terms_direction ?? 'desc', + orderBy: layer.terms_order_by === '_key' ? { type: 'alphabetical' } : { type: 'column' }, + parentFormat: { id: 'terms' }, + }, + }), + metrics: [...metricsArray], + timeInterval: model.interval && !model.interval?.includes('=') ? model.interval : 'auto', + ...(SUPPORTED_FORMATTERS.includes(layer.formatter) && { format: layer.formatter }), + ...(layer.label && { label: layer.label }), + }; + layersConfiguration[layerIdx] = layerConfiguration; + } + + const extents = getYExtents(model); + + return { + layers: layersConfiguration, + type: 'lnsXY', + configuration: { + fill: model.series[0].fill ?? 0.3, + legend: { + isVisible: Boolean(model.show_legend), + showSingleSeries: Boolean(model.show_legend), + position: model.legend_position ?? 'right', + shouldTruncate: Boolean(model.truncate_legend), + maxLines: model.max_lines_legend ?? 1, + }, + gridLinesVisibility: { + x: Boolean(model.show_grid), + yLeft: Boolean(model.show_grid), + yRight: Boolean(model.show_grid), + }, + extents, + }, + }; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.test.ts new file mode 100644 index 0000000000000..8b1a5f5e68dec --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.test.ts @@ -0,0 +1,193 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { METRIC_TYPES } from 'src/plugins/data/public'; +import type { Metric, MetricType } from '../../common/types'; +import { getPercentilesSeries, getParentPipelineSeries } from './metrics_helpers'; + +describe('getPercentilesSeries', () => { + test('should return correct config for multiple percentiles', () => { + const percentiles = [ + { + color: '#68BC00', + id: 'aef159f0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + shade: 0.2, + value: 50, + }, + { + color: 'rgba(0,63,188,1)', + id: 'b0e0a6d0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + percentile: '', + shade: 0.2, + value: '70', + }, + { + color: 'rgba(188,38,0,1)', + id: 'b2e04760-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + percentile: '', + shade: 0.2, + value: '80', + }, + { + color: 'rgba(188,0,3,1)', + id: 'b503eab0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + percentile: '', + shade: 0.2, + value: '90', + }, + ] as Metric['percentiles']; + const config = getPercentilesSeries(percentiles, 'bytes'); + expect(config).toStrictEqual([ + { + agg: 'percentile', + color: '#68BC00', + fieldName: 'bytes', + isFullReference: false, + params: { percentile: 50 }, + }, + { + agg: 'percentile', + color: 'rgba(0,63,188,1)', + fieldName: 'bytes', + isFullReference: false, + params: { percentile: '70' }, + }, + { + agg: 'percentile', + color: 'rgba(188,38,0,1)', + fieldName: 'bytes', + isFullReference: false, + params: { percentile: '80' }, + }, + { + agg: 'percentile', + color: 'rgba(188,0,3,1)', + fieldName: 'bytes', + isFullReference: false, + params: { percentile: '90' }, + }, + ]); + }); +}); + +describe('getParentPipelineSeries', () => { + test('should return correct config for pipeline agg on percentiles', () => { + const metrics = [ + { + field: 'AvgTicketPrice', + id: '04558549-f19f-4a87-9923-27df8b81af3e', + percentiles: [ + { + color: '#68BC00', + id: 'aef159f0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + shade: 0.2, + value: 50, + }, + { + color: 'rgba(0,63,188,1)', + id: 'b0e0a6d0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + percentile: '', + shade: 0.2, + value: '70', + }, + ], + type: 'percentile', + }, + { + field: '04558549-f19f-4a87-9923-27df8b81af3e[70.0]', + id: '764f4110-7db9-11ec-9fdf-91a8881dd06b', + type: 'derivative', + unit: '', + }, + ] as Metric[]; + const config = getParentPipelineSeries(METRIC_TYPES.DERIVATIVE, 1, metrics); + expect(config).toStrictEqual([ + { + agg: 'differences', + fieldName: 'AvgTicketPrice', + isFullReference: true, + params: { + percentile: 70, + }, + pipelineAggType: 'percentile', + }, + ]); + }); + + test('should return null config for pipeline agg on non-supported sub-aggregation', () => { + const metrics = [ + { + field: 'AvgTicketPrice', + id: '04558549-f19f-4a87-9923-27df8b81af3e', + type: 'std_deviation', + }, + { + field: '04558549-f19f-4a87-9923-27df8b81af3e', + id: '764f4110-7db9-11ec-9fdf-91a8881dd06b', + type: 'derivative', + unit: '', + }, + ] as Metric[]; + const config = getParentPipelineSeries(METRIC_TYPES.DERIVATIVE, 1, metrics); + expect(config).toBeNull(); + }); + + test('should return null config for pipeline agg when sub-agregation is not given', () => { + const metrics = [ + { + field: 'AvgTicketPrice', + id: '04558549-f19f-4a87-9923-27df8b81af3e', + type: 'avg', + }, + { + field: '123456', + id: '764f4110-7db9-11ec-9fdf-91a8881dd06b', + type: 'derivative', + unit: '', + }, + ] as Metric[]; + const config = getParentPipelineSeries(METRIC_TYPES.DERIVATIVE, 1, metrics); + expect(config).toBeNull(); + }); + + test('should return formula config for pipeline agg when applied on nested aggregations', () => { + const metrics = [ + { + field: 'AvgTicketPrice', + id: '04558549-f19f-4a87-9923-27df8b81af3e', + type: 'avg', + }, + { + field: '04558549-f19f-4a87-9923-27df8b81af3e', + id: '6e4932d0-7dbb-11ec-8d79-e163106679dc', + model_type: 'simple', + type: 'cumulative_sum', + }, + { + field: '6e4932d0-7dbb-11ec-8d79-e163106679dc', + id: 'a51de940-7dbb-11ec-8d79-e163106679dc', + type: 'moving_average', + window: 5, + }, + ] as Metric[]; + const config = getParentPipelineSeries('moving_average' as MetricType, 2, metrics); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { formula: 'moving_average(cumulative_sum(average(AvgTicketPrice)))' }, + }, + ]); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts new file mode 100644 index 0000000000000..07140c9fdd9d1 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts @@ -0,0 +1,306 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { Query } from '../../../../data/common'; +import type { Metric, MetricType } from '../../common/types'; +import { SUPPORTED_METRICS } from './supported_metrics'; + +export const getPercentilesSeries = (percentiles: Metric['percentiles'], fieldName?: string) => { + return percentiles?.map((percentile) => { + return { + agg: 'percentile', + isFullReference: false, + color: percentile.color, + fieldName: fieldName ?? 'document', + params: { percentile: percentile.value }, + }; + }); +}; + +export const getFormulaSeries = (script: string) => { + return [ + { + agg: 'formula', + isFullReference: true, + fieldName: 'document', + params: { formula: script }, + }, + ]; +}; + +export const getPipelineAgg = (subFunctionMetric: Metric) => { + const pipelineAggMap = SUPPORTED_METRICS[subFunctionMetric.type]; + if (!pipelineAggMap) { + return null; + } + return pipelineAggMap.name; +}; + +export const getTimeScale = (metric: Metric) => { + const supportedTimeScales = ['1s', '1m', '1h', '1d']; + let timeScale; + if (metric.unit && supportedTimeScales.includes(metric.unit)) { + timeScale = metric.unit.replace('1', ''); + } + return timeScale; +}; + +export const computeParentSeries = ( + aggregation: MetricType, + currentMetric: Metric, + subFunctionMetric: Metric, + pipelineAgg: string, + meta?: number +) => { + const aggregationMap = SUPPORTED_METRICS[aggregation]; + if (subFunctionMetric.type === 'filter_ratio') { + const script = getFilterRatioFormula(subFunctionMetric); + if (!script) { + return null; + } + const formula = `${aggregationMap.name}(${script})`; + return getFormulaSeries(formula); + } + const timeScale = getTimeScale(currentMetric); + return [ + { + agg: aggregationMap.name, + isFullReference: aggregationMap.isFullReference, + pipelineAggType: pipelineAgg, + fieldName: + subFunctionMetric?.field && pipelineAgg !== 'count' ? subFunctionMetric?.field : 'document', + params: { + ...(currentMetric.window && { window: currentMetric.window }), + ...(timeScale && { timeScale }), + ...(pipelineAgg === 'percentile' && meta && { percentile: meta }), + }, + }, + ]; +}; + +export const getParentPipelineSeries = ( + aggregation: MetricType, + currentMetricIdx: number, + metrics: Metric[] +) => { + const currentMetric = metrics[currentMetricIdx]; + // percentile value is derived from the field Id. It has the format xxx-xxx-xxx-xxx[percentile] + const [fieldId, meta] = currentMetric?.field?.split('[') ?? []; + const subFunctionMetric = metrics.find((metric) => metric.id === fieldId); + if (!subFunctionMetric) { + return null; + } + const pipelineAgg = getPipelineAgg(subFunctionMetric); + if (!pipelineAgg) { + return null; + } + const metaValue = Number(meta?.replace(']', '')); + const subMetricField = subFunctionMetric.field; + const [nestedFieldId, _] = subMetricField?.split('[') ?? []; + // support nested aggs with formula + const additionalSubFunction = metrics.find((metric) => metric.id === nestedFieldId); + if (additionalSubFunction) { + const formula = getParentPipelineSeriesFormula( + metrics, + subFunctionMetric, + pipelineAgg, + aggregation, + metaValue + ); + if (!formula) { + return null; + } + return getFormulaSeries(formula); + } else { + return computeParentSeries( + aggregation, + currentMetric, + subFunctionMetric, + pipelineAgg, + metaValue + ); + } +}; + +export const getParentPipelineSeriesFormula = ( + metrics: Metric[], + subFunctionMetric: Metric, + pipelineAgg: string, + aggregation: MetricType, + percentileValue?: number +) => { + let formula = ''; + const aggregationMap = SUPPORTED_METRICS[aggregation]; + const subMetricField = subFunctionMetric.field; + const [nestedFieldId, nestedMeta] = subMetricField?.split('[') ?? []; + // support nested aggs + const additionalSubFunction = metrics.find((metric) => metric.id === nestedFieldId); + if (additionalSubFunction) { + // support nested aggs with formula + const additionalPipelineAggMap = SUPPORTED_METRICS[additionalSubFunction.type]; + if (!additionalPipelineAggMap) { + return null; + } + const nestedMetaValue = Number(nestedMeta?.replace(']', '')); + const aggMap = SUPPORTED_METRICS[aggregation]; + let additionalFunctionArgs; + if (additionalPipelineAggMap.name === 'percentile' && nestedMetaValue) { + additionalFunctionArgs = `, percentile=${nestedMetaValue}`; + } + formula = `${aggMap.name}(${pipelineAgg}(${additionalPipelineAggMap.name}(${ + additionalSubFunction.field ?? '' + }${additionalFunctionArgs ?? ''})))`; + } else { + let additionalFunctionArgs; + if (pipelineAgg === 'percentile' && percentileValue) { + additionalFunctionArgs = `, percentile=${percentileValue}`; + } + if (pipelineAgg === 'filter_ratio') { + const script = getFilterRatioFormula(subFunctionMetric); + if (!script) { + return null; + } + formula = `${aggregationMap.name}(${script}${additionalFunctionArgs ?? ''})`; + } else if (pipelineAgg === 'counter_rate') { + formula = `${aggregationMap.name}(${pipelineAgg}(max(${subFunctionMetric.field}${ + additionalFunctionArgs ? `${additionalFunctionArgs}` : '' + })))`; + } else { + formula = `${aggregationMap.name}(${pipelineAgg}(${subFunctionMetric.field}${ + additionalFunctionArgs ? `${additionalFunctionArgs}` : '' + }))`; + } + } + return formula; +}; + +export const getSiblingPipelineSeriesFormula = ( + aggregation: MetricType, + currentMetric: Metric, + metrics: Metric[] +) => { + const subFunctionMetric = metrics.find((metric) => metric.id === currentMetric.field); + if (!subFunctionMetric) { + return null; + } + const pipelineAggMap = SUPPORTED_METRICS[subFunctionMetric.type]; + if (!pipelineAggMap) { + return null; + } + const aggregationMap = SUPPORTED_METRICS[aggregation]; + const subMetricField = subFunctionMetric.field; + // support nested aggs with formula + const additionalSubFunction = metrics.find((metric) => metric.id === subMetricField); + let formula = `${aggregationMap.name}(`; + if (additionalSubFunction) { + const additionalPipelineAggMap = SUPPORTED_METRICS[additionalSubFunction.type]; + if (!additionalPipelineAggMap) { + return null; + } + formula += `${pipelineAggMap.name}(${additionalPipelineAggMap.name}(${ + additionalSubFunction.field ?? '' + })))`; + } else { + formula += `${pipelineAggMap.name}(${subFunctionMetric.field ?? ''}))`; + } + return formula; +}; + +const escapeQuotes = (str: string) => { + return str?.replace(/'/g, "\\'"); +}; + +const constructFilterRationFormula = (operation: string, metric?: Query) => { + return `${operation}${metric?.language === 'lucene' ? 'lucene' : 'kql'}='${ + metric?.query && typeof metric?.query === 'string' + ? escapeQuotes(metric?.query) + : metric?.query ?? '*' + }')`; +}; + +export const getFilterRatioFormula = (currentMetric: Metric) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { numerator, denominator, metric_agg, field } = currentMetric; + let aggregation = SUPPORTED_METRICS.count; + if (metric_agg) { + aggregation = SUPPORTED_METRICS[metric_agg]; + if (!aggregation) { + return null; + } + } + const operation = + metric_agg && metric_agg !== 'count' ? `${aggregation.name}('${field}',` : 'count('; + + if (aggregation.name === 'counter_rate') { + const numeratorFormula = constructFilterRationFormula( + `${aggregation.name}(max('${field}',`, + numerator + ); + const denominatorFormula = constructFilterRationFormula( + `${aggregation.name}(max('${field}',`, + denominator + ); + return `${numeratorFormula}) / ${denominatorFormula})`; + } else { + const numeratorFormula = constructFilterRationFormula(operation, numerator); + const denominatorFormula = constructFilterRationFormula(operation, denominator); + return `${numeratorFormula} / ${denominatorFormula}`; + } +}; + +export const getFormulaEquivalent = ( + currentMetric: Metric, + metrics: Metric[], + metaValue?: number +) => { + const aggregation = SUPPORTED_METRICS[currentMetric.type]?.name; + switch (currentMetric.type) { + case 'avg_bucket': + case 'max_bucket': + case 'min_bucket': + case 'sum_bucket': { + return getSiblingPipelineSeriesFormula(currentMetric.type, currentMetric, metrics); + } + case 'count': { + return `${aggregation}()`; + } + case 'percentile': { + return `${aggregation}(${currentMetric.field}${ + metaValue ? `, percentile=${metaValue}` : '' + })`; + } + case 'cumulative_sum': + case 'derivative': + case 'moving_average': { + const [fieldId, _] = currentMetric?.field?.split('[') ?? []; + const subFunctionMetric = metrics.find((metric) => metric.id === fieldId); + if (!subFunctionMetric) { + return null; + } + const pipelineAgg = getPipelineAgg(subFunctionMetric); + if (!pipelineAgg) { + return null; + } + return getParentPipelineSeriesFormula( + metrics, + subFunctionMetric, + pipelineAgg, + currentMetric.type, + metaValue + ); + } + case 'positive_rate': { + return `${aggregation}(max(${currentMetric.field}))`; + } + case 'filter_ratio': { + return getFilterRatioFormula(currentMetric); + } + default: { + return `${aggregation}(${currentMetric.field})`; + } + } +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts b/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts new file mode 100644 index 0000000000000..b3d58d81105ab --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts @@ -0,0 +1,87 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +interface AggOptions { + name: string; + isFullReference: boolean; +} + +// list of supported TSVB aggregation types in Lens +// some of them are supported on the quick functions tab and some of them +// are supported with formulas + +export const SUPPORTED_METRICS: { [key: string]: AggOptions } = { + avg: { + name: 'average', + isFullReference: false, + }, + cardinality: { + name: 'unique_count', + isFullReference: false, + }, + count: { + name: 'count', + isFullReference: false, + }, + positive_rate: { + name: 'counter_rate', + isFullReference: true, + }, + moving_average: { + name: 'moving_average', + isFullReference: true, + }, + derivative: { + name: 'differences', + isFullReference: true, + }, + cumulative_sum: { + name: 'cumulative_sum', + isFullReference: true, + }, + avg_bucket: { + name: 'overall_average', + isFullReference: true, + }, + max_bucket: { + name: 'overall_max', + isFullReference: true, + }, + min_bucket: { + name: 'overall_min', + isFullReference: true, + }, + sum_bucket: { + name: 'overall_sum', + isFullReference: true, + }, + max: { + name: 'max', + isFullReference: false, + }, + min: { + name: 'min', + isFullReference: false, + }, + percentile: { + name: 'percentile', + isFullReference: false, + }, + sum: { + name: 'sum', + isFullReference: false, + }, + filter_ratio: { + name: 'filter_ratio', + isFullReference: false, + }, + math: { + name: 'formula', + isFullReference: true, + }, +}; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index bcb3e0f4c7216..de2af1d5cdcfb 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -24,7 +24,15 @@ export { getVisSchemas } from './vis_schemas'; /** @public types */ export type { VisualizationsSetup, VisualizationsStart }; export { VisGroups } from './vis_types/vis_groups_enum'; -export type { BaseVisType, VisTypeAlias, VisTypeDefinition, Schema, ISchemas } from './vis_types'; +export type { + BaseVisType, + VisTypeAlias, + VisTypeDefinition, + Schema, + ISchemas, + NavigateToLensContext, + VisualizeEditorLayersContext, +} from './vis_types'; export type { Vis, SerializedVis, SerializedVisData, VisData } from './vis'; export type VisualizeEmbeddableFactoryContract = PublicContract; export type VisualizeEmbeddableContract = PublicContract; @@ -57,3 +65,5 @@ export type { export { urlFor, getFullPath } from './utils/saved_visualize_utils'; export type { IEditorController, EditorRenderProps } from './visualize_app/types'; + +export { VISUALIZE_EDITOR_TRIGGER, ACTION_CONVERT_TO_LENS } from './triggers'; diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index e51258cf8a1e7..0fc142aeead63 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -50,6 +50,7 @@ const createInstance = async () => { inspector: inspectorPluginMock.createSetupContract(), usageCollection: usageCollectionPluginMock.createSetupContract(), urlForwarding: urlForwardingPluginMock.createSetupContract(), + uiActions: uiActionsPluginMock.createSetupContract(), }); const doStart = () => plugin.start(coreMock.createStart(), { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index eae4f704b7c3c..c8c4d57543a02 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -58,6 +58,7 @@ import { VisualizeLocatorDefinition } from '../common/locator'; import { showNewVisModal } from './wizard'; import { createVisEditorsRegistry, VisEditorsRegistry } from './vis_editors_registry'; import { FeatureCatalogueCategory } from '../../home/public'; +import { visualizeEditorTrigger } from './triggers'; import type { VisualizeServices } from './visualize_app/types'; import type { @@ -69,7 +70,7 @@ import type { SavedObjectsClientContract, } from '../../../core/public'; import type { UsageCollectionSetup } from '../../usage_collection/public'; -import type { UiActionsStart } from '../../ui_actions/public'; +import type { UiActionsStart, UiActionsSetup } from '../../ui_actions/public'; import type { SavedObjectsStart } from '../../saved_objects/public'; import type { TypesSetup, TypesStart } from './vis_types'; import type { @@ -105,6 +106,7 @@ export interface VisualizationsSetupDeps { embeddable: EmbeddableSetup; expressions: ExpressionsSetup; inspector: InspectorSetup; + uiActions: UiActionsSetup; usageCollection: UsageCollectionSetup; urlForwarding: UrlForwardingSetup; home?: HomePublicPluginSetup; @@ -165,6 +167,7 @@ export class VisualizationsPlugin home, urlForwarding, share, + uiActions, }: VisualizationsSetupDeps ): VisualizationsSetup { const { @@ -325,6 +328,7 @@ export class VisualizationsPlugin expressions.registerFunction(rangeExpressionFunction); expressions.registerFunction(visDimensionExpressionFunction); expressions.registerFunction(xyDimensionExpressionFunction); + uiActions.registerTrigger(visualizeEditorTrigger); const embeddableFactory = new VisualizeEmbeddableFactory({ start }); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); diff --git a/src/plugins/visualizations/public/triggers/index.ts b/src/plugins/visualizations/public/triggers/index.ts new file mode 100644 index 0000000000000..eedeac1695717 --- /dev/null +++ b/src/plugins/visualizations/public/triggers/index.ts @@ -0,0 +1,18 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Trigger } from '../../../ui_actions/public'; + +export const VISUALIZE_EDITOR_TRIGGER = 'VISUALIZE_EDITOR_TRIGGER'; +export const visualizeEditorTrigger: Trigger = { + id: VISUALIZE_EDITOR_TRIGGER, + title: 'Convert legacy visualizations to Lens', + description: 'Triggered when user navigates from a legacy visualization to Lens.', +}; + +export const ACTION_CONVERT_TO_LENS = 'ACTION_CONVERT_TO_LENS'; diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index 675a1783274aa..80295e5af2e40 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -27,6 +27,7 @@ export class BaseVisType { public readonly description; public readonly note; public readonly getSupportedTriggers; + public readonly navigateToLens; public readonly icon; public readonly image; public readonly stage; @@ -55,6 +56,7 @@ export class BaseVisType { this.description = opts.description ?? ''; this.note = opts.note ?? ''; this.getSupportedTriggers = opts.getSupportedTriggers; + this.navigateToLens = opts.navigateToLens; this.title = opts.title; this.icon = opts.icon; this.image = opts.image; diff --git a/src/plugins/visualizations/public/vis_types/index.ts b/src/plugins/visualizations/public/vis_types/index.ts index 365f0d51bf4f3..e297d9192ed21 100644 --- a/src/plugins/visualizations/public/vis_types/index.ts +++ b/src/plugins/visualizations/public/vis_types/index.ts @@ -10,4 +10,10 @@ export * from './types_service'; export { Schemas } from './schemas'; export { VisGroups } from './vis_groups_enum'; export { BaseVisType } from './base_vis_type'; -export type { VisTypeDefinition, ISchemas, Schema } from './types'; +export type { + VisTypeDefinition, + ISchemas, + Schema, + NavigateToLensContext, + VisualizeEditorLayersContext, +} from './types'; diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts index 724f9d6ccc662..b89af7bd2cdbf 100644 --- a/src/plugins/visualizations/public/vis_types/types.ts +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -9,7 +9,14 @@ import type { IconType } from '@elastic/eui'; import type { ReactNode } from 'react'; import type { Adapters } from 'src/plugins/inspector'; -import type { IndexPattern, AggGroupNames, AggParam, AggGroupName } from '../../../data/public'; +import type { + IndexPattern, + AggGroupNames, + AggParam, + AggGroupName, + Query, +} from '../../../data/public'; +import { PaletteOutput } from '../../../charts/public'; import type { Vis, VisEditorOptionsProps, VisParams, VisToExpressionAst } from '../types'; import { VisGroups } from './vis_groups_enum'; @@ -67,6 +74,73 @@ interface CustomEditorConfig { editor: string; } +interface SplitByFilters { + color?: string; + filter?: Query; + id?: string; + label?: string; +} + +interface VisualizeEditorMetricContext { + agg: string; + fieldName: string; + pipelineAggType?: string; + params?: Record; + isFullReference: boolean; + color?: string; + accessor?: string; +} + +export interface VisualizeEditorLayersContext { + indexPatternId: string; + splitWithDateHistogram?: boolean; + timeFieldName?: string; + chartType?: string; + axisPosition?: string; + termsParams?: Record; + splitField?: string; + splitMode?: string; + splitFilters?: SplitByFilters[]; + palette?: PaletteOutput; + metrics: VisualizeEditorMetricContext[]; + timeInterval?: string; + format?: string; + label?: string; + layerId?: string; +} + +interface AxisExtents { + mode: string; + lowerBound?: number; + upperBound?: number; +} + +export interface NavigateToLensContext { + layers: { + [key: string]: VisualizeEditorLayersContext; + }; + type: string; + configuration: { + fill: number | string; + legend: { + isVisible: boolean; + position: string; + shouldTruncate: boolean; + maxLines: number; + showSingleSeries: boolean; + }; + gridLinesVisibility: { + x: boolean; + yLeft: boolean; + yRight: boolean; + }; + extents: { + yLeftExtent: AxisExtents; + yRightExtent: AxisExtents; + }; + }; +} + /** * A visualization type definition representing a spec of one specific type of "classical" * visualizations (i.e. not Lens visualizations). @@ -92,6 +166,15 @@ export interface VisTypeDefinition { * If given, it will return the supported triggers for this vis. */ readonly getSupportedTriggers?: (params?: VisParams) => string[]; + /** + * If given, it will navigateToLens with the given viz params. + * Every visualization that wants to be edited also in Lens should have this function. + * It receives the current visualization params as a parameter and should return the correct config + * in order to be displayed in the Lens editor. + */ + readonly navigateToLens?: ( + params?: VisParams + ) => Promise | undefined; /** * Some visualizations are created without SearchSource and may change the used indexes during the visualization configuration. diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx index 0ef26a8b72f05..245441d26f3f0 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx @@ -10,6 +10,7 @@ import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; import { AppMountParameters, OverlayRef } from 'kibana/public'; import { i18n } from '@kbn/i18n'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { useKibana } from '../../../../kibana_react/public'; import { VisualizeServices, @@ -20,6 +21,9 @@ import { import { VISUALIZE_APP_NAME } from '../../../common/constants'; import { getTopNavConfig } from '../utils'; import type { IndexPattern } from '../../../../data/public'; +import type { NavigateToLensContext } from '../../../../visualizations/public'; + +const LOCAL_STORAGE_EDIT_IN_LENS_BADGE = 'EDIT_IN_LENS_BADGE_VISIBLE'; interface VisualizeTopNavProps { currentAppState: VisualizeAppState; @@ -59,6 +63,18 @@ const TopNav = ({ const { setHeaderActionMenu, visualizeCapabilities } = services; const { embeddableHandler, vis } = visInstance; const [inspectorSession, setInspectorSession] = useState(); + const [editInLensConfig, setEditInLensConfig] = useState(); + const [navigateToLens, setNavigateToLens] = useState(false); + // If the user has clicked the edit in lens button, we want to hide the badge. + // The information is stored in local storage to persist across reloads. + const [hideTryInLensBadge, setHideTryInLensBadge] = useLocalStorage( + LOCAL_STORAGE_EDIT_IN_LENS_BADGE, + false + ); + const hideLensBadge = useCallback(() => { + setHideTryInLensBadge(true); + }, [setHideTryInLensBadge]); + const openInspector = useCallback(() => { const session = embeddableHandler.openInspector(); setInspectorSession(session); @@ -80,6 +96,17 @@ const TopNav = ({ [doReload] ); + useEffect(() => { + const asyncGetTriggerContext = async () => { + if (vis.type.navigateToLens) { + const triggerConfig = await vis.type.navigateToLens(vis.params); + setEditInLensConfig(triggerConfig); + } + }; + asyncGetTriggerContext(); + }, [vis.params, vis.type]); + + const displayEditInLensItem = Boolean(vis.type.navigateToLens && editInLensConfig); const config = useMemo(() => { if (isEmbeddableRendered) { return getTopNavConfig( @@ -96,6 +123,11 @@ const TopNav = ({ visualizationIdFromUrl, stateTransfer: services.stateTransferService, embeddableId, + editInLensConfig, + displayEditInLensItem, + hideLensBadge, + setNavigateToLens, + showBadge: !hideTryInLensBadge && displayEditInLensItem, }, services ); @@ -107,13 +139,17 @@ const TopNav = ({ hasUnappliedChanges, openInspector, originatingApp, + setOriginatingApp, originatingPath, visInstance, - setOriginatingApp, stateContainer, visualizationIdFromUrl, services, embeddableId, + editInLensConfig, + displayEditInLensItem, + hideLensBadge, + hideTryInLensBadge, ]); const [indexPatterns, setIndexPatterns] = useState( vis.data.indexPattern ? [vis.data.indexPattern] : [] @@ -140,10 +176,12 @@ const TopNav = ({ onAppLeave((actions) => { // Confirm when the user has made any changes to an existing visualizations // or when the user has configured something without saving + // the warning won't appear if you navigate from the Viz editor to Lens if ( originatingApp && (hasUnappliedChanges || hasUnsavedChanges) && - !services.stateTransferService.isTransferInProgress + !services.stateTransferService.isTransferInProgress && + !navigateToLens ) { return actions.confirm( i18n.translate('visualizations.confirmModal.confirmTextDescription', { @@ -167,6 +205,7 @@ const TopNav = ({ hasUnappliedChanges, visualizeCapabilities.save, services.stateTransferService.isTransferInProgress, + navigateToLens, ]); useEffect(() => { diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx index 81f9c83dec2b1..7ddece73d54b7 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx +++ b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx @@ -250,4 +250,147 @@ describe('getTopNavConfig', () => { ] `); }); + + test('returns correct for visualization that allows editing in Lens editor', () => { + const vis = { + savedVis: { + id: 'test', + sharingSavedObjectProps: { + outcome: 'conflict', + aliasTargetId: 'alias_id', + }, + }, + vis: { + type: { + title: 'TSVB', + }, + }, + } as VisualizeEditorVisInstance; + const topNavLinks = getTopNavConfig( + { + hasUnsavedChanges: false, + setHasUnsavedChanges: jest.fn(), + hasUnappliedChanges: false, + onOpenInspector: jest.fn(), + originatingApp: 'dashboards', + setOriginatingApp: jest.fn(), + visInstance: vis, + stateContainer, + visualizationIdFromUrl: undefined, + stateTransfer: createEmbeddableStateTransferMock(), + editInLensConfig: { + layers: { + '0': { + indexPatternId: 'test-id', + timeFieldName: 'timefield-1', + chartType: 'area', + axisPosition: 'left', + palette: { + name: 'default', + type: 'palette', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + }, + configuration: { + fill: 0.5, + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: 1, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + }, + displayEditInLensItem: true, + hideLensBadge: false, + } as unknown as TopNavConfigParams, + services as unknown as VisualizeServices + ); + + expect(topNavLinks).toMatchInlineSnapshot(` + Array [ + Object { + "className": "visNavItem__goToLens", + "description": "Go to Lens with your current configuration", + "disableButton": false, + "emphasize": false, + "id": "goToLens", + "label": "Edit visualization in Lens", + "run": [Function], + "testId": "visualizeEditInLensButton", + }, + Object { + "description": "Open Inspector for visualization", + "disableButton": [Function], + "id": "inspector", + "label": "inspect", + "run": undefined, + "testId": "openInspectorButton", + "tooltip": [Function], + }, + Object { + "description": "Share Visualization", + "disableButton": false, + "id": "share", + "label": "share", + "run": [Function], + "testId": "shareTopNavButton", + }, + Object { + "description": "Return to the last app without saving changes", + "emphasize": false, + "id": "cancel", + "label": "Cancel", + "run": [Function], + "testId": "visualizeCancelAndReturnButton", + "tooltip": [Function], + }, + Object { + "description": "Save Visualization", + "disableButton": false, + "emphasize": false, + "iconType": undefined, + "id": "save", + "label": "Save as", + "run": [Function], + "testId": "visualizeSaveButton", + "tooltip": [Function], + }, + Object { + "description": "Finish editing visualization and return to the last app", + "disableButton": false, + "emphasize": true, + "iconType": "checkInCircleFilled", + "id": "saveAndReturn", + "label": "Save and return", + "run": [Function], + "testId": "visualizesaveAndReturnButton", + "tooltip": [Function], + }, + ] + `); + }); }); diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx index fcf446021e9f9..362749cb206df 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx +++ b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx @@ -10,6 +10,7 @@ import React from 'react'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; +import { EuiBetaBadgeProps } from '@elastic/eui'; import { parse } from 'query-string'; import { Capabilities } from 'src/core/public'; @@ -19,6 +20,7 @@ import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput, getFullPath, + NavigateToLensContext, } from '../../../../visualizations/public'; import { showSaveModal, @@ -41,6 +43,11 @@ import { VISUALIZE_APP_NAME, VisualizeConstants } from '../../../common/constant import { getEditBreadcrumbs } from './breadcrumbs'; import { EmbeddableStateTransfer } from '../../../../embeddable/public'; import { VISUALIZE_APP_LOCATOR, VisualizeLocatorParams } from '../../../common/locator'; +import { getUiActions } from '../../services'; +import { VISUALIZE_EDITOR_TRIGGER } from '../../triggers'; +import { getVizEditorOriginatingAppUrl } from './utils'; + +import './visualize_navigation.scss'; interface VisualizeCapabilities { createShortUrl: boolean; @@ -63,6 +70,11 @@ export interface TopNavConfigParams { visualizationIdFromUrl?: string; stateTransfer: EmbeddableStateTransfer; embeddableId?: string; + editInLensConfig?: NavigateToLensContext | null; + displayEditInLensItem: boolean; + hideLensBadge: () => void; + setNavigateToLens: (flag: boolean) => void; + showBadge: boolean; } const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); @@ -89,6 +101,11 @@ export const getTopNavConfig = ( visualizationIdFromUrl, stateTransfer, embeddableId, + editInLensConfig, + displayEditInLensItem, + hideLensBadge, + setNavigateToLens, + showBadge, }: TopNavConfigParams, { data, @@ -272,6 +289,45 @@ export const getTopNavConfig = ( visualizeCapabilities.save || (!originatingApp && dashboardCapabilities.showWriteControls); const topNavMenu: TopNavMenuData[] = [ + ...(displayEditInLensItem + ? [ + { + id: 'goToLens', + label: i18n.translate('visualizations.topNavMenu.goToLensButtonLabel', { + defaultMessage: 'Edit visualization in Lens', + }), + emphasize: false, + description: i18n.translate('visualizations.topNavMenu.goToLensButtonAriaLabel', { + defaultMessage: 'Go to Lens with your current configuration', + }), + className: 'visNavItem__goToLens', + disableButton: !editInLensConfig, + testId: 'visualizeEditInLensButton', + ...(showBadge && { + badge: { + label: i18n.translate('visualizations.tonNavMenu.tryItBadgeText', { + defaultMessage: 'Try it', + }), + color: 'accent' as EuiBetaBadgeProps['color'], + }, + }), + run: async () => { + const updatedWithMeta = { + ...editInLensConfig, + savedObjectId: visInstance.vis.id, + embeddableId, + vizEditorOriginatingAppUrl: getVizEditorOriginatingAppUrl(history), + originatingApp, + }; + if (editInLensConfig) { + hideLensBadge(); + setNavigateToLens(true); + getUiActions().getTrigger(VISUALIZE_EDITOR_TRIGGER).exec(updatedWithMeta); + } + }, + }, + ] + : []), { id: 'inspector', label: i18n.translate('visualizations.topNavMenu.openInspectorButtonLabel', { diff --git a/src/plugins/visualizations/public/visualize_app/utils/utils.ts b/src/plugins/visualizations/public/visualize_app/utils/utils.ts index b3257f03354a6..6f71cb33e7321 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/utils.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/utils.ts @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; - +import type { History } from 'history'; import type { ChromeStart, DocLinksStart } from 'kibana/public'; import type { Filter } from '@kbn/es-query'; import { redirectWhenMissing } from '../../../../kibana_utils/public'; @@ -95,3 +95,7 @@ export const redirectToSavedObjectPage = ( theme: services.theme, })(error); }; + +export function getVizEditorOriginatingAppUrl(history: History) { + return `#/${history.location.pathname}${history.location.search}`; +} diff --git a/src/plugins/visualizations/public/visualize_app/utils/visualize_navigation.scss b/src/plugins/visualizations/public/visualize_app/utils/visualize_navigation.scss new file mode 100644 index 0000000000000..fb8acced47c83 --- /dev/null +++ b/src/plugins/visualizations/public/visualize_app/utils/visualize_navigation.scss @@ -0,0 +1,19 @@ +// Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future. +.visNavItem__goToLens { + @include euiBreakpoint('m', 'l', 'xl') { + margin-right: $euiSizeM; + position: relative; + } + &::after { + @include euiBreakpoint('m', 'l', 'xl') { + border-right: $euiBorderThin; + bottom: 0; + content: ''; + display: block; + pointer-events: none; + position: absolute; + right: -$euiSizeS; + top: 0; + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 00245384ec8b4..83b0a39be9229 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -38,3 +38,23 @@ fill: makeGraphicContrastColor($euiColorVis0, $euiColorDarkShade); } } + +// Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future. +.lnsNavItem__goBack { + @include euiBreakpoint('m', 'l', 'xl') { + margin-right: $euiSizeM; + position: relative; + } + &::after { + @include euiBreakpoint('m', 'l', 'xl') { + border-right: $euiBorderThin; + bottom: 0; + content: ''; + display: block; + pointer-events: none; + position: absolute; + right: -$euiSizeS; + top: 0; + } + } +} diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 8b868539d325f..b16afbfc56a4a 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -1328,6 +1328,82 @@ describe('Lens App', () => { expect(defaultLeave).not.toHaveBeenCalled(); }); + it('should confirm when leaving from a context initial doc with changes made in lens', async () => { + const initialProps = { + ...makeDefaultProps(), + contextOriginatingApp: 'TSVB', + initialContext: { + layers: [ + { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + timeFieldName: 'order_date', + chartType: 'area', + axisPosition: 'left', + palette: { + type: 'palette', + name: 'default', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + fill: 0.5, + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: 1, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + savedObjectId: '', + vizEditorOriginatingAppUrl: '#/tsvb-link', + isVisualizeAction: true, + }, + }; + + const mountedApp = await mountWith({ + props: initialProps as unknown as jest.Mocked, + preloadedState: { + persistedDoc: defaultDoc, + visualization: { + activeId: 'testVis', + state: {}, + }, + isSaveable: true, + }, + }); + const lastCall = + mountedApp.props.onAppLeave.mock.calls[ + mountedApp.props.onAppLeave.mock.calls.length - 1 + ][0]; + lastCall({ default: defaultLeave, confirm: confirmLeave }); + expect(defaultLeave).not.toHaveBeenCalled(); + expect(confirmLeave).toHaveBeenCalled(); + }); + it('should not confirm when changes are saved', async () => { const preloadedState = { persistedDoc: { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 44552c12d680d..3660c3d3db0cb 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -6,10 +6,9 @@ */ import './app.scss'; - import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiBreadcrumb } from '@elastic/eui'; +import { EuiBreadcrumb, EuiConfirmModal } from '@elastic/eui'; import { createKbnUrlStateStorage, withNotifyOnErrors, @@ -55,6 +54,7 @@ export function App({ setHeaderActionMenu, datasourceMap, visualizationMap, + contextOriginatingApp, topNavMenuEntryGenerators, initialContext, }: LensAppProps) { @@ -107,6 +107,10 @@ export function App({ const [indicateNoData, setIndicateNoData] = useState(false); const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); const [lastKnownDoc, setLastKnownDoc] = useState(undefined); + const [initialDocFromContext, setInitialDocFromContext] = useState( + undefined + ); + const [isGoBackToVizEditorModalVisible, setIsGoBackToVizEditorModalVisible] = useState(false); useEffect(() => { if (currentDoc) { @@ -169,7 +173,12 @@ export function App({ }), i18n.translate('xpack.lens.app.unsavedWorkTitle', { defaultMessage: 'Unsaved changes', - }) + }), + undefined, + i18n.translate('xpack.lens.app.unsavedWorkConfirmBtn', { + defaultMessage: 'Discard changes', + }), + 'danger' ); } else { return actions.default(); @@ -210,8 +219,14 @@ export function App({ // Sync Kibana breadcrumbs any time the saved document's title changes useEffect(() => { const isByValueMode = getIsByValueMode(); + const comesFromVizEditorDashboard = + initialContext && 'originatingApp' in initialContext && initialContext.originatingApp; const breadcrumbs: EuiBreadcrumb[] = []; - if (isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) { + if ( + (isLinkedToOriginatingApp || comesFromVizEditorDashboard) && + getOriginatingAppName() && + redirectToOrigin + ) { breadcrumbs.push({ onClick: () => { redirectToOrigin(); @@ -250,6 +265,7 @@ export function App({ chrome, isLinkedToOriginatingApp, persistedDoc, + initialContext, ]); const runSave = useCallback( @@ -298,6 +314,65 @@ export function App({ ] ); + // keeping the initial doc state created by the context + useEffect(() => { + if (lastKnownDoc && !initialDocFromContext) { + setInitialDocFromContext(lastKnownDoc); + } + }, [lastKnownDoc, initialDocFromContext]); + + // if users comes to Lens from the Viz editor, they should have the option to navigate back + const goBackToOriginatingApp = useCallback(() => { + if ( + initialContext && + 'vizEditorOriginatingAppUrl' in initialContext && + initialContext.vizEditorOriginatingAppUrl + ) { + const initialDocFromContextHasChanged = !isLensEqual( + initialDocFromContext, + lastKnownDoc, + data.query.filterManager.inject, + datasourceMap + ); + if (!initialDocFromContextHasChanged) { + onAppLeave((actions) => { + return actions.default(); + }); + application.navigateToApp('visualize', { path: initialContext.vizEditorOriginatingAppUrl }); + } else { + setIsGoBackToVizEditorModalVisible(true); + } + } + }, [ + application, + data.query.filterManager.inject, + datasourceMap, + initialContext, + initialDocFromContext, + lastKnownDoc, + onAppLeave, + ]); + + const navigateToVizEditor = useCallback(() => { + setIsGoBackToVizEditorModalVisible(false); + if ( + initialContext && + 'vizEditorOriginatingAppUrl' in initialContext && + initialContext.vizEditorOriginatingAppUrl + ) { + onAppLeave((actions) => { + return actions.default(); + }); + application.navigateToApp('visualize', { path: initialContext.vizEditorOriginatingAppUrl }); + } + }, [application, initialContext, onAppLeave]); + + const initialContextIsEmbedded = useMemo(() => { + return Boolean( + initialContext && 'originatingApp' in initialContext && initialContext.originatingApp + ); + }, [initialContext]); + return ( <>
@@ -313,10 +388,12 @@ export function App({ datasourceMap={datasourceMap} title={persistedDoc?.title} lensInspector={lensInspector} + goBackToOriginatingApp={goBackToOriginatingApp} + contextOriginatingApp={contextOriginatingApp} + initialContextIsEmbedded={initialContextIsEmbedded} topNavMenuEntryGenerators={topNavMenuEntryGenerators} initialContext={initialContext} /> - {getLegacyUrlConflictCallout()} {(!isLoading || persistedDoc) && ( )} + {isGoBackToVizEditorModalVisible && ( + setIsGoBackToVizEditorModalVisible(false)} + onConfirm={navigateToVizEditor} + cancelButtonText={i18n.translate('xpack.lens.app.goBackModalCancelBtn', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('xpack.lens.app.goBackModalTitle', { + defaultMessage: 'Discard changes?', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + > + {i18n.translate('xpack.lens.app.goBackModalMessage', { + defaultMessage: + 'The changes you have made here are not backwards compatible with your original {contextOriginatingApp} visualization. Are you sure you want to discard these unsaved changes and return to {contextOriginatingApp}?', + values: { contextOriginatingApp }, + })} + + )} ); } 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 90e924134d27b..8e8b7045fc253 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 @@ -39,6 +39,7 @@ function getLensTopNavConfig(options: { tooltips: LensTopNavTooltips; savingToLibraryPermitted: boolean; savingToDashboardPermitted: boolean; + contextOriginatingApp?: string; }): TopNavMenuData[] { const { actions, @@ -49,6 +50,7 @@ function getLensTopNavConfig(options: { savingToLibraryPermitted, savingToDashboardPermitted, tooltips, + contextOriginatingApp, } = options; const topNavMenu: TopNavMenuData[] = []; @@ -71,6 +73,23 @@ function getLensTopNavConfig(options: { defaultMessage: 'Save', }); + if (contextOriginatingApp) { + topNavMenu.push({ + label: i18n.translate('xpack.lens.app.goBackLabel', { + defaultMessage: `Go back to {contextOriginatingApp}`, + values: { contextOriginatingApp }, + }), + run: actions.goBack, + className: 'lnsNavItem__goBack', + testId: 'lnsApp_goBackToAppButton', + description: i18n.translate('xpack.lens.app.goBackLabel', { + defaultMessage: `Go back to {contextOriginatingApp}`, + values: { contextOriginatingApp }, + }), + disableButton: false, + }); + } + topNavMenu.push({ label: i18n.translate('xpack.lens.app.inspect', { defaultMessage: 'Inspect', @@ -151,6 +170,9 @@ export const LensTopNavMenu = ({ redirectToOrigin, datasourceMap, title, + goBackToOriginatingApp, + contextOriginatingApp, + initialContextIsEmbedded, topNavMenuEntryGenerators, initialContext, }: LensTopNavMenuProps) => { @@ -270,17 +292,19 @@ export const LensTopNavMenu = ({ ]); const topNavConfig = useMemo(() => { const baseMenuEntries = getLensTopNavConfig({ - showSaveAndReturn: Boolean( - isLinkedToOriginatingApp && - // Temporarily required until the 'by value' paradigm is default. - (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput)) - ), + showSaveAndReturn: + Boolean( + isLinkedToOriginatingApp && + // Temporarily required until the 'by value' paradigm is default. + (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput)) + ) || Boolean(initialContextIsEmbedded), enableExportToCSV: Boolean(isSaveable && activeData && Object.keys(activeData).length), isByValueMode: getIsByValueMode(), allowByValue: dashboardFeatureFlag.allowByValueEmbeddables, showCancel: Boolean(isLinkedToOriginatingApp), savingToLibraryPermitted, savingToDashboardPermitted, + contextOriginatingApp, tooltips: { showExportWarning: () => { if (activeData) { @@ -354,6 +378,11 @@ export const LensTopNavMenu = ({ setIsSaveModalVisible(true); } }, + goBack: () => { + if (contextOriginatingApp) { + goBackToOriginatingApp?.(); + } + }, cancel: () => { if (redirectToOrigin) { redirectToOrigin(); @@ -363,25 +392,28 @@ export const LensTopNavMenu = ({ }); return [...(additionalMenuEntries || []), ...baseMenuEntries]; }, [ - activeData, - attributeService, + isLinkedToOriginatingApp, dashboardFeatureFlag.allowByValueEmbeddables, - fieldFormats.deserialize, - getIsByValueMode, initialInput, - isLinkedToOriginatingApp, + initialContextIsEmbedded, isSaveable, + activeData, + getIsByValueMode, + savingToLibraryPermitted, + savingToDashboardPermitted, + contextOriginatingApp, + additionalMenuEntries, + lensInspector, title, + unsavedTitle, + uiSettings, + fieldFormats.deserialize, onAppLeave, - redirectToOrigin, runSave, - savingToDashboardPermitted, - savingToLibraryPermitted, + attributeService, setIsSaveModalVisible, - uiSettings, - unsavedTitle, - lensInspector, - additionalMenuEntries, + goBackToOriginatingApp, + redirectToOrigin, ]); const onQuerySubmitWrapped = useCallback( diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index e529c3ece055f..28db5e9f4c43a 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -29,6 +29,7 @@ import { LensByValueInput, } from '../embeddable/embeddable'; import { ACTION_VISUALIZE_LENS_FIELD } from '../../../../../src/plugins/ui_actions/public'; +import { ACTION_CONVERT_TO_LENS } from '../../../../../src/plugins/visualizations/public'; import { LensAttributeService } from '../lens_attribute_service'; import { LensAppServices, RedirectToOriginProps, HistoryLocationState } from './types'; import { @@ -155,28 +156,38 @@ export async function mountApp( }; const redirectToOrigin = (props?: RedirectToOriginProps) => { - if (!embeddableEditorIncomingState?.originatingApp) { + const contextOriginatingApp = + initialContext && 'originatingApp' in initialContext ? initialContext.originatingApp : null; + const originatingApp = embeddableEditorIncomingState?.originatingApp ?? contextOriginatingApp; + if (!originatingApp) { throw new Error('redirectToOrigin called without an originating app'); } + let embeddableId = embeddableEditorIncomingState?.embeddableId; + if (initialContext && 'embeddableId' in initialContext) { + embeddableId = initialContext.embeddableId; + } if (stateTransfer && props?.input) { const { input, isCopied } = props; - stateTransfer.navigateToWithEmbeddablePackage(embeddableEditorIncomingState?.originatingApp, { + stateTransfer.navigateToWithEmbeddablePackage(originatingApp, { path: embeddableEditorIncomingState?.originatingPath, state: { - embeddableId: isCopied ? undefined : embeddableEditorIncomingState.embeddableId, + embeddableId: isCopied ? undefined : embeddableId, type: LENS_EMBEDDABLE_TYPE, input, searchSessionId: data.search.session.getSessionId(), }, }); } else { - coreStart.application.navigateToApp(embeddableEditorIncomingState?.originatingApp, { + coreStart.application.navigateToApp(originatingApp, { path: embeddableEditorIncomingState?.originatingPath, }); } }; + // get state from location, used for nanigating from Visualize/Discover to Lens const initialContext = - historyLocationState && historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD + historyLocationState && + (historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD || + historyLocationState.type === ACTION_CONVERT_TO_LENS) ? historyLocationState.payload : undefined; @@ -229,8 +240,9 @@ export async function mountApp( history={props.history} datasourceMap={datasourceMap} visualizationMap={visualizationMap} - topNavMenuEntryGenerators={topNavMenuEntryGenerators} initialContext={initialContext} + contextOriginatingApp={historyLocationState?.originatingApp} + topNavMenuEntryGenerators={topNavMenuEntryGenerators} /> ); diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index 3181df8b3256d..bdd7bebd991e7 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -31,6 +31,7 @@ import { VisualizeFieldContext, ACTION_VISUALIZE_LENS_FIELD, } from '../../../../../src/plugins/ui_actions/public'; +import { ACTION_CONVERT_TO_LENS } from '../../../../../src/plugins/visualizations/public'; import type { EmbeddableEditorState, EmbeddableStateTransfer, @@ -38,6 +39,7 @@ import type { import type { DatasourceMap, EditorFrameInstance, + VisualizeEditorContext, LensTopNavMenuEntryGenerator, VisualizationMap, } from '../types'; @@ -65,9 +67,9 @@ export interface LensAppProps { incomingState?: EmbeddableEditorState; datasourceMap: DatasourceMap; visualizationMap: VisualizationMap; - + initialContext?: VisualizeEditorContext | VisualizeFieldContext; + contextOriginatingApp?: string; topNavMenuEntryGenerators: LensTopNavMenuEntryGenerator[]; - initialContext?: VisualizeFieldContext; } export type RunSave = ( @@ -97,13 +99,17 @@ export interface LensTopNavMenuProps { datasourceMap: DatasourceMap; title?: string; lensInspector: LensInspector; + goBackToOriginatingApp?: () => void; + contextOriginatingApp?: string; + initialContextIsEmbedded?: boolean; topNavMenuEntryGenerators: LensTopNavMenuEntryGenerator[]; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; } export interface HistoryLocationState { - type: typeof ACTION_VISUALIZE_LENS_FIELD; - payload: VisualizeFieldContext; + type: typeof ACTION_VISUALIZE_LENS_FIELD | typeof ACTION_CONVERT_TO_LENS; + payload: VisualizeFieldContext | VisualizeEditorContext; + originatingApp?: string; } export interface LensAppServices { @@ -140,6 +146,7 @@ export interface LensTopNavActions { inspect: () => void; saveAndReturn: () => void; showSaveModal: () => void; + goBack: () => void; cancel: () => void; exportToCSV: () => void; } 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 6879c35f30fe1..f2e4af61ddbdb 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 @@ -8,7 +8,7 @@ import React, { useCallback, useRef } from 'react'; import { CoreStart } from 'kibana/public'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; -import { DatasourceMap, FramePublicAPI, VisualizationMap } from '../../types'; +import { DatasourceMap, FramePublicAPI, VisualizationMap, Suggestion } from '../../types'; import { DataPanelWrapper } from './data_panel_wrapper'; import { ConfigPanelWrapper } from './config_panel'; import { FrameLayout } from './frame_layout'; @@ -16,7 +16,7 @@ import { SuggestionPanelWrapper } from './suggestion_panel'; import { WorkspacePanel } from './workspace_panel'; import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop'; import { EditorFrameStartPlugins } from '../service'; -import { getTopSuggestionForField, switchToSuggestion, Suggestion } from './suggestion_helpers'; +import { getTopSuggestionForField, switchToSuggestion } from './suggestion_helpers'; import { trackUiEvent } from '../../lens_ui_telemetry'; import { useLensSelector, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 0ea621997e859..40db06285d0b6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -17,6 +17,7 @@ import { Visualization, VisualizationDimensionGroupConfig, VisualizationMap, + VisualizeEditorContext, } from '../../types'; import { buildExpression } from './expression_helpers'; import { Document } from '../../persistence/saved_object_store'; @@ -35,7 +36,7 @@ export async function initializeDatasources( datasourceMap: DatasourceMap, datasourceStates: DatasourceStates, references?: SavedObjectReference[], - initialContext?: VisualizeFieldContext, + initialContext?: VisualizeFieldContext | VisualizeEditorContext, options?: InitializationOptions ) { const states: DatasourceStates = {}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index 9d1e5910b468d..48536f8599060 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -7,7 +7,12 @@ import { getSuggestions, getTopSuggestionForField } from './suggestion_helpers'; import { createMockVisualization, createMockDatasource, DatasourceMock } from '../../mocks'; -import { TableSuggestion, DatasourceSuggestion, Visualization } from '../../types'; +import { + TableSuggestion, + DatasourceSuggestion, + Visualization, + VisualizeEditorContext, +} from '../../types'; import { PaletteOutput } from 'src/plugins/charts/public'; import { DatasourceStates } from '../../state_management'; @@ -251,6 +256,166 @@ describe('suggestion helpers', () => { ).not.toHaveBeenCalled(); }); + it('should call getDatasourceSuggestionsForVisualizeCharts when a visualizeChartTrigger is passed', () => { + datasourceMap.mock.getDatasourceSuggestionsForVisualizeCharts.mockReturnValue([ + generateSuggestion(), + ]); + + const visualizationMap = { + testVis: createMockVisualization(), + }; + const triggerContext = { + layers: [ + { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + timeFieldName: 'order_date', + chartType: 'area', + axisPosition: 'left', + palette: { + type: 'palette', + name: 'default', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + fill: '0.5', + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: true, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + isVisualizeAction: true, + } as VisualizeEditorContext; + + getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.testVis, + visualizationState: {}, + datasourceMap, + datasourceStates, + visualizeTriggerFieldContext: triggerContext, + }); + expect(datasourceMap.mock.getDatasourceSuggestionsForVisualizeCharts).toHaveBeenCalledWith( + datasourceStates.mock.state, + triggerContext.layers + ); + }); + + it('should call getDatasourceSuggestionsForVisualizeCharts from all datasources with a state', () => { + const multiDatasourceStates = { + mock: { + isLoading: false, + state: {}, + }, + mock2: { + isLoading: false, + state: {}, + }, + }; + const multiDatasourceMap = { + mock: createMockDatasource('a'), + mock2: createMockDatasource('a'), + mock3: createMockDatasource('a'), + }; + const triggerContext = { + layers: [ + { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + timeFieldName: 'order_date', + chartType: 'area', + axisPosition: 'left', + palette: { + type: 'palette', + name: 'default', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + fill: '0.5', + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: true, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + isVisualizeAction: true, + } as VisualizeEditorContext; + + const visualizationMap = { + testVis: createMockVisualization(), + }; + getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.testVis, + visualizationState: {}, + datasourceMap: multiDatasourceMap, + datasourceStates: multiDatasourceStates, + visualizeTriggerFieldContext: triggerContext, + }); + expect(multiDatasourceMap.mock.getDatasourceSuggestionsForVisualizeCharts).toHaveBeenCalledWith( + datasourceStates.mock.state, + triggerContext.layers + ); + + expect( + multiDatasourceMap.mock2.getDatasourceSuggestionsForVisualizeCharts + ).toHaveBeenCalledWith(multiDatasourceStates.mock2.state, triggerContext.layers); + expect( + multiDatasourceMap.mock3.getDatasourceSuggestionsForVisualizeCharts + ).not.toHaveBeenCalled(); + }); + it('should rank the visualizations by score', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index ac55d966927bd..b8ce851f25349 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -5,20 +5,19 @@ * 2.0. */ -import { Ast } from '@kbn/interpreter'; -import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Datatable } from 'src/plugins/expressions'; import { PaletteOutput } from 'src/plugins/charts/public'; import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; import { Visualization, Datasource, - TableChangeType, TableSuggestion, DatasourceSuggestion, DatasourcePublicAPI, DatasourceMap, VisualizationMap, + VisualizeEditorContext, + Suggestion, } from '../../types'; import { DragDropIdentifier } from '../../drag_drop'; import { LayerType, layerTypes } from '../../../common'; @@ -30,21 +29,6 @@ import { VisualizationState, } from '../../state_management'; -export interface Suggestion { - visualizationId: string; - datasourceState?: unknown; - datasourceId?: string; - columns: number; - score: number; - title: string; - visualizationState: unknown; - previewExpression?: Ast | string; - previewIcon: IconType; - hide?: boolean; - changeType: TableChangeType; - keptLayerIds: string[]; -} - /** * This function takes a list of available data tables and a list of visualization * extensions and creates a ranked list of suggestions which contain a pair of a data table @@ -72,7 +56,7 @@ export function getSuggestions({ subVisualizationId?: string; visualizationState: unknown; field?: unknown; - visualizeTriggerFieldContext?: VisualizeFieldContext; + visualizeTriggerFieldContext?: VisualizeFieldContext | VisualizeEditorContext; activeData?: Record; mainPalette?: PaletteOutput; }): Suggestion[] { @@ -100,12 +84,22 @@ export function getSuggestions({ const datasourceTableSuggestions = datasources.flatMap(([datasourceId, datasource]) => { const datasourceState = datasourceStates[datasourceId].state; let dataSourceSuggestions; + // context is used to pass the state from location to datasource if (visualizeTriggerFieldContext) { - dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeField( - datasourceState, - visualizeTriggerFieldContext.indexPatternId, - visualizeTriggerFieldContext.fieldName - ); + // used for navigating from VizEditor to Lens + if ('isVisualizeAction' in visualizeTriggerFieldContext) { + dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeCharts( + datasourceState, + visualizeTriggerFieldContext.layers + ); + } else { + // used for navigating from Discover to Lens + dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeField( + datasourceState, + visualizeTriggerFieldContext.indexPatternId, + visualizeTriggerFieldContext.fieldName + ); + } } else if (field) { dataSourceSuggestions = datasource.getDatasourceSuggestionsForField( datasourceState, @@ -170,7 +164,7 @@ export function getVisualizeFieldSuggestions({ datasourceStates: DatasourceStates; visualizationMap: VisualizationMap; subVisualizationId?: string; - visualizeTriggerFieldContext?: VisualizeFieldContext; + visualizeTriggerFieldContext?: VisualizeFieldContext | VisualizeEditorContext; }): Suggestion | undefined { const activeVisualization = visualizationMap?.[Object.keys(visualizationMap)[0]] || null; const suggestions = getSuggestions({ @@ -181,6 +175,17 @@ export function getVisualizeFieldSuggestions({ visualizationState: undefined, visualizeTriggerFieldContext, }); + + if (visualizeTriggerFieldContext && 'isVisualizeAction' in visualizeTriggerFieldContext) { + const allSuggestions = suggestions.filter( + (s) => s.visualizationId === visualizeTriggerFieldContext.type + ); + return activeVisualization?.getVisualizationSuggestionFromContext?.({ + suggestions: allSuggestions, + context: visualizeTriggerFieldContext, + }); + } + if (suggestions.length) { return suggestions.find((s) => s.visualizationId === activeVisualization?.id) || suggestions[0]; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index 47070822a8080..c9ddc0ea6551c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { Visualization } from '../../types'; +import { Visualization, Suggestion } from '../../types'; import { createMockVisualization, createMockDatasource, @@ -17,7 +17,7 @@ import { import { act } from 'react-dom/test-utils'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { SuggestionPanel, SuggestionPanelProps, SuggestionPanelWrapper } from './suggestion_panel'; -import { getSuggestions, Suggestion } from './suggestion_helpers'; +import { getSuggestions } from './suggestion_helpers'; import { EuiIcon, EuiPanel, EuiToolTip, EuiAccordion } from '@elastic/eui'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { mountWithProvider } from '../../mocks'; 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 101f863d3227c..d24ed0a736ae2 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 @@ -26,8 +26,9 @@ import { VisualizationType, VisualizationMap, DatasourceMap, + Suggestion, } from '../../../types'; -import { getSuggestions, switchToSuggestion, Suggestion } from '../suggestion_helpers'; +import { getSuggestions, switchToSuggestion } from '../suggestion_helpers'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { ToolbarButton } from '../../../../../../../src/plugins/kibana_react/public'; import { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 5f14e83bf41a1..3554f77047577 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -37,9 +37,10 @@ import { VisualizationMap, DatasourceMap, DatasourceFixAction, + Suggestion, } from '../../../types'; import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop'; -import { Suggestion, switchToSuggestion } from '../suggestion_helpers'; +import { switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 8efb667120f77..2a44550af2b58 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -42,6 +42,7 @@ import { getDatasourceSuggestionsForField, getDatasourceSuggestionsFromCurrentState, getDatasourceSuggestionsForVisualizeField, + getDatasourceSuggestionsForVisualizeCharts, } from './indexpattern_suggestions'; import { getVisualDefaultsForLayer, isColumnInvalid } from './utils'; @@ -61,7 +62,7 @@ import { import { DataPublicPluginStart, ES_FIELD_TYPES } from '../../../../../src/plugins/data/public'; import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/public'; import { mergeLayer } from './state_helpers'; -import { Datasource, StateSetter } from '../types'; +import { Datasource, StateSetter, VisualizeEditorContext } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { deleteColumn, isReferenced } from './operations'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; @@ -150,7 +151,7 @@ export function getIndexPatternDatasource({ async initialize( persistedState?: IndexPatternPersistedState, references?: SavedObjectReference[], - initialContext?: VisualizeFieldContext, + initialContext?: VisualizeFieldContext | VisualizeEditorContext, options?: InitializationOptions ) { return loadInitialState({ @@ -485,6 +486,7 @@ export function getIndexPatternDatasource({ }, getDatasourceSuggestionsFromCurrentState, getDatasourceSuggestionsForVisualizeField, + getDatasourceSuggestionsForVisualizeCharts, getErrorMessages(state) { if (!state) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 5a0eb1a73e075..c25b8b7264077 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { VisualizeEditorLayersContext } from '../../../../../src/plugins/visualizations/public'; import { DatasourceSuggestion } from '../types'; import { generateId } from '../id_generator'; import type { IndexPatternPrivateState } from './types'; @@ -12,6 +12,7 @@ import { getDatasourceSuggestionsForField, getDatasourceSuggestionsFromCurrentState, getDatasourceSuggestionsForVisualizeField, + getDatasourceSuggestionsForVisualizeCharts, IndexPatternSuggestion, } from './indexpattern_suggestions'; import { documentField } from './document_field'; @@ -1406,6 +1407,432 @@ describe('IndexPattern Data Source suggestions', () => { }); }); + describe('#getDatasourceSuggestionsForVisualizeCharts', () => { + const context = [ + { + indexPatternId: '1', + timeFieldName: 'timestamp', + chartType: 'area', + axisPosition: 'left', + palette: { + name: 'default', + type: 'palette', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ] as VisualizeEditorLayersContext[]; + function stateWithoutLayer() { + return { + ...testInitialState(), + layers: {}, + }; + } + + it('should return empty array if indexpattern id doesnt match the state', () => { + const updatedContext = [ + { + ...context[0], + indexPatternId: 'test', + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toStrictEqual([]); + }); + + it('should apply a count metric, with a timeseries bucket', () => { + const suggestions = getDatasourceSuggestionsForVisualizeCharts(stateWithoutLayer(), context); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + }), + id3: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a custom label if given', () => { + const updatedContext = [ + { + ...context[0], + label: 'testLabel', + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + label: 'testLabel', + }), + id3: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a custom format if given', () => { + const updatedContext = [ + { + ...context[0], + format: 'bytes', + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + label: 'Count of records', + params: expect.objectContaining({ + format: { + id: 'bytes', + params: { + decimals: 0, + }, + }, + }), + }), + id3: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a split by terms aggregation if it is provided', () => { + const updatedContext = [ + { + ...context[0], + splitField: 'source', + splitMode: 'terms', + termsParams: { + size: 10, + otherBucket: false, + orderBy: { + type: 'column', + }, + }, + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id4', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + }), + id3: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + params: expect.objectContaining({ + size: 10, + otherBucket: false, + orderDirection: 'desc', + }), + }), + id4: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id4', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a split by filters aggregation if it is provided', () => { + const updatedContext = [ + { + ...context[0], + splitMode: 'filters', + splitFilters: [ + { + filter: { + query: 'category.keyword : "Men\'s Clothing" ', + language: 'kuery', + }, + label: '', + color: '#68BC00', + id: 'a8d92740-7de1-11ec-b443-27e8df79881f', + }, + { + filter: { + query: 'category.keyword : "Women\'s Accessories" ', + language: 'kuery', + }, + label: '', + color: '#68BC00', + id: 'ad5dc500-7de1-11ec-b443-27e8df79881f', + }, + ], + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id4', 'id3', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + }), + id3: expect.objectContaining({ + operationType: 'filters', + label: 'Filters', + params: expect.objectContaining({ + filters: [ + { + input: { + language: 'kuery', + query: 'category.keyword : "Men\'s Clothing" ', + }, + label: '', + }, + { + input: { + language: 'kuery', + query: 'category.keyword : "Women\'s Accessories" ', + }, + label: '', + }, + ], + }), + }), + id4: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id4', + }), + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a formula layer if it is provided', () => { + const updatedContext = [ + { + ...context[0], + metrics: [ + { + agg: 'formula', + isFullReference: true, + fieldName: 'document', + params: { + formula: 'overall_sum(count())', + }, + color: '#68BC00', + }, + ], + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id2X0', 'id2X1', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'formula', + params: expect.objectContaining({ + formula: 'overall_sum(count())', + }), + }), + id2X0: expect.objectContaining({ + operationType: 'count', + label: 'Part of overall_sum(count())', + }), + id2X1: expect.objectContaining({ + operationType: 'overall_sum', + label: 'Part of overall_sum(count())', + }), + id3: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + }); + describe('#getDatasourceSuggestionsForVisualizeField', () => { describe('with no layer', () => { function stateWithoutLayer() { 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 a96a43f74f0f4..0e6fbf02a491e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -7,6 +7,7 @@ import { flatten, minBy, pick, mapValues, partition } from 'lodash'; import { i18n } from '@kbn/i18n'; +import type { VisualizeEditorLayersContext } from '../../../../../src/plugins/visualizations/public'; import { generateId } from '../id_generator'; import type { DatasourceSuggestion, TableChangeType } from '../types'; import { columnToOperation } from './indexpattern'; @@ -21,6 +22,9 @@ import { getExistingColumnGroups, isReferenced, getReferencedColumnIds, + getSplitByTermsLayer, + getSplitByFiltersLayer, + computeLayerFromContext, hasTermsWithManyBuckets, } from './operations'; import { hasField } from './pure_utils'; @@ -31,7 +35,6 @@ import type { IndexPatternField, } from './types'; import { documentField } from './document_field'; - export type IndexPatternSuggestion = DatasourceSuggestion; function buildSuggestion({ @@ -129,6 +132,86 @@ export function getDatasourceSuggestionsForField( } } +// Called when the user navigates from Visualize editor to Lens +export function getDatasourceSuggestionsForVisualizeCharts( + state: IndexPatternPrivateState, + context: VisualizeEditorLayersContext[] +): IndexPatternSuggestion[] { + const layers = Object.keys(state.layers); + const layerIds = layers.filter( + (id) => state.layers[id].indexPatternId === context[0].indexPatternId + ); + if (layerIds.length !== 0) return []; + return getEmptyLayersSuggestionsForVisualizeCharts(state, context); +} + +function getEmptyLayersSuggestionsForVisualizeCharts( + state: IndexPatternPrivateState, + context: VisualizeEditorLayersContext[] +): IndexPatternSuggestion[] { + const suggestions: IndexPatternSuggestion[] = []; + for (let layerIdx = 0; layerIdx < context.length; layerIdx++) { + const layer = context[layerIdx]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + if (!indexPattern) return []; + + const newId = generateId(); + let newLayer: IndexPatternLayer | undefined; + if (indexPattern.timeFieldName) { + newLayer = createNewTimeseriesLayerWithMetricAggregationFromVizEditor(indexPattern, layer); + } + if (newLayer) { + const suggestion = buildSuggestion({ + state, + updatedLayer: newLayer, + layerId: newId, + changeType: 'initial', + }); + const layerId = Object.keys(suggestion.state.layers)[0]; + context[layerIdx].layerId = layerId; + suggestions.push(suggestion); + } + } + return suggestions; +} + +function createNewTimeseriesLayerWithMetricAggregationFromVizEditor( + indexPattern: IndexPattern, + layer: VisualizeEditorLayersContext +): IndexPatternLayer | undefined { + const { timeFieldName, splitMode, splitFilters, metrics, timeInterval } = layer; + const dateField = indexPattern.getFieldByName(timeFieldName!); + const splitField = layer.splitField ? indexPattern.getFieldByName(layer.splitField) : null; + // generate the layer for split by terms + if (splitMode === 'terms' && splitField) { + return getSplitByTermsLayer(indexPattern, splitField, dateField, layer); + // generate the layer for split by filters + } else if (splitMode?.includes('filter') && splitFilters && splitFilters.length) { + return getSplitByFiltersLayer(indexPattern, dateField, layer); + } else { + const copyMetricsArray = [...metrics]; + const computedLayer = computeLayerFromContext( + metrics.length === 1, + copyMetricsArray, + indexPattern, + layer.format, + layer.label + ); + + return insertNewColumn({ + op: 'date_histogram', + layer: computedLayer, + columnId: generateId(), + field: dateField, + indexPattern, + visualizationGroups: [], + columnParams: { + interval: timeInterval, + }, + }); + } +} + // Called when the user navigates from Discover to Lens (Visualize button) export function getDatasourceSuggestionsForVisualizeField( state: IndexPatternPrivateState, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index d2922ed86614a..9099b68cdaf0e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -506,6 +506,58 @@ describe('loader', () => { }); }); + it('should use the indexPatternId of the visualize trigger chart context, if provided', async () => { + const storage = createMockStorage(); + const state = await loadInitialState({ + indexPatternsService: mockIndexPatternsService(), + storage, + initialContext: { + layers: [ + { + indexPatternId: '1', + timeFieldName: 'timestamp', + chartType: 'area', + axisPosition: 'left', + metrics: [], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: true, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + }, + savedObjectId: '', + isVisualizeAction: true, + }, + options: { isFullEditor: true }, + }); + + expect(state).toMatchObject({ + currentIndexPatternId: '1', + indexPatternRefs: [ + { id: '1', title: sampleIndexPatterns['1'].title }, + { id: '2', title: sampleIndexPatterns['2'].title }, + ], + indexPatterns: { + '1': sampleIndexPatterns['1'], + }, + layers: {}, + }); + expect(storage.set).toHaveBeenCalledWith('lens-settings', { + indexPatternId: '1', + }); + }); + it('should initialize all the embeddable references without local storage', async () => { const savedState: IndexPatternPersistedState = { layers: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index c61569539bec8..8b3a0556b0320 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -9,8 +9,7 @@ import { uniq, mapValues, difference } from 'lodash'; import type { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import type { DataView } from 'src/plugins/data_views/public'; import type { HttpSetup, SavedObjectReference } from 'kibana/public'; -import type { InitializationOptions, StateSetter } from '../types'; - +import type { InitializationOptions, StateSetter, VisualizeEditorContext } from '../types'; import { IndexPattern, IndexPatternRef, @@ -226,7 +225,7 @@ export async function loadInitialState({ defaultIndexPatternId?: string; storage: IStorageWrapper; indexPatternsService: IndexPatternsService; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; options?: InitializationOptions; }): Promise { const { isFullEditor } = options ?? {}; @@ -237,12 +236,20 @@ export async function loadInitialState({ const lastUsedIndexPatternId = getLastUsedIndexPatternId(storage, indexPatternRefs); const fallbackId = lastUsedIndexPatternId || defaultIndexPatternId || indexPatternRefs[0]?.id; - + const indexPatternIds = []; + if (initialContext && 'isVisualizeAction' in initialContext) { + for (let layerIdx = 0; layerIdx < initialContext.layers.length; layerIdx++) { + const layerContext = initialContext.layers[layerIdx]; + indexPatternIds.push(layerContext.indexPatternId); + } + } else if (initialContext) { + indexPatternIds.push(initialContext.indexPatternId); + } const state = persistedState && references ? injectReferences(persistedState, references) : undefined; const usedPatterns = ( initialContext - ? [initialContext.indexPatternId] + ? indexPatternIds : uniq( state ? Object.values(state.layers) @@ -272,11 +279,9 @@ export async function loadInitialState({ // * start with the indexPattern in context // * then fallback to the used ones // * then as last resort use a first one from not used refs - const availableIndexPatternIds = [ - initialContext?.indexPatternId, - ...usedPatterns, - ...notUsedPatterns, - ].filter((id) => id != null && availableIndexPatterns.has(id) && indexPatterns[id]); + const availableIndexPatternIds = [...indexPatternIds, ...usedPatterns, ...notUsedPatterns].filter( + (id) => id != null && availableIndexPatterns.has(id) && indexPatterns[id] + ); const currentIndexPatternId = availableIndexPatternIds[0]; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx index 3f051286f3da9..674eac8194e41 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -81,7 +81,9 @@ export const counterRateOperation: OperationDefinition< }, buildColumn: ({ referenceIds, previousColumn, layer, indexPattern }, columnParams) => { const metric = layer.columns[referenceIds[0]]; - const timeScale = previousColumn?.timeScale || DEFAULT_TIME_SCALE; + const counterRateColumnParams = columnParams as CounterRateIndexPatternColumn; + const timeScale = + previousColumn?.timeScale || counterRateColumnParams?.timeScale || DEFAULT_TIME_SCALE; return { label: ofName( metric && 'sourceField' in metric diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx index 31b21327958d7..2c4ab56d7e223 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx @@ -75,6 +75,8 @@ export const derivativeOperation: OperationDefinition< }, buildColumn: ({ referenceIds, previousColumn, layer }, columnParams) => { const ref = layer.columns[referenceIds[0]]; + const differencesColumnParams = columnParams as DerivativeIndexPatternColumn; + const timeScale = differencesColumnParams?.timeScale ?? previousColumn?.timeScale; return { label: ofName(ref?.label, previousColumn?.timeScale, previousColumn?.timeShift), dataType: 'number', @@ -82,7 +84,7 @@ export const derivativeOperation: OperationDefinition< isBucketed: false, scale: 'ratio', references: referenceIds, - timeScale: previousColumn?.timeScale, + timeScale, filter: getFilter(previousColumn, columnParams), timeShift: columnParams?.shift || previousColumn?.timeShift, params: getFormatFromPreviousColumn(previousColumn), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 1a8519e6a60a1..aa68c8409ad80 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -92,12 +92,10 @@ export const movingAverageOperation: OperationDefinition< window: [(layer.columns[columnId] as MovingAverageIndexPatternColumn).params.window], }); }, - buildColumn: ( - { referenceIds, previousColumn, layer }, - columnParams = { window: WINDOW_DEFAULT_VALUE } - ) => { + buildColumn: ({ referenceIds, previousColumn, layer }, columnParams) => { const metric = layer.columns[referenceIds[0]]; - const { window = WINDOW_DEFAULT_VALUE } = columnParams; + const window = columnParams?.window ?? WINDOW_DEFAULT_VALUE; + return { label: ofName(metric?.label, previousColumn?.timeScale, previousColumn?.timeShift), dataType: 'number', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 3a1a53ba1a5f0..a048f2b559191 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -55,7 +55,7 @@ export type { } from './column_types'; export type { TermsIndexPatternColumn } from './terms'; -export type { FiltersIndexPatternColumn } from './filters'; +export type { FiltersIndexPatternColumn, Filter } from './filters'; export type { CardinalityIndexPatternColumn } from './cardinality'; export type { PercentileIndexPatternColumn } from './percentile'; export type { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 438d728b7df1f..ab7ee8992f2fe 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -5,9 +5,10 @@ * 2.0. */ -import { partition, mapValues, pickBy } from 'lodash'; +import { partition, mapValues, pickBy, isArray } from 'lodash'; import { CoreStart } from 'kibana/public'; import { Query } from 'src/plugins/data/common'; +import type { VisualizeEditorLayersContext } from '../../../../../../src/plugins/visualizations/public'; import type { DatasourceFixAction, FrameDatasourceAPI, @@ -38,7 +39,9 @@ import { } from './definitions/column_types'; import { FormulaIndexPatternColumn, insertOrReplaceFormulaColumn } from './definitions/formula'; import type { TimeScaleUnit } from '../../../common/expressions'; +import { documentField } from '../document_field'; import { isColumnOfType } from './definitions/helpers'; +import { isSortableByColumn } from './definitions/terms/helpers'; interface ColumnAdvancedParams { filter?: Query | undefined; @@ -57,6 +60,9 @@ interface ColumnChange { shouldResetLabel?: boolean; shouldCombineField?: boolean; incompleteParams?: ColumnAdvancedParams; + incompleteFieldName?: string; + incompleteFieldOperation?: OperationType; + columnParams?: Record; initialParams?: { params: Record }; // TODO: bind this to the op parameter } @@ -190,6 +196,9 @@ export function insertNewColumn({ targetGroup, shouldResetLabel, incompleteParams, + incompleteFieldName, + incompleteFieldOperation, + columnParams, initialParams, }: ColumnChange): IndexPatternLayer { const operationDefinition = operationDefinitionMap[op]; @@ -218,6 +227,7 @@ export function insertNewColumn({ const possibleOperation = operationDefinition.getPossibleOperation(); const isBucketed = Boolean(possibleOperation?.isBucketed); const addOperationFn = isBucketed ? addBucket : addMetric; + return updateDefaultLabels( addOperationFn( layer, @@ -247,12 +257,30 @@ export function insertNewColumn({ } const newId = generateId(); + if (incompleteFieldOperation && incompleteFieldName) { + const validFields = indexPattern.fields.filter( + (validField) => validField.name === incompleteFieldName + ); + tempLayer = insertNewColumn({ + layer: tempLayer, + columnId: newId, + op: incompleteFieldOperation, + indexPattern, + field: validFields[0] ?? documentField, + visualizationGroups, + columnParams, + targetGroup, + }); + } if (validOperations.length === 1) { const def = validOperations[0]; - const validFields = + let validFields = def.input === 'field' ? indexPattern.fields.filter(def.getPossibleOperationForField) : []; + if (incompleteFieldName) { + validFields = validFields.filter((validField) => validField.name === incompleteFieldName); + } if (def.input === 'none') { tempLayer = insertNewColumn({ layer: tempLayer, @@ -293,14 +321,14 @@ export function insertNewColumn({ const isBucketed = Boolean(possibleOperation.isBucketed); const addOperationFn = isBucketed ? addBucket : addMetric; + const buildColumnFn = columnParams + ? operationDefinition.buildColumn( + { ...baseOptions, layer: tempLayer, referenceIds }, + columnParams + ) + : operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, referenceIds }); return updateDefaultLabels( - addOperationFn( - tempLayer, - operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, referenceIds }), - columnId, - visualizationGroups, - targetGroup - ), + addOperationFn(tempLayer, buildColumnFn, columnId, visualizationGroups, targetGroup), indexPattern ); } @@ -359,7 +387,7 @@ export function insertNewColumn({ }; } - const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer, field }); + const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer, field }, columnParams); const isBucketed = Boolean(possibleOperation.isBucketed); const addOperationFn = isBucketed ? addBucket : addMetric; return updateDefaultLabels( @@ -1107,6 +1135,29 @@ export function getMetricOperationTypes(field: IndexPatternField) { }); } +export function updateColumnLabel({ + layer, + columnId, + customLabel, +}: { + layer: IndexPatternLayer; + columnId: string; + customLabel: string; +}): IndexPatternLayer { + const oldColumn = layer.columns[columnId]; + return { + ...layer, + columns: { + ...layer.columns, + [columnId]: { + ...oldColumn, + label: customLabel ? customLabel : oldColumn.label, + customLabel: Boolean(customLabel), + }, + } as Record, + }; +} + export function updateColumnParam({ layer, columnId, @@ -1507,3 +1558,234 @@ export function getManagedColumnsFrom( } return store.filter(([, column]) => column); } + +export function computeLayerFromContext( + isLast: boolean, + metricsArray: VisualizeEditorLayersContext['metrics'], + indexPattern: IndexPattern, + format?: string, + customLabel?: string +): IndexPatternLayer { + let layer: IndexPatternLayer = { + indexPatternId: indexPattern.id, + columns: {}, + columnOrder: [], + }; + if (isArray(metricsArray)) { + const metricContext = metricsArray.shift(); + const field = metricContext + ? indexPattern.getFieldByName(metricContext.fieldName) ?? documentField + : documentField; + + const operation = metricContext?.agg; + // Formula should be treated differently from other operations + if (operation === 'formula') { + const operationDefinition = operationDefinitionMap.formula as OperationDefinition< + FormulaIndexPatternColumn, + 'managedReference' + >; + const tempLayer = { indexPatternId: indexPattern.id, columns: {}, columnOrder: [] }; + let newColumn = operationDefinition.buildColumn({ + indexPattern, + layer: tempLayer, + }) as FormulaIndexPatternColumn; + let filterBy = metricContext?.params?.kql + ? { query: metricContext?.params?.kql, language: 'kuery' } + : undefined; + if (metricContext?.params?.lucene) { + filterBy = metricContext?.params?.lucene + ? { query: metricContext?.params?.lucene, language: 'lucene' } + : undefined; + } + newColumn = { + ...newColumn, + ...(filterBy && { filter: filterBy }), + params: { + ...newColumn.params, + ...metricContext?.params, + }, + } as FormulaIndexPatternColumn; + layer = metricContext?.params?.formula + ? insertOrReplaceFormulaColumn(generateId(), newColumn, tempLayer, { + indexPattern, + }).layer + : tempLayer; + } else { + const columnId = generateId(); + // recursive function to build the layer + layer = insertNewColumn({ + op: operation as OperationType, + layer: isLast + ? { indexPatternId: indexPattern.id, columns: {}, columnOrder: [] } + : computeLayerFromContext(metricsArray.length === 1, metricsArray, indexPattern), + columnId, + field: !metricContext?.isFullReference ? field ?? documentField : undefined, + columnParams: metricContext?.params ?? undefined, + incompleteFieldName: metricContext?.isFullReference ? field?.name : undefined, + incompleteFieldOperation: metricContext?.isFullReference + ? metricContext?.pipelineAggType + : undefined, + indexPattern, + visualizationGroups: [], + }); + if (metricContext) { + metricContext.accessor = columnId; + } + } + } + + // update the layer with the custom label and the format + let columnIdx = 0; + for (const [columnId, column] of Object.entries(layer.columns)) { + if (format) { + layer = updateColumnParam({ + layer, + columnId, + paramName: 'format', + value: { + id: format, + params: { + decimals: 0, + }, + }, + }); + } + + // for percentiles I want to update all columns with the custom label + if (customLabel && column.operationType === 'percentile') { + layer = updateColumnLabel({ + layer, + columnId, + customLabel, + }); + } else if (customLabel && columnIdx === Object.keys(layer.columns).length - 1) { + layer = updateColumnLabel({ + layer, + columnId, + customLabel, + }); + } + columnIdx++; + } + return layer; +} + +export function getSplitByTermsLayer( + indexPattern: IndexPattern, + splitField: IndexPatternField, + dateField: IndexPatternField | undefined, + layer: VisualizeEditorLayersContext +): IndexPatternLayer { + const { termsParams, metrics, timeInterval, splitWithDateHistogram } = layer; + const copyMetricsArray = [...metrics]; + const computedLayer = computeLayerFromContext( + metrics.length === 1, + copyMetricsArray, + indexPattern, + layer.format, + layer.label + ); + + const columnId = generateId(); + let termsLayer = insertNewColumn({ + op: splitWithDateHistogram ? 'date_histogram' : 'terms', + layer: insertNewColumn({ + op: 'date_histogram', + layer: computedLayer, + columnId: generateId(), + field: dateField, + indexPattern, + visualizationGroups: [], + columnParams: { + interval: timeInterval, + }, + }), + columnId, + field: splitField, + indexPattern, + visualizationGroups: [], + }); + const termsColumnParams = termsParams as TermsIndexPatternColumn['params']; + if (termsColumnParams) { + for (const [param, value] of Object.entries(termsColumnParams)) { + let paramValue = value; + if (param === 'orderBy') { + const [existingMetricColumn] = Object.keys(termsLayer.columns).filter((colId) => + isSortableByColumn(termsLayer, colId) + ); + + paramValue = ( + termsColumnParams.orderBy.type === 'column' && existingMetricColumn + ? { + type: 'column', + columnId: existingMetricColumn, + } + : { type: 'alphabetical', fallback: true } + ) as TermsIndexPatternColumn['params']['orderBy']; + } + termsLayer = updateColumnParam({ + layer: termsLayer, + columnId, + paramName: param, + value: paramValue, + }); + } + } + return termsLayer; +} + +export function getSplitByFiltersLayer( + indexPattern: IndexPattern, + dateField: IndexPatternField | undefined, + layer: VisualizeEditorLayersContext +): IndexPatternLayer { + const { splitFilters, metrics, timeInterval } = layer; + const filterParams = splitFilters?.map((param) => { + const query = param.filter ? param.filter.query : ''; + const language = param.filter ? param.filter.language : 'kuery'; + return { + input: { + query, + language, + }, + label: param.label ?? '', + }; + }); + const copyMetricsArray = [...metrics]; + const computedLayer = computeLayerFromContext( + metrics.length === 1, + copyMetricsArray, + indexPattern, + layer.format, + layer.label + ); + const columnId = generateId(); + let filtersLayer = insertNewColumn({ + op: 'filters', + layer: insertNewColumn({ + op: 'date_histogram', + layer: computedLayer, + columnId: generateId(), + field: dateField, + indexPattern, + visualizationGroups: [], + columnParams: { + interval: timeInterval, + }, + }), + columnId, + field: undefined, + indexPattern, + visualizationGroups: [], + }); + + if (filterParams) { + filtersLayer = updateColumnParam({ + layer: filtersLayer, + columnId, + paramName: 'filters', + value: filterParams, + }); + } + return filtersLayer; +} diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index ce36b575b30e3..67b286b2ef8a2 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -24,6 +24,7 @@ export function createMockDatasource(id: string): DatasourceMock { clearLayer: jest.fn((state, _layerId) => state), getDatasourceSuggestionsForField: jest.fn((_state, _item, filterFn) => []), getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []), + getDatasourceSuggestionsForVisualizeCharts: jest.fn((_state, _context) => []), getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []), getPersistableState: jest.fn((x) => ({ state: x, diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index bba54c85a67c6..42e4a55167c8b 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -68,6 +68,7 @@ import { ACTION_VISUALIZE_FIELD, VISUALIZE_FIELD_TRIGGER, } from '../../../../src/plugins/ui_actions/public'; +import { VISUALIZE_EDITOR_TRIGGER } from '../../../../src/plugins/visualizations/public'; import { APP_ID, getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common/constants'; import type { FormatFactory } from '../common/types'; import type { @@ -78,6 +79,7 @@ import type { } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import { visualizeFieldAction } from './trigger_actions/visualize_field_actions'; +import { visualizeTSVBAction } from './trigger_actions/visualize_tsvb_actions'; import type { LensEmbeddableInput } from './embeddable'; import { EmbeddableFactory, LensEmbeddableStartServices } from './embeddable/embeddable_factory'; @@ -419,6 +421,11 @@ export class LensPlugin { visualizeFieldAction(core.application) ); + startDependencies.uiActions.addTriggerAction( + VISUALIZE_EDITOR_TRIGGER, + visualizeTSVBAction(core.application) + ); + return { EmbeddableComponent: getEmbeddableComponent(core, startDependencies), SaveModalComponent: getSaveModalComponent(core, startDependencies), 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 67b7ccac97478..099929cdf4796 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -12,16 +12,14 @@ import { History } from 'history'; import { LensEmbeddableInput } from '..'; import { getDatasourceLayers } from '../editor_frame_service/editor_frame'; import { TableInspectorAdapter } from '../editor_frame_service/types'; +import type { VisualizeEditorContext, Suggestion } from '../types'; import { getInitialDatasourceId, getResolvedDateRange, getRemoveOperation } from '../utils'; import { LensAppState, LensStoreDeps, VisualizationState } from './types'; import { Datasource, Visualization } from '../types'; import { generateId } from '../id_generator'; import type { LayerType } from '../../common/types'; import { getLayerType } from '../editor_frame_service/editor_frame/config_panel/add_layer'; -import { - getVisualizeFieldSuggestions, - Suggestion, -} from '../editor_frame_service/editor_frame/suggestion_helpers'; +import { getVisualizeFieldSuggestions } from '../editor_frame_service/editor_frame/suggestion_helpers'; import { FramePublicAPI, LensEditContextMapping, LensEditEvent } from '../types'; export const initialState: LensAppState = { @@ -131,7 +129,7 @@ export const initEmpty = createAction( initialContext, }: { newState: Partial; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; }) { return { payload: { layerId: generateId(), newState, initialContext } }; } @@ -411,7 +409,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { }: { payload: { newState: Partial; - initialContext: VisualizeFieldContext | undefined; + initialContext: VisualizeFieldContext | VisualizeEditorContext | undefined; layerId: string; }; } diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts index 8c18a2a6082b5..b0ff49862d9b8 100644 --- a/x-pack/plugins/lens/public/state_management/types.ts +++ b/x-pack/plugins/lens/public/state_management/types.ts @@ -14,7 +14,12 @@ import { Document } from '../persistence'; import { TableInspectorAdapter } from '../editor_frame_service/types'; import { DateRange } from '../../common'; import { LensAppServices } from '../app_plugin/types'; -import { DatasourceMap, VisualizationMap, SharingSavedObjectProps } from '../types'; +import { + DatasourceMap, + VisualizationMap, + SharingSavedObjectProps, + VisualizeEditorContext, +} from '../types'; export interface VisualizationState { activeId: string | null; state: unknown; @@ -60,6 +65,6 @@ export interface LensStoreDeps { lensServices: LensAppServices; datasourceMap: DatasourceMap; visualizationMap: VisualizationMap; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; embeddableEditorIncomingState?: EmbeddableEditorState; } diff --git a/x-pack/plugins/lens/public/trigger_actions/visualize_tsvb_actions.ts b/x-pack/plugins/lens/public/trigger_actions/visualize_tsvb_actions.ts new file mode 100644 index 0000000000000..6694efac7bec7 --- /dev/null +++ b/x-pack/plugins/lens/public/trigger_actions/visualize_tsvb_actions.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { createAction } from '../../../../../src/plugins/ui_actions/public'; +import { ACTION_CONVERT_TO_LENS } from '../../../../../src/plugins/visualizations/public'; +import type { VisualizeEditorContext } from '../types'; +import type { ApplicationStart } from '../../../../../src/core/public'; + +export const visualizeTSVBAction = (application: ApplicationStart) => + createAction<{ [key: string]: VisualizeEditorContext }>({ + type: ACTION_CONVERT_TO_LENS, + id: ACTION_CONVERT_TO_LENS, + getDisplayName: () => + i18n.translate('xpack.lens.visualizeTSVBLegend', { + defaultMessage: 'Visualize TSVB chart', + }), + isCompatible: async () => !!application.capabilities.visualize.show, + execute: async (context: { [key: string]: VisualizeEditorContext }) => { + const table = Object.values(context.layers); + const payload = { + ...context, + layers: table, + isVisualizeAction: true, + }; + application.navigateToApp('lens', { + state: { + type: ACTION_CONVERT_TO_LENS, + payload, + originatingApp: i18n.translate('xpack.lens.TSVBLabel', { + defaultMessage: 'TSVB', + }), + }, + }); + }, + }); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 7cffd7bd88c17..483da14207516 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { Ast } from '@kbn/interpreter'; import type { IconType } from '@elastic/eui/src/components/icon/icon'; import type { CoreSetup, SavedObjectReference } from 'kibana/public'; import type { PaletteOutput } from 'src/plugins/charts/public'; @@ -17,6 +17,7 @@ import type { IInterpreterRenderHandlers, Datatable, } from '../../../../src/plugins/expressions/public'; +import type { VisualizeEditorLayersContext } from '../../../../src/plugins/visualizations/public'; import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; import type { DateRange, LayerType, SortingHint } from '../common'; import type { Query } from '../../../../src/plugins/data/public'; @@ -165,6 +166,33 @@ export interface InitializationOptions { isFullEditor?: boolean; } +interface AxisExtents { + mode: string; + lowerBound?: number; + upperBound?: number; +} + +export interface VisualizeEditorContext { + layers: VisualizeEditorLayersContext[]; + configuration: ChartSettings; + savedObjectId?: string; + embeddableId?: string; + vizEditorOriginatingAppUrl?: string; + originatingApp?: string; + isVisualizeAction: boolean; + type: string; +} + +interface ChartSettings { + fill?: string; + legend?: Record; + gridLinesVisibility?: Record; + extents?: { + yLeftExtent: AxisExtents; + yRightExtent: AxisExtents; + }; +} + /** * Interface for the datasource registry */ @@ -177,7 +205,7 @@ export interface Datasource { initialize: ( state?: P, savedObjectReferences?: SavedObjectReference[], - initialContext?: VisualizeFieldContext, + initialContext?: VisualizeFieldContext | VisualizeEditorContext, options?: InitializationOptions ) => Promise; @@ -247,6 +275,10 @@ export interface Datasource { field: unknown, filterFn: (layerId: string) => boolean ) => Array>; + getDatasourceSuggestionsForVisualizeCharts: ( + state: T, + context: VisualizeEditorLayersContext[] + ) => Array>; getDatasourceSuggestionsForVisualizeField: ( state: T, indexPatternId: string, @@ -529,6 +561,31 @@ interface VisualizationDimensionChangeProps { prevState: T; frame: Pick; } +export interface Suggestion { + visualizationId: string; + datasourceState?: unknown; + datasourceId?: string; + columns: number; + score: number; + title: string; + visualizationState: unknown; + previewExpression?: Ast | string; + previewIcon: IconType; + hide?: boolean; + changeType: TableChangeType; + keptLayerIds: string[]; +} + +interface VisualizationConfigurationFromContextChangeProps { + layerId: string; + prevState: T; + context: VisualizeEditorLayersContext; +} + +interface VisualizationStateFromContextChangeProps { + suggestions: Suggestion[]; + context: VisualizeEditorContext; +} /** * Object passed to `getSuggestions` of a visualization. @@ -745,6 +802,19 @@ export interface Visualization { */ removeDimension: (props: VisualizationDimensionChangeProps) => T; + /** + * Update the configuration for the visualization. This is used to update the state + */ + updateLayersConfigurationFromContext?: ( + props: VisualizationConfigurationFromContextChangeProps + ) => T; + + /** + * Update the visualization state from the context. + */ + getVisualizationSuggestionFromContext?: ( + props: VisualizationStateFromContextChangeProps + ) => Suggestion; /** * Additional editor that gets rendered inside the dimension popover. * This can be used to configure dimension-specific options @@ -892,5 +962,5 @@ export type LensTopNavMenuEntryGenerator = (props: { visualizationState: unknown; query: Query; filters: Filter[]; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; }) => undefined | TopNavMenuData; diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 75e80782c5d38..b59d69bd8cbe6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -17,7 +17,7 @@ import { LensIconChartBarHorizontalStacked } from '../assets/chart_bar_horizonta import { LensIconChartBarHorizontalPercentage } from '../assets/chart_bar_horizontal_percentage'; import { LensIconChartLine } from '../assets/chart_line'; -import type { VisualizationType } from '../types'; +import type { VisualizationType, Suggestion } from '../types'; import type { SeriesType, LegendConfig, @@ -157,3 +157,12 @@ export const visualizationTypes: VisualizationType[] = [ sortPriority: 2, }, ]; + +interface XYStateWithLayers { + [prop: string]: unknown; + layers: XYLayerConfig[]; +} +export interface XYSuggestion extends Suggestion { + datasourceState: XYStateWithLayers; + visualizationState: XYStateWithLayers; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index ff7ad2c0f2d85..51cf15c292647 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -7,12 +7,13 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; -import { Operation } from '../types'; -import type { State } from './types'; +import { Operation, VisualizeEditorContext, Suggestion } from '../types'; +import type { State, XYSuggestion } from './types'; import type { SeriesType, XYLayerConfig } from '../../common/expressions'; import { layerTypes } from '../../common'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { LensIconChartBar } from '../assets/chart_bar'; +import type { VisualizeEditorLayersContext } from '../../../../../src/plugins/visualizations/public'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { Datatable } from 'src/plugins/expressions'; @@ -356,6 +357,243 @@ describe('xy_visualization', () => { }); }); + describe('#updateLayersConfigurationFromContext', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + let context: VisualizeEditorLayersContext; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'd' }, + { columnId: 'a' }, + { columnId: 'b' }, + { columnId: 'c' }, + ]); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + + context = { + chartType: 'area', + axisPosition: 'right', + palette: { + name: 'temperature', + type: 'palette', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + format: 'bytes', + } as VisualizeEditorLayersContext; + }); + + it('sets the context configuration correctly', () => { + const state = xyVisualization?.updateLayersConfigurationFromContext?.({ + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'line', + xAccessor: undefined, + accessors: ['a'], + }, + ], + }, + layerId: 'first', + context, + }); + expect(state?.layers[0]).toHaveProperty('seriesType', 'area'); + expect(state?.layers[0].yConfig).toStrictEqual([ + { + axisMode: 'right', + color: '#68BC00', + forAccessor: 'a', + }, + ]); + + expect(state?.layers[0].palette).toStrictEqual({ + name: 'temperature', + type: 'palette', + }); + }); + }); + + describe('#getVisualizationSuggestionFromContext', () => { + let context: VisualizeEditorContext; + let suggestions: Suggestion[]; + + beforeEach(() => { + suggestions = [ + { + title: 'Average of AvgTicketPrice over timestamp', + score: 0.3333333333333333, + hide: true, + visualizationId: 'lnsXY', + visualizationState: { + legend: { + isVisible: true, + position: 'right', + }, + valueLabels: 'hide', + fittingFunction: 'None', + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: 'e71c3459-ddcf-4a13-94a1-bf91f7b40175', + seriesType: 'bar_stacked', + xAccessor: '911abe51-36ca-42ba-ae4e-bcf3f941f3c1', + accessors: ['0ffeb3fb-86fd-42d1-ab62-5a00b7000a7b'], + layerType: 'data', + }, + ], + }, + keptLayerIds: [], + datasourceState: { + layers: { + 'e71c3459-ddcf-4a13-94a1-bf91f7b40175': { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + columns: { + '911abe51-36ca-42ba-ae4e-bcf3f941f3c1': { + label: 'timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: 'timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + }, + }, + '0ffeb3fb-86fd-42d1-ab62-5a00b7000a7b': { + label: 'Average of AvgTicketPrice', + dataType: 'number', + operationType: 'average', + sourceField: 'AvgTicketPrice', + isBucketed: false, + scale: 'ratio', + }, + }, + columnOrder: [ + '911abe51-36ca-42ba-ae4e-bcf3f941f3c1', + '0ffeb3fb-86fd-42d1-ab62-5a00b7000a7b', + ], + incompleteColumns: {}, + }, + }, + }, + datasourceId: 'indexpattern', + columns: 2, + changeType: 'initial', + }, + ] as unknown as Suggestion[]; + + context = { + layers: [ + { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + timeFieldName: 'order_date', + chartType: 'area', + axisPosition: 'left', + palette: { + type: 'palette', + name: 'default', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + fill: '0.5', + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: true, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + isVisualizeAction: true, + } as VisualizeEditorContext; + }); + + it('updates the visualization state correctly based on the context', () => { + const suggestion = xyVisualization?.getVisualizationSuggestionFromContext?.({ + suggestions, + context, + }) as XYSuggestion; + expect(suggestion?.visualizationState?.fillOpacity).toEqual(0.5); + expect(suggestion?.visualizationState?.yRightExtent).toEqual({ mode: 'full' }); + expect(suggestion?.visualizationState?.legend).toEqual({ + isVisible: true, + maxLines: true, + position: 'right', + shouldTruncate: true, + }); + }); + }); + describe('#removeDimension', () => { let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 47a0f43538eb2..9a84304bcfb34 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -20,8 +20,8 @@ import { getSuggestions } from './xy_suggestions'; import { XyToolbar, DimensionEditor } from './xy_config_panel'; import { LayerHeader } from './xy_config_panel/layer_header'; import type { Visualization, OperationMetadata, VisualizationType, AccessorConfig } from '../types'; -import { State, visualizationTypes } from './types'; -import { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { State, visualizationTypes, XYSuggestion } from './types'; +import { SeriesType, XYLayerConfig, YAxisMode } from '../../common/expressions'; import { LayerType, layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; @@ -527,6 +527,83 @@ export const getXyVisualization = ({ }; }, + updateLayersConfigurationFromContext({ prevState, layerId, context }) { + const { chartType, axisPosition, palette, metrics } = context; + const foundLayer = prevState?.layers.find((l) => l.layerId === layerId); + if (!foundLayer) { + return prevState; + } + const axisMode = axisPosition as YAxisMode; + const yConfig = metrics.map((metric, idx) => { + return { + color: metric.color, + forAccessor: metric.accessor ?? foundLayer.accessors[idx], + ...(axisMode && { axisMode }), + }; + }); + const newLayer = { + ...foundLayer, + ...(chartType && { seriesType: chartType as SeriesType }), + ...(palette && { palette }), + yConfig, + }; + + const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + + return { + ...prevState, + layers: newLayers, + }; + }, + + getVisualizationSuggestionFromContext({ suggestions, context }) { + const visualizationStateLayers = []; + let datasourceStateLayers = {}; + const fillOpacity = context.configuration.fill ? Number(context.configuration.fill) : undefined; + for (let suggestionIdx = 0; suggestionIdx < suggestions.length; suggestionIdx++) { + const currentSuggestion = suggestions[suggestionIdx] as XYSuggestion; + const currentSuggestionsLayers = currentSuggestion.visualizationState.layers; + const contextLayer = context.layers.find( + (layer) => layer.layerId === Object.keys(currentSuggestion.datasourceState.layers)[0] + ); + if (this.updateLayersConfigurationFromContext && contextLayer) { + const updatedSuggestionState = this.updateLayersConfigurationFromContext({ + prevState: currentSuggestion.visualizationState as unknown as State, + layerId: currentSuggestionsLayers[0].layerId as string, + context: contextLayer, + }); + + visualizationStateLayers.push(...updatedSuggestionState.layers); + datasourceStateLayers = { + ...datasourceStateLayers, + ...currentSuggestion.datasourceState.layers, + }; + } + } + let suggestion = suggestions[0] as XYSuggestion; + suggestion = { + ...suggestion, + datasourceState: { + ...suggestion.datasourceState, + layers: { + ...suggestion.datasourceState.layers, + ...datasourceStateLayers, + }, + }, + visualizationState: { + ...suggestion.visualizationState, + fillOpacity, + yRightExtent: context.configuration.extents?.yRightExtent, + yLeftExtent: context.configuration.extents?.yLeftExtent, + legend: context.configuration.legend, + gridlinesVisibilitySettings: context.configuration.gridLinesVisibility, + valuesInLegend: true, + layers: visualizationStateLayers, + }, + }; + return suggestion; + }, + removeDimension({ prevState, layerId, columnId, frame }) { const foundLayer = prevState.layers.find((l) => l.layerId === layerId); if (!foundLayer) { diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 19bd3510c9527..45c53ea18a601 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -76,6 +76,7 @@ export default function ({ getService, loadTestFile, getPageObjects }: FtrProvid loadTestFile(require.resolve('./error_handling')); loadTestFile(require.resolve('./lens_tagging')); loadTestFile(require.resolve('./lens_reporting')); + loadTestFile(require.resolve('./tsvb_open_in_lens')); // has to be last one in the suite because it overrides saved objects loadTestFile(require.resolve('./rollup')); }); diff --git a/x-pack/test/functional/apps/lens/tsvb_open_in_lens.ts b/x-pack/test/functional/apps/lens/tsvb_open_in_lens.ts new file mode 100644 index 0000000000000..0856fbb4ff1ec --- /dev/null +++ b/x-pack/test/functional/apps/lens/tsvb_open_in_lens.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const { visualize, visualBuilder, header, lens, timeToVisualize, dashboard, canvas } = + getPageObjects([ + 'visualBuilder', + 'visualize', + 'header', + 'lens', + 'timeToVisualize', + 'dashboard', + 'canvas', + ]); + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const panelActions = getService('dashboardPanelActions'); + const retry = getService('retry'); + const filterBar = getService('filterBar'); + const queryBar = getService('queryBar'); + + describe('TSVB to Lens', function describeIndexTests() { + before(async () => { + await visualize.initTests(); + }); + + describe('Time Series', () => { + it('should show the "Edit Visualization in Lens" menu item for a count aggregation', async () => { + await visualize.navigateToNewVisualization(); + await visualize.clickVisualBuilder(); + await visualBuilder.checkVisualBuilderIsPresent(); + await visualBuilder.resetPage(); + const isMenuItemVisible = await find.existsByCssSelector( + '[data-test-subj="visualizeEditInLensButton"]' + ); + expect(isMenuItemVisible).to.be(true); + }); + + it('visualizes field to Lens and loads fields to the dimesion editor', async () => { + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + await retry.try(async () => { + const dimensions = await testSubjects.findAll('lns-dimensionTrigger'); + expect(dimensions).to.have.length(2); + expect(await dimensions[0].getVisibleText()).to.be('@timestamp'); + expect(await dimensions[1].getVisibleText()).to.be('Count of records'); + }); + }); + + it('navigates back to TSVB when the Back button is clicked', async () => { + const goBackBtn = await testSubjects.find('lnsApp_goBackToAppButton'); + goBackBtn.click(); + await visualBuilder.checkVisualBuilderIsPresent(); + await retry.try(async () => { + const actualCount = await visualBuilder.getRhythmChartLegendValue(); + expect(actualCount).to.be('56'); + }); + }); + + it('should preserve app filters in lens', async () => { + await filterBar.addFilter('extension', 'is', 'css'); + await header.waitUntilLoadingHasFinished(); + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + + expect(await filterBar.hasFilter('extension', 'css')).to.be(true); + }); + + it('should preserve query in lens', async () => { + const goBackBtn = await testSubjects.find('lnsApp_goBackToAppButton'); + goBackBtn.click(); + await visualBuilder.checkVisualBuilderIsPresent(); + await queryBar.setQuery('machine.os : ios'); + await queryBar.submitQuery(); + await header.waitUntilLoadingHasFinished(); + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + + expect(await queryBar.getQueryString()).to.equal('machine.os : ios'); + }); + }); + + describe('Metric', () => { + beforeEach(async () => { + await visualize.navigateToNewVisualization(); + await visualize.clickVisualBuilder(); + await visualBuilder.checkVisualBuilderIsPresent(); + await visualBuilder.resetPage(); + await visualBuilder.clickMetric(); + await visualBuilder.clickDataTab('metric'); + }); + + it('should hide the "Edit Visualization in Lens" menu item', async () => { + const button = await testSubjects.exists('visualizeEditInLensButton'); + expect(button).to.eql(false); + }); + }); + + describe('Dashboard to TSVB to Lens', () => { + it('should convert a by value TSVB viz to a Lens viz', async () => { + await visualize.navigateToNewVisualization(); + await visualize.clickVisualBuilder(); + await visualBuilder.checkVisualBuilderIsPresent(); + await visualBuilder.resetPage(); + await testSubjects.click('visualizeSaveButton'); + + await timeToVisualize.saveFromModal('My TSVB to Lens viz 1', { + addToDashboard: 'new', + saveToLibrary: false, + }); + + await dashboard.waitForRenderComplete(); + const originalEmbeddableCount = await canvas.getEmbeddableCount(); + await panelActions.openContextMenu(); + await panelActions.clickEdit(); + + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + await retry.try(async () => { + const dimensions = await testSubjects.findAll('lns-dimensionTrigger'); + expect(await dimensions[1].getVisibleText()).to.be('Count of records'); + }); + + await lens.saveAndReturn(); + await retry.try(async () => { + const embeddableCount = await canvas.getEmbeddableCount(); + expect(embeddableCount).to.eql(originalEmbeddableCount); + }); + await panelActions.removePanel(); + }); + + it('should convert a by reference TSVB viz to a Lens viz', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickVisType('metrics'); + await testSubjects.click('visualizesaveAndReturnButton'); + // save it to library + const originalPanel = await testSubjects.find('embeddablePanelHeading-'); + await panelActions.saveToLibrary('My TSVB to Lens viz 2', originalPanel); + + await dashboard.waitForRenderComplete(); + const originalEmbeddableCount = await canvas.getEmbeddableCount(); + await panelActions.openContextMenu(); + await panelActions.clickEdit(); + + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + await retry.try(async () => { + const dimensions = await testSubjects.findAll('lns-dimensionTrigger'); + expect(await dimensions[1].getVisibleText()).to.be('Count of records'); + }); + + await lens.saveAndReturn(); + await retry.try(async () => { + const embeddableCount = await canvas.getEmbeddableCount(); + expect(embeddableCount).to.eql(originalEmbeddableCount); + }); + + const panel = await testSubjects.find(`embeddablePanelHeading-`); + const descendants = await testSubjects.findAllDescendant( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + panel + ); + expect(descendants.length).to.equal(0); + + await panelActions.removePanel(); + }); + }); + }); +}