[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>
This commit is contained in:
Stratoula Kalafateli 2022-02-14 19:10:17 +02:00 committed by GitHub
parent 1f4a7d4d72
commit d364f237c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 4476 additions and 135 deletions

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) &gt; [buttonColor](./kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md)
## AppLeaveConfirmAction.buttonColor property
<b>Signature:</b>
```typescript
buttonColor?: ButtonColor;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) &gt; [confirmButtonText](./kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md)
## AppLeaveConfirmAction.confirmButtonText property
<b>Signature:</b>
```typescript
confirmButtonText?: string;
```

View file

@ -18,7 +18,9 @@ export interface AppLeaveConfirmAction
| Property | Type | Description |
| --- | --- | --- |
| [buttonColor?](./kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md) | ButtonColor | <i>(Optional)</i> |
| [callback?](./kibana-plugin-core-public.appleaveconfirmaction.callback.md) | () =&gt; void | <i>(Optional)</i> |
| [confirmButtonText?](./kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md) | string | <i>(Optional)</i> |
| [text](./kibana-plugin-core-public.appleaveconfirmaction.text.md) | string | |
| [title?](./kibana-plugin-core-public.appleaveconfirmaction.title.md) | string | <i>(Optional)</i> |
| [type](./kibana-plugin-core-public.appleaveconfirmaction.type.md) | AppLeaveActionType.confirm | |

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<>
<EuiBetaBadge className="kbnTopNavMenu__betaBadgeItem" {...props.badge} size="s" />
{upperFirst(props.label || props.id!)}
</>
);
} else {
return upperFirst(props.label || props.id!);
}
}
function handleClick(e: MouseEvent<HTMLButtonElement>) {
if (isDisabled()) return;
props.run(e.currentTarget);
@ -39,11 +52,11 @@ export function TopNavMenuItem(props: TopNavMenuData) {
const btn = props.emphasize ? (
<EuiButton size="s" {...commonButtonProps} fill>
{upperFirst(props.label || props.id!)}
{getButtonContainer()}
</EuiButton>
) : (
<EuiHeaderLink size="s" color="primary" {...commonButtonProps}>
{upperFirst(props.label || props.id!)}
{getButtonContainer()}
</EuiHeaderLink>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<NavigateToLensContext | null> => {
// 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,
},
};
};

View file

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

View file

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

View file

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

View file

@ -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<VisualizeEmbeddableFactory>;
export type VisualizeEmbeddableContract = PublicContract<VisualizeEmbeddable>;
@ -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';

View file

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

View file

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

View file

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

View file

@ -27,6 +27,7 @@ export class BaseVisType<TVisParams = VisParams> {
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<TVisParams = VisParams> {
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;

View file

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

View file

@ -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<string, unknown>;
isFullReference: boolean;
color?: string;
accessor?: string;
}
export interface VisualizeEditorLayersContext {
indexPatternId: string;
splitWithDateHistogram?: boolean;
timeFieldName?: string;
chartType?: string;
axisPosition?: string;
termsParams?: Record<string, unknown>;
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<TVisParams> {
* 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<NavigateToLensContext | null> | undefined;
/**
* Some visualizations are created without SearchSource and may change the used indexes during the visualization configuration.

View file

@ -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<OverlayRef>();
const [editInLensConfig, setEditInLensConfig] = useState<NavigateToLensContext | null>();
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<IndexPattern[]>(
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(() => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<LensAppProps>,
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: {

View file

@ -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<Document | undefined>(undefined);
const [initialDocFromContext, setInitialDocFromContext] = useState<Document | undefined>(
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 (
<>
<div className="lnsApp" data-test-subj="lnsApp">
@ -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) && (
<MemoizedEditorFrameWrapper
@ -352,6 +429,30 @@ export function App({
}
/>
)}
{isGoBackToVizEditorModalVisible && (
<EuiConfirmModal
maxWidth={600}
title={i18n.translate('xpack.lens.app.goBackModalTitle', {
defaultMessage: 'Discard changes?',
})}
onCancel={() => 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 },
})}
</EuiConfirmModal>
)}
</>
);
}

View file

@ -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,
dashboardFeatureFlag.allowByValueEmbeddables,
fieldFormats.deserialize,
getIsByValueMode,
initialInput,
isLinkedToOriginatingApp,
dashboardFeatureFlag.allowByValueEmbeddables,
initialInput,
initialContextIsEmbedded,
isSaveable,
title,
onAppLeave,
redirectToOrigin,
runSave,
savingToDashboardPermitted,
activeData,
getIsByValueMode,
savingToLibraryPermitted,
setIsSaveModalVisible,
uiSettings,
unsavedTitle,
lensInspector,
savingToDashboardPermitted,
contextOriginatingApp,
additionalMenuEntries,
lensInspector,
title,
unsavedTitle,
uiSettings,
fieldFormats.deserialize,
onAppLeave,
runSave,
attributeService,
setIsSaveModalVisible,
goBackToOriginatingApp,
redirectToOrigin,
]);
const onQuerySubmitWrapped = useCallback(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, Datatable>;
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];
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<IndexPatternPrivateState> {
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];

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>;
initialParams?: { params: Record<string, unknown> }; // 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<C extends GenericIndexPatternColumn>({
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<string, GenericIndexPatternColumn>,
};
}
export function updateColumnParam<C extends GenericIndexPatternColumn>({
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;
}

View file

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

View file

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

View file

@ -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<LensAppState>;
initialContext?: VisualizeFieldContext;
initialContext?: VisualizeFieldContext | VisualizeEditorContext;
}) {
return { payload: { layerId: generateId(), newState, initialContext } };
}
@ -411,7 +409,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
}: {
payload: {
newState: Partial<LensAppState>;
initialContext: VisualizeFieldContext | undefined;
initialContext: VisualizeFieldContext | VisualizeEditorContext | undefined;
layerId: string;
};
}

View file

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

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; 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',
}),
},
});
},
});

View file

@ -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<string, boolean | string>;
gridLinesVisibility?: Record<string, boolean>;
extents?: {
yLeftExtent: AxisExtents;
yRightExtent: AxisExtents;
};
}
/**
* Interface for the datasource registry
*/
@ -177,7 +205,7 @@ export interface Datasource<T = unknown, P = unknown> {
initialize: (
state?: P,
savedObjectReferences?: SavedObjectReference[],
initialContext?: VisualizeFieldContext,
initialContext?: VisualizeFieldContext | VisualizeEditorContext,
options?: InitializationOptions
) => Promise<T>;
@ -247,6 +275,10 @@ export interface Datasource<T = unknown, P = unknown> {
field: unknown,
filterFn: (layerId: string) => boolean
) => Array<DatasourceSuggestion<T>>;
getDatasourceSuggestionsForVisualizeCharts: (
state: T,
context: VisualizeEditorLayersContext[]
) => Array<DatasourceSuggestion<T>>;
getDatasourceSuggestionsForVisualizeField: (
state: T,
indexPatternId: string,
@ -529,6 +561,31 @@ interface VisualizationDimensionChangeProps<T> {
prevState: T;
frame: Pick<FramePublicAPI, 'datasourceLayers' | 'activeData'>;
}
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<T> {
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<T = unknown> {
*/
removeDimension: (props: VisualizationDimensionChangeProps<T>) => T;
/**
* Update the configuration for the visualization. This is used to update the state
*/
updateLayersConfigurationFromContext?: (
props: VisualizationConfigurationFromContextChangeProps<T>
) => 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;

View file

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

View file

@ -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<typeof createMockDatasource>;
let frame: ReturnType<typeof createMockFramePublicAPI>;
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<typeof createMockDatasource>;
let frame: ReturnType<typeof createMockFramePublicAPI>;

View file

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

View file

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

View file

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