mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[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:
parent
1f4a7d4d72
commit
d364f237c5
72 changed files with 4476 additions and 135 deletions
|
@ -0,0 +1,11 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) > [buttonColor](./kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md)
|
||||
|
||||
## AppLeaveConfirmAction.buttonColor property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
buttonColor?: ButtonColor;
|
||||
```
|
|
@ -0,0 +1,11 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) > [confirmButtonText](./kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md)
|
||||
|
||||
## AppLeaveConfirmAction.confirmButtonText property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
confirmButtonText?: string;
|
||||
```
|
|
@ -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) | () => 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 | |
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 });
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
165
src/plugins/vis_types/timeseries/public/trigger_action/index.ts
Normal file
165
src/plugins/vis_types/timeseries/public/trigger_action/index.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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)))' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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})`;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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(), {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
18
src/plugins/visualizations/public/triggers/index.ts
Normal file
18
src/plugins/visualizations/public/triggers/index.ts
Normal 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';
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = {};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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];
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
|
|
183
x-pack/test/functional/apps/lens/tsvb_open_in_lens.ts
Normal file
183
x-pack/test/functional/apps/lens/tsvb_open_in_lens.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue