[Lens] Query based annotations (#138753)

* ⚗️ Initial code for query based annotations

* 🐛 Solved more conflicts

* ⚗️ More scaffolding layout

* ⚗️ Initial indexpatetrn move into frame

* ⚗️ Make field selection work

* 🚧 Fixed almost all dataViews occurrencies, but changeIndexPattern

* 🚧 More work on change index pattern

* Move lens dataViews state into main state

* 🔥 Remove some old cruft from the code

* 🐛 Fix dataViews layer change

* 🐛 Fix datasourceLayers refs

* 🔥 Remove more old cruft

* 🐛 Fix bug when loading SO

* 🐛 Fix initial existence flag

* 🏷️ Fix type issues

* 🏷️ Fix types and tests

* 🏷️ Fix types issues

*  Fix more tests

*  Fix with new dataViews structure

*  Fix more test mocks

*  More tests fixed

* 🔥 Removed unused prop

*  Down to single broken test suite

* 🏷️ Fix type issue

* 👌 Integrate selector feedback

*  Fix remaining unit tests

* 🏷️ fix type issues

* 🐛 Fix bug when creating dataview in place

*  Update with latest dataview state + fix dataviews picker for annotations

* 🐛 Fix edit + remove field flow

* Update x-pack/plugins/lens/public/visualizations/xy/types.ts

* 📸 Fix snapshot

* 🐛 Fix the dataViews switch bug

* 🔥 remove old cruft

* ♻️ Revert removal from dataviews state branch

* ♻️ Load all at once

* 🔧 working on persistent state + fix new layer bug

* 🔥 remove unused stuff

* 🏷️ Fix some typings

* 🔧 Fix expression issue

*  Add service unit tests

* 👌 Integrated feedback

*  Add migration code for manual annotations

* 🏷️ Fix type issue

*  Add some other unit test

* 🏷️ Fix more type issues

* 🐛 Fix importing issue

* ♻️ Make range default color dependant on opint one

* 🐛 Fix duplicate fields selection in tooltip section

*  Add more unit tests

*  Fix broken test

* 🏷️ Mute ts error for now

*  Fix tests

* 🔥 Reduce plugin weight

* 🐛 prevent layout shift on panel open

* 🐛 Fix extract + inject visualization references

* 🏷️ fix type issues

*  Add dataview reference migration for annotations

* 🔧 Add migration to embedadble

* 🏷️ Fix type export

* 🐛 Fix more conflicts with main

*  Fix tests

* 🏷️ Make textField optional

* ♻️ Refactor query input to be a shared component

* 🐛 Fix missing import

* 🐛 fix more import issues

* 🔥 remove duplicate code

* 🐛 Fix dataView switch bug

* 🏷️ Fix type issue

* annotations with fetching_event_annotations

* portal for kql input fix

* timeField goes for default if not filled

* limit changes

* handle ad-hoc data view references correctly

* fix types

* adjust tests to datatable format (remove isHidden tests as it's filtered before)

* small refactors

* fix loading on dashboard

* empty is invalid (?) tbd

* new tooltip

* emptyDatatable

* ♻️ Flip field + query inputs

* 🏷️ Fix type issue

*  Add field validation for text and tooltip fields

* tooltip for single annotation

* fix tests

* fix for non--timefilter dataview

* fix annotations test - the cause was that we now don't display label for aggregated annotations ever

* use eui elements

* newline problem solved

*  Add more error tests

* 👌 Rename migration state version type

* fix types for expression chart

* 🐛 Fix i18n id

* 🏷️ Fix type issue

* fix hidden all annotations

*  Fix tests after ishidden removal

* 🐛 Revert references migration to an in app solution

Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Marta Bondyra <4283304+mbondyra@users.noreply.github.com>
Co-authored-by: Marta Bondyra <marta.bondyra@elastic.co>
This commit is contained in:
Marco Liberati 2022-09-08 18:23:19 +02:00 committed by GitHub
parent e7a8c875e7
commit 1a1159b0c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
118 changed files with 3357 additions and 961 deletions

View file

@ -130,7 +130,7 @@ pageLoadAssetSize:
eventAnnotation: 19334
screenshotting: 22870
synthetics: 40958
expressionXY: 36000
expressionXY: 38000
kibanaUsageCollection: 16463
kubernetesSecurity: 77234
threatIntelligence: 29195

View file

@ -132,6 +132,15 @@ export const createArgsWithLayers = (
},
],
layers: Array.isArray(layers) ? layers : [layers],
annotations: {
type: 'event_annotations_result',
layers: [],
datatable: {
type: 'datatable',
columns: [],
rows: [],
},
},
});
export function sampleArgs() {

View file

@ -0,0 +1,57 @@
/*
* 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 { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { ExtendedAnnotationLayerConfigResult } from '../types';
import { strings } from '../i18n';
import { EXTENDED_ANNOTATION_LAYER } from '../constants';
export interface EventAnnotationResultArgs {
layers?: ExtendedAnnotationLayerConfigResult[];
datatable: Datatable;
}
export interface EventAnnotationResultResult {
type: 'event_annotations_result';
layers: ExtendedAnnotationLayerConfigResult[];
datatable: Datatable;
}
export function eventAnnotationsResult(): ExpressionFunctionDefinition<
'event_annotations_result',
null,
EventAnnotationResultArgs,
EventAnnotationResultResult
> {
return {
name: 'event_annotations_result',
aliases: [],
type: 'event_annotations_result',
inputTypes: ['null'],
help: strings.getAnnotationLayerFnHelp(),
args: {
layers: {
types: [EXTENDED_ANNOTATION_LAYER],
multi: true,
help: strings.getAnnotationLayerFnHelp(),
},
datatable: {
types: ['datatable'],
help: strings.getAnnotationLayerFnHelp(),
},
},
fn: (input, args) => {
return {
...args,
type: 'event_annotations_result',
layers: args.layers || [],
datatable: args.datatable || {},
};
},
};
}

View file

@ -6,14 +6,14 @@
* Side Public License, v 1.
*/
import type { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { LayerTypes, EXTENDED_ANNOTATION_LAYER } from '../constants';
import { ExtendedAnnotationLayerConfigResult, ExtendedAnnotationLayerArgs } from '../types';
import { strings } from '../i18n';
export function extendedAnnotationLayerFunction(): ExpressionFunctionDefinition<
typeof EXTENDED_ANNOTATION_LAYER,
Datatable,
null,
ExtendedAnnotationLayerArgs,
ExtendedAnnotationLayerConfigResult
> {
@ -21,7 +21,7 @@ export function extendedAnnotationLayerFunction(): ExpressionFunctionDefinition<
name: EXTENDED_ANNOTATION_LAYER,
aliases: [],
type: EXTENDED_ANNOTATION_LAYER,
inputTypes: ['datatable'],
inputTypes: ['null'],
help: strings.getAnnotationLayerFnHelp(),
args: {
simpleView: {

View file

@ -12,7 +12,6 @@ import {
EXTENDED_DATA_LAYER,
REFERENCE_LINE_LAYER,
LAYERED_XY_VIS,
EXTENDED_ANNOTATION_LAYER,
REFERENCE_LINE,
} from '../constants';
import { commonXYArgs } from './common_xy_args';
@ -26,12 +25,18 @@ export const layeredXyVisFunction: LayeredXyVisFn = {
args: {
...commonXYArgs,
layers: {
types: [EXTENDED_DATA_LAYER, REFERENCE_LINE_LAYER, EXTENDED_ANNOTATION_LAYER, REFERENCE_LINE],
types: [EXTENDED_DATA_LAYER, REFERENCE_LINE_LAYER, REFERENCE_LINE],
help: i18n.translate('expressionXY.layeredXyVis.layers.help', {
defaultMessage: 'Layers of visual series',
}),
multi: true,
},
annotations: {
types: ['event_annotations_result'],
help: i18n.translate('expressionXY.layeredXyVis.annotations.help', {
defaultMessage: 'Annotations',
}),
},
splitColumnAccessor: {
types: ['vis_dimension', 'string'],
help: strings.getSplitColumnAccessorHelp(),

View file

@ -18,7 +18,7 @@ describe('xyVis', () => {
const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer;
const result = await xyVisFunction.fn(
data,
{ ...rest, ...restLayerArgs, referenceLines: [], annotationLayers: [] },
{ ...rest, ...restLayerArgs, referenceLines: [] },
createMockExecutionContext()
);
@ -53,7 +53,6 @@ describe('xyVis', () => {
...{ ...sampleLayer, markSizeAccessor: 'b' },
markSizeRatio: 0,
referenceLines: [],
annotationLayers: [],
},
createMockExecutionContext()
)
@ -67,7 +66,6 @@ describe('xyVis', () => {
...{ ...sampleLayer, markSizeAccessor: 'b' },
markSizeRatio: 101,
referenceLines: [],
annotationLayers: [],
},
createMockExecutionContext()
)
@ -86,7 +84,6 @@ describe('xyVis', () => {
...restLayerArgs,
minTimeBarInterval: '1q',
referenceLines: [],
annotationLayers: [],
},
createMockExecutionContext()
)
@ -105,7 +102,6 @@ describe('xyVis', () => {
...restLayerArgs,
minTimeBarInterval: '1h',
referenceLines: [],
annotationLayers: [],
},
createMockExecutionContext()
)
@ -124,7 +120,6 @@ describe('xyVis', () => {
...restLayerArgs,
addTimeMarker: true,
referenceLines: [],
annotationLayers: [],
},
createMockExecutionContext()
)
@ -144,7 +139,7 @@ describe('xyVis', () => {
...rest,
...restLayerArgs,
referenceLines: [],
annotationLayers: [],
splitRowAccessor,
},
createMockExecutionContext()
@ -165,7 +160,7 @@ describe('xyVis', () => {
...rest,
...restLayerArgs,
referenceLines: [],
annotationLayers: [],
splitColumnAccessor,
},
createMockExecutionContext()
@ -185,7 +180,7 @@ describe('xyVis', () => {
...rest,
...restLayerArgs,
referenceLines: [],
annotationLayers: [],
markSizeRatio: 5,
},
createMockExecutionContext()
@ -207,7 +202,7 @@ describe('xyVis', () => {
...rest,
...restLayerArgs,
referenceLines: [],
annotationLayers: [],
seriesType: 'bar',
showLines: true,
},
@ -230,7 +225,7 @@ describe('xyVis', () => {
...rest,
...restLayerArgs,
referenceLines: [],
annotationLayers: [],
isHistogram: true,
xScaleType: 'time',
xAxisConfig: {
@ -257,7 +252,7 @@ describe('xyVis', () => {
...rest,
...restLayerArgs,
referenceLines: [],
annotationLayers: [],
xAxisConfig: {
type: 'xAxisConfig',
extent: {
@ -287,7 +282,7 @@ describe('xyVis', () => {
...rest,
...restLayerArgs,
referenceLines: [],
annotationLayers: [],
xAxisConfig: {
type: 'xAxisConfig',
extent: { type: 'axisExtentConfig', mode: 'dataBounds' },
@ -308,7 +303,7 @@ describe('xyVis', () => {
...rest,
...restLayerArgs,
referenceLines: [],
annotationLayers: [],
isHistogram: true,
xAxisConfig: {
type: 'xAxisConfig',

View file

@ -7,7 +7,7 @@
*/
import { XyVisFn } from '../types';
import { XY_VIS, REFERENCE_LINE, ANNOTATION_LAYER } from '../constants';
import { XY_VIS, REFERENCE_LINE } from '../constants';
import { strings } from '../i18n';
import { commonXYArgs } from './common_xy_args';
import { commonDataLayerArgs } from './common_data_layer_args';
@ -39,11 +39,6 @@ export const xyVisFunction: XyVisFn = {
help: strings.getReferenceLinesHelp(),
multi: true,
},
annotationLayers: {
types: [ANNOTATION_LAYER],
help: strings.getAnnotationLayerHelp(),
multi: true,
},
splitColumnAccessor: {
types: ['vis_dimension', 'string'],
help: strings.getSplitColumnAccessorHelp(),

View file

@ -63,7 +63,6 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => {
const {
referenceLines = [],
annotationLayers = [],
// data_layer args
seriesType,
accessors,
@ -101,7 +100,6 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => {
const layers: XYLayerConfig[] = [
...appendLayerIds(dataLayers, 'dataLayers'),
...appendLayerIds(referenceLines, 'referenceLines'),
...appendLayerIds(annotationLayers, 'annotationLayers'),
];
logDatatable(data, layers, handlers, args.splitColumnAccessor, args.splitRowAccessor);

View file

@ -34,7 +34,7 @@ export type {
DataLayerConfig,
FittingFunction,
AxisExtentConfig,
CollectiveConfig,
MergedAnnotation,
LegendConfigResult,
AxesSettingsConfig,
XAxisConfigResult,

View file

@ -215,7 +215,6 @@ export interface XYArgs extends DataLayerArgs {
emphasizeFitting?: boolean;
valueLabels: ValueLabelMode;
referenceLines: ReferenceLineConfigResult[];
annotationLayers: AnnotationLayerConfigResult[];
fittingFunction?: FittingFunction;
fillOpacity?: number;
hideEndzones?: boolean;
@ -233,12 +232,21 @@ export interface XYArgs extends DataLayerArgs {
showTooltip: boolean;
}
export interface ExpressionAnnotationsLayers {
layers: AnnotationLayerConfigResult[];
datatable: Datatable;
}
export type ExpressionAnnotationResult = ExpressionAnnotationsLayers & {
type: 'event_annotations_result';
};
export interface LayeredXYArgs {
legend: LegendConfigResult;
endValue?: EndValue;
emphasizeFitting?: boolean;
valueLabels: ValueLabelMode;
layers?: XYExtendedLayerConfigResult[];
annotations?: ExpressionAnnotationResult;
fittingFunction?: FittingFunction;
fillOpacity?: number;
hideEndzones?: boolean;
@ -279,6 +287,7 @@ export interface XYProps {
orderBucketsBySum?: boolean;
showTooltip: boolean;
singleTable?: boolean;
annotations?: ExpressionAnnotationResult;
}
export interface AnnotationLayerArgs {
@ -326,7 +335,6 @@ export type XYExtendedLayerConfig =
export type XYExtendedLayerConfigResult =
| ExtendedDataLayerConfigResult
| ReferenceLineLayerConfigResult
| ExtendedAnnotationLayerConfigResult
| ReferenceLineConfigResult;
export interface ExtendedReferenceLineDecorationConfig extends ReferenceLineArgs {

View file

@ -24,9 +24,10 @@ export interface XYRender {
value: XYChartProps;
}
export interface CollectiveConfig extends Omit<ManualPointEventAnnotationArgs, 'icon'> {
export interface MergedAnnotation extends Omit<ManualPointEventAnnotationArgs, 'icon'> {
timebucket: number;
position: 'bottom';
icon?: AvailableAnnotationIcon | string;
customTooltipDetails?: AnnotationTooltipFormatter | undefined;
customTooltipDetails: AnnotationTooltipFormatter;
isGrouped: boolean;
}

View file

@ -2,6 +2,7 @@
exports[`XYChart component annotations should render basic line annotation 1`] = `
<LineAnnotation
customTooltipDetails={[Function]}
dataValues={
Array [
Object {
@ -18,8 +19,10 @@ exports[`XYChart component annotations should render basic line annotation 1`] =
<Marker
config={
Object {
"customTooltipDetails": [Function],
"icon": "triangle",
"id": "annotation",
"isGrouped": false,
"label": "Annotation",
"position": "bottom",
"time": "2022-03-18T08:25:17.140Z",
@ -38,6 +41,7 @@ exports[`XYChart component annotations should render basic line annotation 1`] =
/>
}
markerPosition="top"
placement="bottom"
style={
Object {
"line": Object {
@ -101,9 +105,9 @@ exports[`XYChart component annotations should render grouped line annotations pr
dataValues={
Array [
Object {
"dataValue": 1647591900025,
"dataValue": 1647591917125,
"details": "Event 1",
"header": 1647591900000,
"header": 1647591917100,
},
]
}
@ -118,19 +122,19 @@ exports[`XYChart component annotations should render grouped line annotations pr
"customTooltipDetails": [Function],
"icon": "3",
"id": "event1",
"isGrouped": true,
"label": "Event 1",
"lineStyle": "dashed",
"lineWidth": 3,
"position": "bottom",
"textVisibility": undefined,
"time": "2022-03-18T08:25:00.000Z",
"timebucket": 1647591900000,
"timebucket": 1647591917100,
"type": "point",
}
}
hasReducedPadding={true}
isHorizontal={true}
label="Event 1"
/>
}
markerBody={
@ -139,6 +143,7 @@ exports[`XYChart component annotations should render grouped line annotations pr
/>
}
markerPosition="top"
placement="bottom"
style={
Object {
"line": Object {
@ -161,9 +166,9 @@ exports[`XYChart component annotations should render grouped line annotations wi
dataValues={
Array [
Object {
"dataValue": 1647591900025,
"dataValue": 1647591917125,
"details": "Event 1",
"header": 1647591900000,
"header": 1647591917100,
},
]
}
@ -178,19 +183,19 @@ exports[`XYChart component annotations should render grouped line annotations wi
"customTooltipDetails": [Function],
"icon": "2",
"id": "event1",
"isGrouped": true,
"label": "Event 1",
"lineStyle": "solid",
"lineWidth": 1,
"position": "bottom",
"textVisibility": undefined,
"time": "2022-03-18T08:25:00.000Z",
"timebucket": 1647591900000,
"timebucket": 1647591917100,
"type": "point",
}
}
hasReducedPadding={true}
isHorizontal={true}
label="Event 1"
/>
}
markerBody={
@ -199,6 +204,7 @@ exports[`XYChart component annotations should render grouped line annotations wi
/>
}
markerPosition="top"
placement="bottom"
style={
Object {
"line": Object {
@ -214,6 +220,7 @@ exports[`XYChart component annotations should render grouped line annotations wi
exports[`XYChart component annotations should render simplified annotations when simpleView is true 1`] = `
<LineAnnotation
customTooltipDetails={[Function]}
dataValues={
Array [
Object {
@ -227,6 +234,7 @@ exports[`XYChart component annotations should render simplified annotations when
id="annotation_2022_03_18_t_08_25_17_140_z"
key="annotation_2022_03_18_t_08_25_17_140_z"
markerPosition="top"
placement="bottom"
style={
Object {
"line": Object {

View file

@ -10,7 +10,7 @@ import './annotations.scss';
import './reference_lines/reference_lines.scss';
import React from 'react';
import { snakeCase } from 'lodash';
import { groupBy, snakeCase } from 'lodash';
import {
AnnotationDomainType,
AnnotationTooltipFormatter,
@ -19,24 +19,33 @@ import {
RectAnnotation,
} from '@elastic/charts';
import moment from 'moment';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import type {
EventAnnotationOutput,
ManualPointEventAnnotationArgs,
ManualRangeEventAnnotationRow,
} from '@kbn/event-annotation-plugin/common';
import type { FieldFormat } from '@kbn/field-formats-plugin/common';
import type { FieldFormat, FormatFactory } from '@kbn/field-formats-plugin/common';
import {
defaultAnnotationColor,
defaultAnnotationRangeColor,
} from '@kbn/event-annotation-plugin/public';
import { Datatable, DatatableRow } from '@kbn/expressions-plugin/common';
import { ManualPointEventAnnotationRow } from '@kbn/event-annotation-plugin/common/manual_event_annotation/types';
import type { CollectiveConfig } from '../../common';
import { Datatable, DatatableColumn, DatatableRow } from '@kbn/expressions-plugin/common';
import { PointEventAnnotationRow } from '@kbn/event-annotation-plugin/common/manual_event_annotation/types';
import { FormattedMessage } from '@kbn/i18n-react';
import type { MergedAnnotation } from '../../common';
import { AnnotationIcon, hasIcon, Marker, MarkerBody } from '../helpers';
import { mapVerticalToHorizontalPlacement, LINES_MARKER_SIZE } from '../helpers';
export interface AnnotationsProps {
groupedLineAnnotations: CollectiveConfig[];
groupedLineAnnotations: MergedAnnotation[];
rangeAnnotations: ManualRangeEventAnnotationRow[];
formatter?: FieldFormat;
isHorizontal: boolean;
@ -47,27 +56,126 @@ export interface AnnotationsProps {
outsideDimension: number;
}
const TooltipAnnotationHeader = ({
row: { label, color, icon },
}: {
row: PointEventAnnotationRow;
}) => (
<div className="echTooltip__item--container" key={snakeCase(label)}>
<EuiFlexGroup className="echTooltip__label" gutterSize="xs">
{hasIcon(icon) && (
<EuiFlexItem grow={false}>
<AnnotationIcon type={icon} color={color} />
</EuiFlexItem>
)}
<EuiFlexItem> {label}</EuiFlexItem>
</EuiFlexGroup>
</div>
);
const TooltipAnnotationDetails = ({
row,
extraFields,
isGrouped,
}: {
row: PointEventAnnotationRow;
extraFields: Array<{
key: string;
name: string;
formatter: FieldFormat | undefined;
}>;
isGrouped?: boolean;
}) => {
return (
<div className="echTooltip__item--container">
<span className="echTooltip__value">
{isGrouped && <div>{moment(row.time).format('YYYY-MM-DD, hh:mm:ss')}</div>}
<div className="xyAnnotationTooltip__extraFields">
{extraFields.map((field) => (
<div>
{field.name}:{' '}
{field.formatter ? field.formatter.convert(row[field.key]) : row[field.key]}
</div>
))}
</div>
</span>
</div>
);
};
const getExtraFields = (
row: PointEventAnnotationRow,
formatFactory: FormatFactory,
columns: DatatableColumn[] | undefined
) => {
return Object.keys(row)
.filter((key) => key.startsWith('field:'))
.map((key) => {
const columnFormatter = columns?.find((c) => c.id === key)?.meta?.params;
return {
key,
name: key.replace('field:', ''),
formatter: columnFormatter && formatFactory(columnFormatter),
};
});
};
const createCustomTooltipDetails =
(
config: ManualPointEventAnnotationArgs[],
formatter?: FieldFormat
): AnnotationTooltipFormatter | undefined =>
rows: PointEventAnnotationRow[],
formatFactory: FormatFactory,
columns: DatatableColumn[] | undefined
): AnnotationTooltipFormatter =>
() => {
const groupedConfigs = groupBy(rows, 'id');
const lastElement = rows[rows.length - 1];
return (
<div key={config[0].time}>
{config.map(({ icon, label, time, color }) => (
<div className="echTooltip__item--container" key={snakeCase(label)}>
<EuiFlexGroup className="echTooltip__label" gutterSize="xs">
{hasIcon(icon) && (
<EuiFlexItem grow={false}>
<AnnotationIcon type={icon} color={color} />
</EuiFlexItem>
)}
<EuiFlexItem> {label}</EuiFlexItem>
</EuiFlexGroup>
<span className="echTooltip__value"> {formatter?.convert(time) || String(time)}</span>
<div key={rows[0].time} className="xyAnnotationTooltip">
{Object.values(groupedConfigs).map((group) => {
const firstElement = group[0];
const extraFields = getExtraFields(firstElement, formatFactory, columns);
return (
<div className="xyAnnotationTooltip__group">
<TooltipAnnotationHeader row={firstElement} />
<EuiPanel
color="subdued"
hasShadow={false}
paddingSize="xs"
borderRadius="none"
hasBorder={true}
>
{group.map((row, index) => (
<>
{index > 0 && (
<>
<EuiSpacer size="xs" />
<EuiHorizontalRule margin="none" />
<EuiSpacer size="xs" />
</>
)}
<TooltipAnnotationDetails
key={snakeCase(row.time)}
isGrouped={rows.length > 1}
row={row}
extraFields={extraFields}
/>
</>
))}
</EuiPanel>
</div>
);
})}
{lastElement.skippedCount && (
<div className="echTooltip__value">
<FormattedMessage
id="expressionXY.annotations.skippedCount"
defaultMessage="... +{value} more"
values={{ value: lastElement.skippedCount }}
/>
</div>
))}
)}
</div>
);
};
@ -109,10 +217,12 @@ export const OUTSIDE_RECT_ANNOTATION_WIDTH = 8;
export const OUTSIDE_RECT_ANNOTATION_WIDTH_SUGGESTION = 2;
export const getAnnotationsGroupedByInterval = (
annotations: ManualPointEventAnnotationRow[],
formatter?: FieldFormat
annotations: PointEventAnnotationRow[],
configs: EventAnnotationOutput[] | undefined,
columns: DatatableColumn[] | undefined,
formatFactory: FormatFactory
) => {
const visibleGroupedConfigs = annotations.reduce<Record<string, ManualPointEventAnnotationRow[]>>(
const visibleGroupedConfigs = annotations.reduce<Record<string, PointEventAnnotationRow[]>>(
(acc, current) => {
const timebucket = moment(current.timebucket).valueOf();
return {
@ -122,24 +232,36 @@ export const getAnnotationsGroupedByInterval = (
},
{}
);
let collectiveConfig: CollectiveConfig;
return Object.entries(visibleGroupedConfigs).map(([timebucket, configArr]) => {
collectiveConfig = {
...configArr[0],
icon: configArr[0].icon || 'triangle',
return Object.entries(visibleGroupedConfigs).map(([timebucket, rowsPerBucket]) => {
const firstRow = rowsPerBucket[0];
const config = configs?.find((c) => c.id === firstRow.id);
const textField = config && 'textField' in config && config?.textField;
const columnFormatter = columns?.find((c) => c.id === `field:${textField}`)?.meta?.params;
const formatter = columnFormatter && formatFactory(columnFormatter);
const label =
textField && formatter && `field:${textField}` in firstRow
? formatter.convert(firstRow[`field:${textField}`])
: firstRow.label;
const mergedAnnotation: MergedAnnotation = {
...firstRow,
label,
icon: firstRow.icon || 'triangle',
timebucket: Number(timebucket),
position: 'bottom',
customTooltipDetails: createCustomTooltipDetails(rowsPerBucket, formatFactory, columns),
isGrouped: false,
};
if (configArr.length > 1) {
const commonStyles = getCommonStyles(configArr);
collectiveConfig = {
...collectiveConfig,
if (rowsPerBucket.length > 1) {
const commonStyles = getCommonStyles(rowsPerBucket);
return {
...mergedAnnotation,
...commonStyles,
icon: String(configArr.length),
customTooltipDetails: createCustomTooltipDetails(configArr, formatter),
isGrouped: true,
icon: String(rowsPerBucket.length),
};
}
return collectiveConfig;
return mergedAnnotation;
});
};
@ -161,20 +283,10 @@ export const Annotations = ({
<>
{groupedLineAnnotations.map((annotation) => {
const markerPositionVertical = Position.Top;
const markerPosition = isHorizontal
? mapVerticalToHorizontalPlacement(markerPositionVertical)
: markerPositionVertical;
const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE;
const id = snakeCase(`${annotation.id}-${annotation.time}`);
const { timebucket, time } = annotation;
const isGrouped = Boolean(annotation.customTooltipDetails);
const header =
formatter?.convert(isGrouped ? timebucket : time) ||
moment(isGrouped ? timebucket : time).toISOString();
const { timebucket, time, isGrouped, id: configId } = annotation;
const strokeWidth = simpleView ? 1 : annotation.lineWidth || 1;
const dataValue = isGrouped
? moment(isBarChart && minInterval ? timebucket + minInterval / 2 : timebucket).valueOf()
: moment(time).valueOf();
const id = snakeCase(`${configId}-${time}`);
return (
<LineAnnotation
id={id}
@ -187,7 +299,7 @@ export const Annotations = ({
config: annotation,
isHorizontal: !isHorizontal,
hasReducedPadding,
label: annotation.label,
label: !isGrouped ? annotation.label : undefined,
rotateClassName: isHorizontal ? 'xyAnnotationIcon_rotate90' : undefined,
}}
/>
@ -197,21 +309,34 @@ export const Annotations = ({
!simpleView ? (
<MarkerBody
label={
annotation.textVisibility && !hasReducedPadding ? annotation.label : undefined
!isGrouped && annotation.textVisibility && !hasReducedPadding
? annotation.label
: undefined
}
isHorizontal={!isHorizontal}
/>
) : undefined
}
markerPosition={markerPosition}
markerPosition={
isHorizontal
? mapVerticalToHorizontalPlacement(markerPositionVertical)
: markerPositionVertical
}
dataValues={[
{
dataValue,
header,
dataValue: isGrouped
? moment(
isBarChart && minInterval ? timebucket + minInterval / 2 : timebucket
).valueOf()
: moment(time).valueOf(),
header:
formatter?.convert(isGrouped ? timebucket : time) ||
moment(isGrouped ? timebucket : time).toISOString(),
details: annotation.label,
},
]}
customTooltipDetails={annotation.customTooltipDetails}
placement={'bottom'}
style={{
line: {
strokeWidth,

View file

@ -54,13 +54,8 @@ import {
sampleLayer,
} from '../../common/__mocks__';
import { XYChart, XYChartRenderProps } from './xy_chart';
import {
CommonXYAnnotationLayerConfig,
ExtendedDataLayerConfig,
XYProps,
} from '../../common/types';
import { ExtendedDataLayerConfig, XYProps, AnnotationLayerConfigResult } from '../../common/types';
import { DataLayers } from './data_layers';
import { Annotations } from './annotations';
import { SplitChart } from './split_chart';
import { LegendSize } from '@kbn/visualizations-plugin/common';
@ -3068,12 +3063,18 @@ describe('XYChart component', () => {
label: 'Event range',
type: 'manual_range_event_annotation' as const,
};
const configToRowHelper = (config: EventAnnotationOutput) => {
return {
...config,
timebucket: 1647591917100,
type: config.type === 'manual_point_event_annotation' ? 'point' : 'range',
};
};
const createLayerWithAnnotations = (
annotations: EventAnnotationOutput[] = [defaultLineStaticAnnotation]
): CommonXYAnnotationLayerConfig => ({
): AnnotationLayerConfigResult => ({
type: 'annotationLayer',
layerType: LayerTypes.ANNOTATIONS,
layerId: 'annotation',
annotations,
});
function sampleArgsWithAnnotations(annotationLayers = [createLayerWithAnnotations()]) {
@ -3081,7 +3082,16 @@ describe('XYChart component', () => {
return {
args: {
...args,
layers: [dateHistogramLayer, ...annotationLayers],
layers: [dateHistogramLayer],
annotations: {
type: 'event_annotations_result' as const,
layers: annotationLayers,
datatable: {
type: 'datatable' as const,
columns: [],
rows: annotationLayers.flatMap((l) => l.annotations.map(configToRowHelper)),
},
},
},
};
}
@ -3102,7 +3112,7 @@ describe('XYChart component', () => {
const { args } = sampleArgsWithAnnotations([
createLayerWithAnnotations([defaultLineStaticAnnotation, defaultRangeStaticAnnotation]),
]);
(args.layers[1] as CommonXYAnnotationLayerConfig).simpleView = true;
args.annotations.layers[0].simpleView = true;
const component = mount(<XYChart {...defaultProps} args={args} />);
expect(component.find('LineAnnotation')).toMatchSnapshot();
expect(component.find('RectAnnotation')).toMatchSnapshot();
@ -3135,7 +3145,7 @@ describe('XYChart component', () => {
// checking tooltip
const renderLinks = mount(<div>{groupedAnnotation.prop('customTooltipDetails')!()}</div>);
expect(renderLinks.text()).toEqual(
' Event 1 2022-03-18T08:25:00.000Z Event 3 2022-03-18T08:25:00.001Z Event 2 2022-03-18T08:25:00.020Z'
' Event 12022-03-18, 04:25:002022-03-18, 04:25:002022-03-18, 04:25:00'
);
});
@ -3161,28 +3171,6 @@ describe('XYChart component', () => {
// styles are default because they are different for both annotations
expect(groupedAnnotation).toMatchSnapshot();
});
test('should not render hidden annotations', () => {
const { args } = sampleArgsWithAnnotations([
createLayerWithAnnotations([
customLineStaticAnnotation,
{ ...customLineStaticAnnotation, time: '2022-03-18T08:30:00.020Z', label: 'Event 2' },
{
...customLineStaticAnnotation,
time: '2022-03-18T08:35:00.001Z',
label: 'Event 3',
isHidden: true,
},
defaultRangeStaticAnnotation,
{ ...defaultRangeStaticAnnotation, label: 'range', isHidden: true },
]),
]);
const component = mount(<XYChart {...defaultProps} args={args} />);
const lineAnnotations = component.find(LineAnnotation);
const rectAnnotations = component.find(Annotations).find(RectAnnotation);
expect(lineAnnotations.length).toEqual(2);
expect(rectAnnotations.length).toEqual(1);
});
});
describe('split chart', () => {

View file

@ -29,19 +29,13 @@ import {
XYChartElementEvent,
} from '@elastic/charts';
import { partition } from 'lodash';
import moment from 'moment';
import { IconType } from '@elastic/eui';
import { PaletteRegistry } from '@kbn/coloring';
import { Datatable, DatatableRow, RenderMode } from '@kbn/expressions-plugin/common';
import { Datatable, RenderMode } from '@kbn/expressions-plugin/common';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { EmptyPlaceholder, LegendToggle } from '@kbn/charts-plugin/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import {
ManualPointEventAnnotationRow,
ManualRangeEventAnnotationOutput,
ManualPointEventAnnotationOutput,
ManualRangeEventAnnotationRow,
} from '@kbn/event-annotation-plugin/common';
import { PointEventAnnotationRow } from '@kbn/event-annotation-plugin/common';
import { ChartsPluginSetup, ChartsPluginStart, useActiveCursor } from '@kbn/charts-plugin/public';
import { MULTILAYER_TIME_AXIS_STYLE } from '@kbn/charts-plugin/common';
import {
@ -61,11 +55,9 @@ import type {
ExtendedReferenceLineDecorationConfig,
XYChartProps,
AxisExtentConfigResult,
CommonXYAnnotationLayerConfig,
} from '../../common/types';
import {
isHorizontalChart,
getAnnotationsLayers,
getDataLayers,
AxisConfiguration,
getAxisPosition,
@ -192,49 +184,6 @@ function createSplitPoint(
export const XYChartReportable = React.memo(XYChart);
// TODO: remove this function when we start using fetch_event_annotation expression
const convertToAnnotationsTable = (
layers: CommonXYAnnotationLayerConfig[],
minInterval?: number,
firstTimestamp?: number
) => {
return layers
.flatMap(({ annotations }) =>
annotations.filter(
(a): a is ManualPointEventAnnotationOutput | ManualRangeEventAnnotationOutput =>
!a.isHidden && 'time' in a
)
)
.sort((a, b) => moment(a.time).valueOf() - moment(b.time).valueOf())
.map((a) => {
const timebucket = getRoundedTimestamp(moment(a.time).valueOf(), firstTimestamp, minInterval);
if (a.type === 'manual_point_event_annotation') {
const pointRow: ManualPointEventAnnotationRow = {
...a,
type: 'point',
timebucket: moment(timebucket).toISOString(),
};
return pointRow;
}
const rangeRow: ManualRangeEventAnnotationRow = {
...a,
type: 'range',
};
return rangeRow;
});
};
export const sortByTime = (a: DatatableRow, b: DatatableRow) => {
return 'time' in a && 'time' in b ? a.time.localeCompare(b.time) : 0;
};
const getRoundedTimestamp = (timestamp: number, firstTimestamp?: number, minInterval?: number) => {
if (!firstTimestamp || !minInterval) {
return timestamp;
}
return timestamp - ((timestamp - firstTimestamp) % minInterval);
};
export function XYChart({
args,
data,
@ -267,6 +216,7 @@ export function XYChart({
splitColumnAccessor,
splitRowAccessor,
singleTable,
annotations,
} = args;
const chartRef = useRef<Chart>(null);
const chartTheme = chartsThemeService.useChartsTheme();
@ -447,25 +397,18 @@ export function XYChart({
};
const referenceLineLayers = getReferenceLayers(layers);
const annotationsLayers = getAnnotationsLayers(layers);
const firstTable = dataLayers[0]?.table;
const columnId = dataLayers[0]?.xAccessor
? getColumnByAccessor(dataLayers[0]?.xAccessor, firstTable.columns)?.id
: null;
const annotations = convertToAnnotationsTable(
annotationsLayers,
minInterval,
columnId ? firstTable.rows[0]?.[columnId] : undefined
const [rangeAnnotations, lineAnnotations] = partition(
annotations?.datatable.rows,
isRangeAnnotation
);
const [rangeAnnotations, lineAnnotations] = partition(annotations, isRangeAnnotation);
const annotationsConfigs = annotations?.layers.flatMap((l) => l.annotations);
const groupedLineAnnotations = getAnnotationsGroupedByInterval(
lineAnnotations as ManualPointEventAnnotationRow[],
xAxisFormatter
lineAnnotations as PointEventAnnotationRow[],
annotationsConfigs,
annotations?.datatable.columns,
formatFactory
);
const visualConfigs = [
@ -483,7 +426,10 @@ export function XYChart({
...groupedLineAnnotations,
].filter(Boolean);
const shouldHideDetails = annotationsLayers.length > 0 ? annotationsLayers[0].simpleView : false;
const shouldHideDetails =
annotations?.layers && annotations.layers.length > 0
? annotations?.layers[0].simpleView
: false;
const linesPaddings = !shouldHideDetails
? getLinesCausedPaddings(visualConfigs, yAxesMap, shouldRotate)
: {};

View file

@ -12,7 +12,7 @@ import classnames from 'classnames';
import type {
IconPosition,
ReferenceLineDecorationConfig,
CollectiveConfig,
MergedAnnotation,
} from '../../common/types';
import { getBaseIconPlacement } from '../components';
import { hasIcon, iconSet } from './icon';
@ -27,16 +27,16 @@ type PartialReferenceLineDecorationConfig = Pick<
position?: Position;
};
type PartialCollectiveConfig = Pick<CollectiveConfig, 'position' | 'icon' | 'textVisibility'>;
type PartialMergedAnnotation = Pick<MergedAnnotation, 'position' | 'icon' | 'textVisibility'>;
const isExtendedDecorationConfig = (
config: PartialReferenceLineDecorationConfig | PartialCollectiveConfig | undefined
config: PartialReferenceLineDecorationConfig | PartialMergedAnnotation | undefined
): config is PartialReferenceLineDecorationConfig =>
(config as PartialReferenceLineDecorationConfig)?.iconPosition ? true : false;
// Note: it does not take into consideration whether the reference line is in view or not
export const getLinesCausedPaddings = (
visualConfigs: Array<PartialReferenceLineDecorationConfig | PartialCollectiveConfig | undefined>,
visualConfigs: Array<PartialReferenceLineDecorationConfig | PartialMergedAnnotation | undefined>,
axesMap: AxesMap,
shouldRotate: boolean
) => {

View file

@ -57,10 +57,3 @@ const isAnnotationLayerCommon = (
export const isAnnotationsLayer = (
layer: CommonXYLayerConfig
): layer is CommonXYAnnotationLayerConfig => isAnnotationLayerCommon(layer);
export const getAnnotationsLayers = (
layers: CommonXYLayerConfig[]
): CommonXYAnnotationLayerConfig[] =>
(layers || []).filter((layer): layer is CommonXYAnnotationLayerConfig =>
isAnnotationsLayer(layer)
);

View file

@ -31,6 +31,7 @@ import {
referenceLineDecorationConfigFunction,
} from '../common/expression_functions';
import { GetStartDepsFn, getXyChartRenderer } from './expression_renderers';
import { eventAnnotationsResult } from '../common/expression_functions/event_annotations_result';
export interface XYPluginStartDependencies {
data: DataPublicPluginStart;
@ -63,6 +64,7 @@ export class ExpressionXyPlugin {
expressions.registerFunction(xAxisConfigFunction);
expressions.registerFunction(annotationLayerFunction);
expressions.registerFunction(extendedAnnotationLayerFunction);
expressions.registerFunction(eventAnnotationsResult);
expressions.registerFunction(referenceLineFunction);
expressions.registerFunction(referenceLineLayerFunction);
expressions.registerFunction(xyVisFunction);

View file

@ -25,6 +25,7 @@ import {
extendedAnnotationLayerFunction,
} from '../common/expression_functions';
import { SetupDeps } from './types';
import { eventAnnotationsResult } from '../common/expression_functions/event_annotations_result';
export class ExpressionXyPlugin
implements Plugin<ExpressionXyPluginSetup, ExpressionXyPluginStart>
@ -39,6 +40,7 @@ export class ExpressionXyPlugin
expressions.registerFunction(axisExtentConfigFunction);
expressions.registerFunction(annotationLayerFunction);
expressions.registerFunction(extendedAnnotationLayerFunction);
expressions.registerFunction(eventAnnotationsResult);
expressions.registerFunction(referenceLineFunction);
expressions.registerFunction(referenceLineLayerFunction);
expressions.registerFunction(xyVisFunction);

View file

@ -29,6 +29,7 @@ export * from './lib/cidr_mask';
export * from './lib/date_range';
export * from './lib/ip_range';
export * from './lib/time_buckets/calc_auto_interval';
export { TimeBuckets } from './lib/time_buckets';
export * from './migrate_include_exclude_format';
export * from './range_fn';
export * from './range';

View file

@ -0,0 +1,51 @@
/*
* 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 { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { i18n } from '@kbn/i18n';
import type { EventAnnotationGroupOutput } from '../event_annotation_group';
export interface FetchEventAnnotationArgs {
group: EventAnnotationGroupOutput[];
}
export type FetchEventAnnotationOutput = FetchEventAnnotationArgs & {
type: 'fetch_event_annotation';
};
export function eventAnnotationGroup(): ExpressionFunctionDefinition<
'fetch_event_annotation',
null,
FetchEventAnnotationArgs,
FetchEventAnnotationOutput
> {
return {
name: 'fetch_event_annotation',
aliases: [],
type: 'fetch_event_annotation',
inputTypes: ['null'],
help: i18n.translate('eventAnnotation.fetch.description', {
defaultMessage: 'Event annotation fetch',
}),
args: {
group: {
types: ['event_annotation_group'],
help: i18n.translate('eventAnnotation.group.args.annotationGroups', {
defaultMessage: 'Annotation group',
}),
multi: true,
},
},
fn: (input, args) => {
return {
type: 'fetch_event_annotation',
group: args.group,
};
},
};
}

View file

@ -23,7 +23,6 @@ export const getFetchEventAnnotationsMeta: () => Omit<
}),
args: {
groups: {
required: true,
types: ['event_annotation_group'],
help: i18n.translate('eventAnnotation.fetchEventAnnotations.args.annotationConfigs', {
defaultMessage: 'Annotation configs',

View file

@ -10,13 +10,13 @@ import { defer, firstValueFrom } from 'rxjs';
import { partition } from 'lodash';
import {
AggsStart,
DataViewsContract,
DataView,
DataViewSpec,
ExpressionValueSearchContext,
parseEsInterval,
AggConfigs,
IndexPatternExpressionType,
} from '@kbn/data-plugin/common';
import { ExecutionContext } from '@kbn/expressions-plugin/common';
import moment from 'moment';
import { ESCalendarInterval, ESFixedInterval, roundDateToESInterval } from '@elastic/charts';
@ -26,6 +26,7 @@ import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { handleRequest } from './handle_request';
import {
ANNOTATIONS_PER_BUCKET,
getCalculatedInterval,
isInRange,
isManualAnnotation,
isManualPointAnnotation,
@ -45,9 +46,9 @@ interface ManualGroup {
interface QueryGroup {
type: 'query';
annotations: QueryPointEventAnnotationOutput[];
allFields?: string[];
dataView: IndexPatternExpressionType;
timeField: string;
dataView: DataView;
allFields?: string[];
}
export function getTimeZone(uiSettings: IUiSettingsClient) {
@ -58,6 +59,7 @@ export function getTimeZone(uiSettings: IUiSettingsClient) {
return configuredTimeZone;
}
const emptyDatatable = { rows: [], columns: [], type: 'datatable' };
export const requestEventAnnotations = (
input: ExpressionValueSearchContext | null,
@ -71,20 +73,39 @@ export const requestEventAnnotations = (
getStartDependencies: () => Promise<FetchEventAnnotationsStartDependencies>
) => {
return defer(async () => {
if (!input?.timeRange || !args.groups) {
return emptyDatatable;
}
const { aggs, dataViews, searchSource, getNow, uiSettings } = await getStartDependencies();
const interval = getCalculatedInterval(uiSettings, args.interval, input?.timeRange);
if (!interval) {
return emptyDatatable;
}
const uniqueDataViewsToLoad = args.groups
.map((g) => g.dataView.value)
.reduce<DataViewSpec[]>((acc, current) => {
if (acc.find((el) => el.id === current.id)) return acc;
return [...acc, current];
}, []);
const loadedDataViews = await Promise.all(
uniqueDataViewsToLoad.map((dataView) => dataViews.create(dataView, true))
);
const [manualGroups, queryGroups] = partition(
regroupForRequestOptimization(args, input),
regroupForRequestOptimization(args, input, loadedDataViews),
isManualSubGroup
);
const manualAnnotationDatatableRows = manualGroups.length
? convertManualToDatatableRows(manualGroups[0], args.interval, getTimeZone(uiSettings))
? convertManualToDatatableRows(manualGroups[0], interval, getTimeZone(uiSettings))
: [];
if (!queryGroups.length) {
return manualAnnotationDatatableRows.length
? wrapRowsInDatatable(manualAnnotationDatatableRows)
: null;
: emptyDatatable;
}
const createEsaggsSingleRequest = async ({
@ -92,7 +113,7 @@ export const requestEventAnnotations = (
aggConfigs,
timeFields,
}: {
dataView: any;
dataView: DataView;
aggConfigs: AggConfigs;
timeFields: string[];
}) =>
@ -113,12 +134,7 @@ export const requestEventAnnotations = (
})
);
const esaggsGroups = await prepareEsaggsForQueryGroups(
queryGroups,
args.interval,
dataViews,
aggs
);
const esaggsGroups = await prepareEsaggsForQueryGroups(queryGroups, interval, aggs);
const allQueryAnnotationsConfigs = queryGroups.flatMap((group) => group.annotations);
@ -169,23 +185,9 @@ const convertManualToDatatableRows = (
const prepareEsaggsForQueryGroups = async (
queryGroups: QueryGroup[],
interval: string,
dataViews: DataViewsContract,
aggs: AggsStart
) => {
const uniqueDataViewsToLoad = queryGroups
.map((g) => g.dataView.value)
.reduce<DataViewSpec[]>((acc, current) => {
if (acc.find((el) => el.id === current.id)) return acc;
return [...acc, current];
}, []);
const loadedDataViews = await Promise.all(
uniqueDataViewsToLoad.map((dataView) => dataViews.create(dataView, true))
);
return queryGroups.map((group) => {
const dataView = loadedDataViews.find((dv) => dv.id === group.dataView.value.id)!;
const annotationsFilters = {
type: 'agg_type',
value: {
@ -260,9 +262,12 @@ const prepareEsaggsForQueryGroups = async (
...fieldsTopMetric,
];
const aggConfigs = aggs.createAggConfigs(dataView, aggregations?.map((agg) => agg.value) ?? []);
const aggConfigs = aggs.createAggConfigs(
group.dataView,
aggregations?.map((agg) => agg.value) ?? []
);
return {
esaggsParams: { dataView, aggConfigs, timeFields: [group.timeField] },
esaggsParams: { dataView: group.dataView, aggConfigs, timeFields: [group.timeField] },
fieldsColIdMap:
group.allFields?.reduce<Record<string, string>>(
(acc, fieldName, i) => ({
@ -278,15 +283,12 @@ const prepareEsaggsForQueryGroups = async (
function regroupForRequestOptimization(
{ groups }: FetchEventAnnotationsArgs,
input: ExpressionValueSearchContext | null
input: ExpressionValueSearchContext | null,
loadedDataViews: DataView[]
) {
const outputGroups = groups
.map((g) => {
return g.annotations.reduce<Record<string, ManualGroup | QueryGroup>>((acc, current) => {
if (current.isHidden) {
return acc;
}
if (isManualAnnotation(current)) {
if (!isInRange(current, input?.timeRange)) {
return acc;
@ -297,7 +299,14 @@ function regroupForRequestOptimization(
(acc.manual as ManualGroup).annotations.push(current);
return acc;
} else {
const key = `${g.dataView.value.id}-${current.timeField}`;
const dataView = loadedDataViews.find((dv) => dv.id === g.dataView.value.id)!;
const timeField =
current.timeField ??
(dataView.timeFieldName ||
dataView.fields.find((field) => field.type === 'date' && field.displayName)?.name);
const key = `${g.dataView.value.id}-${timeField}`;
const subGroup = acc[key] as QueryGroup;
if (subGroup) {
let allFields = [...(subGroup.allFields || []), ...(current.extraFields || [])];
@ -321,8 +330,8 @@ function regroupForRequestOptimization(
...acc,
[key]: {
type: 'query',
dataView: g.dataView,
timeField: current.timeField,
dataView,
timeField: timeField!,
allFields,
annotations: [current],
},

View file

@ -17,7 +17,9 @@ import { ExpressionFunctionDefinition, Datatable } from '@kbn/expressions-plugin
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { EventAnnotationGroupOutput } from '../event_annotation_group';
export type FetchEventAnnotationsOutput = Observable<Datatable | null>;
export type FetchEventAnnotationsOutput = Observable<
Datatable | { rows: never[]; columns: never[]; type: string }
>;
export interface FetchEventAnnotationsArgs {
groups: EventAnnotationGroupOutput[];

View file

@ -6,10 +6,12 @@
* Side Public License, v 1.
*/
import { TimeRange } from '@kbn/data-plugin/common';
import { TimeBuckets, TimeRange, UI_SETTINGS } from '@kbn/data-plugin/common';
import { Datatable, DatatableColumn, DatatableRow } from '@kbn/expressions-plugin/common';
import { omit, pick } from 'lodash';
import dateMath from '@kbn/datemath';
import moment from 'moment';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import {
ManualEventAnnotationOutput,
ManualPointEventAnnotationOutput,
@ -41,15 +43,70 @@ export const isManualAnnotation = (
): annotation is ManualPointEventAnnotationOutput | ManualRangeEventAnnotationOutput =>
isRangeAnnotation(annotation) || isManualPointAnnotation(annotation);
function toAbsoluteDate(date: string) {
const parsed = dateMath.parse(date);
return parsed ? parsed.toDate() : undefined;
}
export function toAbsoluteDates(range: TimeRange) {
const fromDate = dateMath.parse(range.from);
const toDate = dateMath.parse(range.to, { roundUp: true });
if (!fromDate || !toDate) {
return;
}
return {
from: fromDate.toDate(),
to: toDate.toDate(),
};
}
export const getCalculatedInterval = (
uiSettings: IUiSettingsClient,
usedInterval: string,
timeRange?: TimeRange
) => {
const dates = timeRange && toAbsoluteDates(timeRange);
if (!dates) {
return;
}
const buckets = new TimeBuckets({
'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS),
'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET),
dateFormat: uiSettings.get('dateFormat'),
'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
});
buckets.setInterval(usedInterval);
buckets.setBounds({
min: moment(dates.from),
max: moment(dates.to),
});
return buckets.getInterval().expression;
};
export const isInRange = (annotation: ManualEventAnnotationOutput, timerange?: TimeRange) => {
if (!timerange) {
return false;
}
const { from, to } = toAbsoluteDates(timerange) || {};
if (!from || !to) {
return false;
}
if (isRangeAnnotation(annotation)) {
return !(annotation.time >= timerange.to || annotation.endTime < timerange.from);
const time = toAbsoluteDate(annotation.time);
const endTime = toAbsoluteDate(annotation.endTime);
if (time && endTime) {
return !(time >= to || endTime < from);
}
}
if (isManualPointAnnotation(annotation)) {
return annotation.time >= timerange.from && annotation.time <= timerange.to;
const time = toAbsoluteDate(annotation.time);
if (time) {
return time >= from && time <= to;
}
}
return true;
};

View file

@ -12,13 +12,12 @@ export type {
ManualRangeEventAnnotationArgs,
ManualRangeEventAnnotationOutput,
ManualRangeEventAnnotationRow,
ManualPointEventAnnotationRow,
PointEventAnnotationRow,
} from './manual_event_annotation/types';
export type {
QueryPointEventAnnotationArgs,
QueryPointEventAnnotationOutput,
} from './query_point_event_annotation/types';
export type { EventAnnotationArgs, EventAnnotationOutput } from './types';
export { manualPointEventAnnotation, manualRangeEventAnnotation } from './manual_event_annotation';
export { queryPointEventAnnotation } from './query_point_event_annotation';
export { eventAnnotationGroup } from './event_annotation_group';
@ -27,8 +26,11 @@ export type { EventAnnotationGroupArgs } from './event_annotation_group';
export type { FetchEventAnnotationsArgs } from './fetch_event_annotations/types';
export type {
EventAnnotationConfig,
EventAnnotationGroupConfig,
EventAnnotationArgs,
RangeEventAnnotationConfig,
PointInTimeEventAnnotationConfig,
QueryPointEventAnnotationConfig,
AvailableAnnotationIcon,
EventAnnotationOutput,
} from './types';

View file

@ -17,13 +17,14 @@ export type ManualPointEventAnnotationOutput = ManualPointEventAnnotationArgs &
type: 'manual_point_event_annotation';
};
export type ManualPointEventAnnotationRow = {
export type PointEventAnnotationRow = {
id: string;
time: string;
type: 'point';
timebucket: string;
skippedCount?: string;
} & PointStyleProps;
skippedCount?: number;
} & PointStyleProps &
Record<string, any>;
export type ManualRangeEventAnnotationArgs = {
id: string;

View file

@ -38,7 +38,6 @@ export const queryPointEventAnnotation: ExpressionFunctionDefinition<
help: i18n.translate('eventAnnotation.queryAnnotation.args.filter', {
defaultMessage: `Annotation filter`,
}),
required: true,
},
extraFields: {
multi: true,
@ -48,7 +47,6 @@ export const queryPointEventAnnotation: ExpressionFunctionDefinition<
}),
},
timeField: {
required: true,
types: ['string'],
help: i18n.translate('eventAnnotation.queryAnnotation.args.timeField', {
defaultMessage: `The time field of the annotation`,

View file

@ -12,7 +12,7 @@ import { PointStyleProps } from '../types';
export type QueryPointEventAnnotationArgs = {
id: string;
filter: KibanaQueryOutput;
timeField: string;
timeField?: string;
extraFields?: string[];
textField?: string;
} & PointStyleProps;

View file

@ -22,35 +22,39 @@ import {
export type LineStyle = 'solid' | 'dashed' | 'dotted';
export type Fill = 'inside' | 'outside' | 'none';
export type AnnotationType = 'manual';
export type ManualAnnotationType = 'manual';
export type QueryAnnotationType = 'query';
export type KeyType = 'point_in_time' | 'range';
export type AvailableAnnotationIcon = $Values<typeof AvailableAnnotationIcons>;
export interface PointStyleProps {
interface StyleSharedProps {
label: string;
color?: string;
isHidden?: boolean;
}
export type PointStyleProps = StyleSharedProps & {
icon?: AvailableAnnotationIcon;
lineWidth?: number;
lineStyle?: LineStyle;
textVisibility?: boolean;
isHidden?: boolean;
}
};
export type PointInTimeEventAnnotationConfig = {
id: string;
type: ManualAnnotationType;
key: {
type: 'point_in_time';
timestamp: string;
};
} & PointStyleProps;
export interface RangeStyleProps {
label: string;
color?: string;
export type RangeStyleProps = StyleSharedProps & {
outside?: boolean;
isHidden?: boolean;
}
};
export type RangeEventAnnotationConfig = {
type: ManualAnnotationType;
id: string;
key: {
type: 'range';
@ -63,9 +67,10 @@ export type StyleProps = PointStyleProps & RangeStyleProps;
export type QueryPointEventAnnotationConfig = {
id: string;
type: QueryAnnotationType;
filter: KibanaQueryOutput;
timeField: string;
textField: string;
timeField?: string;
textField?: string;
extraFields?: string[];
key: {
type: 'point_in_time';
@ -77,6 +82,11 @@ export type EventAnnotationConfig =
| RangeEventAnnotationConfig
| QueryPointEventAnnotationConfig;
export interface EventAnnotationGroupConfig {
annotations: EventAnnotationConfig[];
indexPatternId: string;
}
export type EventAnnotationArgs =
| ManualPointEventAnnotationArgs
| ManualRangeEventAnnotationArgs

View file

@ -14,6 +14,7 @@ import {
QueryPointEventAnnotationConfig,
} from '../../common';
export const defaultAnnotationColor = euiLightVars.euiColorAccent;
// Do not compute it live as dependencies will add tens of Kbs to the plugin
export const defaultAnnotationRangeColor = `#F04E981A`; // defaultAnnotationColor with opacity 0.1
export const defaultAnnotationLabel = i18n.translate(
@ -38,5 +39,5 @@ export const isManualPointAnnotationConfig = (
export const isQueryAnnotationConfig = (
annotation?: EventAnnotationConfig
): annotation is QueryPointEventAnnotationConfig => {
return Boolean(annotation && 'filter' in annotation);
return Boolean(annotation && annotation.type === 'query');
};

View file

@ -0,0 +1,352 @@
/*
* 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 { getEventAnnotationService } from './service';
import { EventAnnotationServiceType } from './types';
describe('Event Annotation Service', () => {
let eventAnnotationService: EventAnnotationServiceType;
beforeAll(() => {
eventAnnotationService = getEventAnnotationService();
});
describe('toExpression', () => {
it('should work for an empty list', () => {
expect(eventAnnotationService.toExpression([])).toEqual([]);
});
it('should skip hidden annotations', () => {
expect(
eventAnnotationService.toExpression([
{
id: 'myEvent',
type: 'manual',
key: {
type: 'point_in_time',
timestamp: '2022',
},
label: 'Hello',
isHidden: true,
},
{
id: 'myRangeEvent',
type: 'manual',
key: {
type: 'range',
timestamp: '2021',
endTimestamp: '2022',
},
label: 'Hello Range',
isHidden: true,
},
{
id: 'myEvent',
type: 'query',
timeField: '@timestamp',
key: {
type: 'point_in_time',
},
label: 'Hello Range',
isHidden: true,
filter: { type: 'kibana_query', query: '', language: 'kuery' },
},
])
).toEqual([]);
});
it('should process manual point annotations', () => {
expect(
eventAnnotationService.toExpression([
{
id: 'myEvent',
type: 'manual',
key: {
type: 'point_in_time',
timestamp: '2022',
},
label: 'Hello',
},
])
).toEqual([
{
type: 'expression',
chain: [
{
type: 'function',
function: 'manual_point_event_annotation',
arguments: {
id: ['myEvent'],
time: ['2022'],
label: ['Hello'],
color: ['#f04e98'],
lineWidth: [1],
lineStyle: ['solid'],
icon: ['triangle'],
textVisibility: [false],
},
},
],
},
]);
});
it('should process manual range annotations', () => {
expect(
eventAnnotationService.toExpression([
{
id: 'myEvent',
type: 'manual',
key: {
type: 'range',
timestamp: '2021',
endTimestamp: '2022',
},
label: 'Hello',
},
])
).toEqual([
{
type: 'expression',
chain: [
{
type: 'function',
function: 'manual_range_event_annotation',
arguments: {
id: ['myEvent'],
time: ['2021'],
endTime: ['2022'],
label: ['Hello'],
color: ['#F04E981A'],
outside: [false],
},
},
],
},
]);
});
it('should process query based annotations', () => {
expect(
eventAnnotationService.toExpression([
{
id: 'myEvent',
type: 'query',
timeField: '@timestamp',
key: {
type: 'point_in_time',
},
label: 'Hello',
filter: { type: 'kibana_query', query: '', language: 'kuery' },
},
])
).toEqual([
{
type: 'expression',
chain: [
{
type: 'function',
function: 'query_point_event_annotation',
arguments: {
id: ['myEvent'],
timeField: ['@timestamp'],
label: ['Hello'],
color: ['#f04e98'],
lineWidth: [1],
lineStyle: ['solid'],
icon: ['triangle'],
textVisibility: [false],
textField: [],
filter: [
{
chain: [
{
arguments: {
q: [''],
},
function: 'kql',
type: 'function',
},
],
type: 'expression',
},
],
extraFields: [],
},
},
],
},
]);
});
it('should process mixed annotations', () => {
expect(
eventAnnotationService.toExpression([
{
id: 'myEvent',
type: 'manual',
key: {
type: 'point_in_time',
timestamp: '2022',
},
label: 'Hello',
},
{
id: 'myRangeEvent',
type: 'manual',
key: {
type: 'range',
timestamp: '2021',
endTimestamp: '2022',
},
label: 'Hello Range',
},
{
id: 'myEvent',
type: 'query',
timeField: '@timestamp',
key: {
type: 'point_in_time',
},
label: 'Hello',
filter: { type: 'kibana_query', query: '', language: 'kuery' },
},
])
).toEqual([
{
type: 'expression',
chain: [
{
type: 'function',
function: 'manual_point_event_annotation',
arguments: {
id: ['myEvent'],
time: ['2022'],
label: ['Hello'],
color: ['#f04e98'],
lineWidth: [1],
lineStyle: ['solid'],
icon: ['triangle'],
textVisibility: [false],
},
},
],
},
{
type: 'expression',
chain: [
{
type: 'function',
function: 'manual_range_event_annotation',
arguments: {
id: ['myRangeEvent'],
time: ['2021'],
endTime: ['2022'],
label: ['Hello Range'],
color: ['#F04E981A'],
outside: [false],
},
},
],
},
{
type: 'expression',
chain: [
{
type: 'function',
function: 'query_point_event_annotation',
arguments: {
id: ['myEvent'],
timeField: ['@timestamp'],
label: ['Hello'],
color: ['#f04e98'],
lineWidth: [1],
lineStyle: ['solid'],
icon: ['triangle'],
textVisibility: [false],
textField: [],
filter: [
{
chain: [
{
arguments: {
q: [''],
},
function: 'kql',
type: 'function',
},
],
type: 'expression',
},
],
extraFields: [],
},
},
],
},
]);
});
it.each`
textVisibility | textField | expected
${'true'} | ${''} | ${''}
${'false'} | ${''} | ${''}
${'true'} | ${'myField'} | ${'myField'}
${'false'} | ${''} | ${''}
`(
"should handle correctly textVisibility when set to '$textVisibility' and textField to '$textField'",
({ textVisibility, textField, expected }) => {
expect(
eventAnnotationService.toExpression([
{
id: 'myEvent',
type: 'query',
timeField: '@timestamp',
key: {
type: 'point_in_time',
},
label: 'Hello',
filter: { type: 'kibana_query', query: '', language: 'kuery' },
textVisibility,
textField,
},
])
).toEqual([
{
type: 'expression',
chain: [
{
type: 'function',
function: 'query_point_event_annotation',
arguments: {
id: ['myEvent'],
timeField: ['@timestamp'],
label: ['Hello'],
color: ['#f04e98'],
lineWidth: [1],
lineStyle: ['solid'],
icon: ['triangle'],
textVisibility: [textVisibility],
textField: expected ? [expected] : [],
filter: [
{
chain: [
{
arguments: {
q: [''],
},
function: 'kql',
type: 'function',
},
],
type: 'expression',
},
],
extraFields: [],
},
},
],
},
]);
}
);
});
});

View file

@ -6,38 +6,42 @@
* Side Public License, v 1.
*/
import { partition } from 'lodash';
import { queryToAst } from '@kbn/data-plugin/common';
import { ExpressionAstExpression } from '@kbn/expressions-plugin/common';
import { EventAnnotationConfig } from '../../common';
import { EventAnnotationServiceType } from './types';
import {
defaultAnnotationColor,
defaultAnnotationRangeColor,
defaultAnnotationLabel,
isRangeAnnotationConfig,
isQueryAnnotationConfig,
} from './helpers';
import { EventAnnotationConfig } from '../../common';
import { RangeEventAnnotationConfig } from '../../common/types';
export function hasIcon(icon: string | undefined): icon is string {
return icon != null && icon !== 'empty';
}
const isRangeAnnotation = (
annotation?: EventAnnotationConfig
): annotation is RangeEventAnnotationConfig => {
return Boolean(annotation && annotation?.key.type === 'range');
};
export function getEventAnnotationService(): EventAnnotationServiceType {
return {
toExpression: (annotation) => {
if (isRangeAnnotation(annotation)) {
const { label, isHidden, color, key, outside, id } = annotation;
const annotationsToExpression = (annotations: EventAnnotationConfig[]) => {
const visibleAnnotations = annotations.filter(({ isHidden }) => !isHidden);
const [queryBasedAnnotations, manualBasedAnnotations] = partition(
visibleAnnotations,
isQueryAnnotationConfig
);
const expressions = [];
for (const annotation of manualBasedAnnotations) {
if (isRangeAnnotationConfig(annotation)) {
const { label, color, key, outside, id } = annotation;
const { timestamp: time, endTimestamp: endTime } = key;
return {
type: 'expression',
expressions.push({
type: 'expression' as const,
chain: [
{
type: 'function',
type: 'function' as const,
function: 'manual_range_event_annotation',
arguments: {
id: [id],
@ -46,57 +50,17 @@ export function getEventAnnotationService(): EventAnnotationServiceType {
label: [label || defaultAnnotationLabel],
color: [color || defaultAnnotationRangeColor],
outside: [Boolean(outside)],
isHidden: [Boolean(isHidden)],
},
},
],
};
} else if (isQueryAnnotationConfig(annotation)) {
const {
id,
extraFields,
label,
isHidden,
color,
lineStyle,
lineWidth,
icon,
filter,
textVisibility,
timeField,
textField,
} = annotation;
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'query_point_event_annotation',
arguments: {
id: [id],
filter: filter ? [queryToAst(filter)] : [],
timeField: [timeField],
textField: [textField],
label: [label || defaultAnnotationLabel],
color: [color || defaultAnnotationColor],
lineWidth: [lineWidth || 1],
lineStyle: [lineStyle || 'solid'],
icon: hasIcon(icon) ? [icon] : ['triangle'],
textVisibility: [textVisibility || false],
isHidden: [Boolean(isHidden)],
extraFields: extraFields || [],
},
},
],
};
});
} else {
const { label, isHidden, color, lineStyle, lineWidth, icon, key, textVisibility, id } =
annotation;
return {
type: 'expression',
const { label, color, lineStyle, lineWidth, icon, key, textVisibility, id } = annotation;
expressions.push({
type: 'expression' as const,
chain: [
{
type: 'function',
type: 'function' as const,
function: 'manual_point_event_annotation',
arguments: {
id: [id],
@ -107,12 +71,106 @@ export function getEventAnnotationService(): EventAnnotationServiceType {
lineStyle: [lineStyle || 'solid'],
icon: hasIcon(icon) ? [icon] : ['triangle'],
textVisibility: [textVisibility || false],
isHidden: [Boolean(isHidden)],
},
},
],
};
});
}
}
for (const annotation of queryBasedAnnotations) {
const {
id,
label,
color,
lineStyle,
lineWidth,
icon,
timeField,
textVisibility,
textField,
filter,
extraFields,
} = annotation;
expressions.push({
type: 'expression' as const,
chain: [
{
type: 'function' as const,
function: 'query_point_event_annotation',
arguments: {
id: [id],
timeField: timeField ? [timeField] : [],
label: [label || defaultAnnotationLabel],
color: [color || defaultAnnotationColor],
lineWidth: [lineWidth || 1],
lineStyle: [lineStyle || 'solid'],
icon: hasIcon(icon) ? [icon] : ['triangle'],
textVisibility: [textVisibility || false],
textField: textVisibility && textField ? [textField] : [],
filter: filter ? [queryToAst(filter)] : [],
extraFields: extraFields || [],
},
},
],
});
}
return expressions;
};
return {
toExpression: annotationsToExpression,
toFetchExpression: ({ interval, groups }) => {
if (groups.length === 0) {
return [];
}
const groupsExpressions = groups
.filter((g) => g.annotations.some((a) => !a.isHidden))
.map(({ annotations, indexPatternId }): ExpressionAstExpression => {
const indexPatternExpression: ExpressionAstExpression = {
type: 'expression',
chain: [
{
type: 'function',
function: 'indexPatternLoad',
arguments: {
id: [indexPatternId],
},
},
],
};
const annotationExpressions = annotationsToExpression(annotations);
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'event_annotation_group',
arguments: {
dataView: [indexPatternExpression],
annotations: [...annotationExpressions],
},
},
],
};
});
const fetchExpression: ExpressionAstExpression = {
type: 'expression',
chain: [
{ type: 'function', function: 'kibana', arguments: {} },
{
type: 'function',
function: 'fetch_event_annotations',
arguments: {
interval: [interval],
groups: [...groupsExpressions],
},
},
],
};
return [fetchExpression];
},
};
}

View file

@ -7,8 +7,12 @@
*/
import { ExpressionAstExpression } from '@kbn/expressions-plugin/common/ast';
import { EventAnnotationConfig } from '../../common';
import { EventAnnotationConfig, EventAnnotationGroupConfig } from '../../common';
export interface EventAnnotationServiceType {
toExpression: (props: EventAnnotationConfig) => ExpressionAstExpression;
toExpression: (props: EventAnnotationConfig[]) => ExpressionAstExpression[];
toFetchExpression: (props: {
interval: string;
groups: EventAnnotationGroupConfig[];
}) => ExpressionAstExpression[];
}

View file

@ -37,6 +37,13 @@ Array [
"timebucket": "2022-07-05T04:30:00.000Z",
"type": "point",
},
Object {
"id": "mann5",
"isHidden": true,
"time": "2022-07-05T05:55:00Z",
"timebucket": "2022-07-05T05:30:00.000Z",
"type": "point",
},
Object {
"id": "mann1",
"time": "2022-07-05T11:12:00Z",

View file

@ -285,12 +285,12 @@ describe('getFetchEventAnnotations', () => {
(startServices[1].data.dataViews.create as jest.Mock).mockClear();
(handleRequest as jest.Mock).mockClear();
});
test('Returns null for empty groups', async () => {
test('Returns empty datatable for empty groups', async () => {
const result = await runGetFetchEventAnnotations({
interval: '2h',
groups: [],
});
expect(result).toEqual(null);
expect(result).toEqual({ columns: [], rows: [], type: 'datatable' });
});
describe('Manual annotations', () => {
@ -322,10 +322,6 @@ describe('getFetchEventAnnotations', () => {
],
} as unknown as FetchEventAnnotationsArgs;
test(`Doesn't run dataViews.create for manual annotations groups only`, async () => {
await runGetFetchEventAnnotations(manualOnlyArgs);
expect(startServices[1].data.dataViews.create).not.toHaveBeenCalled();
});
test('Sorts annotations by time, assigns correct timebuckets, filters out hidden and out of range annotations', async () => {
const result = await runGetFetchEventAnnotations(manualOnlyArgs);
expect(result!.rows).toMatchSnapshot();
@ -340,7 +336,7 @@ describe('getFetchEventAnnotations', () => {
{
type: 'event_annotation_group',
annotations: [manualAnnotationSamples.point1],
dataView1,
dataView: dataView1,
},
{
type: 'event_annotation_group',

View file

@ -19,4 +19,5 @@ export {
defaultAnnotationRangeColor,
isRangeAnnotationConfig,
isManualPointAnnotationConfig,
isQueryAnnotationConfig,
} from './event_annotation_service/helpers';

View file

@ -31,7 +31,7 @@ import { SaveModalContainer, runSaveLensVisualization } from './save_modal_conta
import { LensInspector } from '../lens_inspector_service';
import { getEditPath } from '../../common';
import { isLensEqual } from './lens_document_equality';
import { IndexPatternServiceAPI, createIndexPatternService } from '../indexpattern_service/service';
import { IndexPatternServiceAPI, createIndexPatternService } from '../data_views_service/service';
import { replaceIndexpattern } from '../state_management/lens_slice';
export type SaveProps = Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
@ -403,6 +403,7 @@ export function App({
setHeaderActionMenu={setHeaderActionMenu}
indicateNoData={indicateNoData}
datasourceMap={datasourceMap}
visualizationMap={visualizationMap}
title={persistedDoc?.title}
lensInspector={lensInspector}
currentDoc={currentDoc}

View file

@ -210,6 +210,7 @@ export const LensTopNavMenu = ({
onAppLeave,
redirectToOrigin,
datasourceMap,
visualizationMap,
title,
goBackToOriginatingApp,
contextOriginatingApp,
@ -305,6 +306,10 @@ export const LensTopNavMenu = ({
{}
),
datasourceStates,
visualizationState: visualization.state,
activeVisualization: visualization.activeId
? visualizationMap[visualization.activeId]
: undefined,
})
);
// Add ad-hoc data views from the Lens state even if they are not used
@ -337,6 +342,8 @@ export const LensTopNavMenu = ({
activeDatasourceId,
rejectedIndexPatterns,
datasourceMap,
visualizationMap,
visualization,
indexPatterns,
dataViewsService,
dataViews,

View file

@ -52,7 +52,7 @@ import type {
import type { LensAttributeService } from '../lens_attribute_service';
import type { LensEmbeddableInput } from '../embeddable/embeddable';
import type { LensInspector } from '../lens_inspector_service';
import { IndexPatternServiceAPI } from '../indexpattern_service/service';
import { IndexPatternServiceAPI } from '../data_views_service/service';
import { Document } from '../persistence/saved_object_store';
export interface RedirectToOriginProps {
@ -106,6 +106,7 @@ export interface LensTopNavMenuProps {
setIsSaveModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
runSave: RunSave;
datasourceMap: DatasourceMap;
visualizationMap: VisualizationMap;
title?: string;
lensInspector: LensInspector;
goBackToOriginatingApp?: () => void;

View file

@ -11,22 +11,38 @@ import {
createMockedIndexPattern,
createMockedRestrictedIndexPattern,
} from '../indexpattern_datasource/mocks';
import { IndexPattern } from '../types';
import { DataViewsState } from '../state_management';
import { ExistingFieldsMap, IndexPattern } from '../types';
import { getFieldByNameFactory } from './loader';
export function loadInitialDataViews() {
const indexPattern = createMockedIndexPattern();
const restricted = createMockedRestrictedIndexPattern();
/**
* Create a DataViewState from partial parameters, and infer the rest from the passed one.
* Passing no parameter will return an empty state.
*/
export const createMockDataViewsState = ({
indexPatterns,
indexPatternRefs,
isFirstExistenceFetch,
existingFields,
}: Partial<DataViewsState> = {}): DataViewsState => {
const refs =
indexPatternRefs ??
Object.values(indexPatterns ?? {}).map(({ id, title, name }) => ({ id, title, name }));
const allFields =
existingFields ??
refs.reduce((acc, { id, title }) => {
if (indexPatterns && id in indexPatterns) {
acc[title] = Object.fromEntries(indexPatterns[id].fields.map((f) => [f.displayName, true]));
}
return acc;
}, {} as ExistingFieldsMap);
return {
indexPatternRefs: [],
existingFields: {},
indexPatterns: {
[indexPattern.id]: indexPattern,
[restricted.id]: restricted,
},
isFirstExistenceFetch: false,
indexPatterns: indexPatterns ?? {},
indexPatternRefs: refs,
isFirstExistenceFetch: Boolean(isFirstExistenceFetch),
existingFields: allFields,
};
}
};
export const createMockStorage = (lastData?: Record<string, string>) => {
return {

View file

@ -196,9 +196,9 @@ export interface OnVisDropProps<T> {
group?: VisualizationDimensionGroupConfig;
}
export function onDropForVisualization<T>(
export function onDropForVisualization<T, P = unknown>(
props: OnVisDropProps<T>,
activeVisualization: Visualization<T>
activeVisualization: Visualization<T, P>
) {
const { prevState, target, frame, dropType, source, group } = props;
const { layerId, columnId, groupId } = target;

View file

@ -29,7 +29,8 @@ function fromExcludedClickTarget(event: Event) {
) {
if (
node.classList!.contains(DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS) ||
node.classList!.contains('euiBody-hasPortalContent')
node.classList!.contains('euiBody-hasPortalContent') ||
node.getAttribute('data-euiportal') === 'true'
) {
return true;
}

View file

@ -18,7 +18,7 @@ import {
EuiIconTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { IndexPatternServiceAPI } from '../../../indexpattern_service/service';
import { IndexPatternServiceAPI } from '../../../data_views_service/service';
import { NativeRenderer } from '../../../native_renderer';
import {
StateSetter,
@ -524,8 +524,13 @@ export function LayerPanel(
columnId,
label: columnLabelMap?.[columnId] ?? '',
hideTooltip,
invalid: group.invalid,
invalidMessage: group.invalidMessage,
...(activeVisualization?.validateColumn?.(
visualizationState,
{ dataViews },
layerId,
columnId,
group
) || { invalid: false }),
})}
</>
)}

View file

@ -6,7 +6,7 @@
*/
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { IndexPatternServiceAPI } from '../../../indexpattern_service/service';
import { IndexPatternServiceAPI } from '../../../data_views_service/service';
import {
Visualization,

View file

@ -7,7 +7,7 @@
import React from 'react';
import { DataPanelWrapper } from './data_panel_wrapper';
import { Datasource, DatasourceDataPanelProps } from '../../types';
import { Datasource, DatasourceDataPanelProps, VisualizationMap } from '../../types';
import { DragDropIdentifier } from '../../drag_drop';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { createMockFramePublicAPI, mockStoreDeps, mountWithProvider } from '../../mocks';
@ -26,12 +26,14 @@ describe('Data Panel Wrapper', () => {
const datasourceMap = {
activeDatasource: {
renderDataPanel,
getUsedDataViews: jest.fn(),
} as unknown as Datasource,
};
const mountResult = await mountWithProvider(
<DataPanelWrapper
datasourceMap={datasourceMap}
visualizationMap={{} as VisualizationMap}
showNoDataPopover={() => {}}
core={{} as DatasourceDataPanelProps['core']}
dropOntoWorkspace={(field: DragDropIdentifier) => {}}

View file

@ -16,7 +16,13 @@ import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { Easteregg } from './easteregg';
import { NativeRenderer } from '../../native_renderer';
import { DragContext, DragDropIdentifier } from '../../drag_drop';
import { StateSetter, DatasourceDataPanelProps, DatasourceMap, FramePublicAPI } from '../../types';
import {
StateSetter,
DatasourceDataPanelProps,
DatasourceMap,
FramePublicAPI,
VisualizationMap,
} from '../../types';
import {
switchDatasource,
useLensDispatch,
@ -27,14 +33,16 @@ import {
selectExecutionContext,
selectActiveDatasourceId,
selectDatasourceStates,
selectVisualizationState,
} from '../../state_management';
import { initializeSources } from './state_helpers';
import type { IndexPatternServiceAPI } from '../../indexpattern_service/service';
import type { IndexPatternServiceAPI } from '../../data_views_service/service';
import { changeIndexPattern } from '../../state_management/lens_slice';
import { getInitialDataViewsObject } from '../../utils';
interface DataPanelWrapperProps {
datasourceMap: DatasourceMap;
visualizationMap: VisualizationMap;
showNoDataPopover: () => void;
core: DatasourceDataPanelProps['core'];
dropOntoWorkspace: (field: DragDropIdentifier) => void;
@ -48,6 +56,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
const externalContext = useLensSelector(selectExecutionContext);
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
const datasourceStates = useLensSelector(selectDatasourceStates);
const visualizationState = useLensSelector(selectVisualizationState);
const datasourceIsLoading = activeDatasourceId
? datasourceStates[activeDatasourceId].isLoading
@ -74,6 +83,8 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
initializeSources(
{
datasourceMap: props.datasourceMap,
visualizationMap: props.visualizationMap,
visualizationState,
datasourceStates,
dataViews: props.plugins.dataViews,
references: undefined,
@ -84,29 +95,38 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
{
isFullEditor: true,
}
).then(({ states, indexPatterns, indexPatternRefs }) => {
const newDatasourceStates = Object.entries(states).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
...datasourceState,
isLoading: false,
},
}),
{}
);
dispatchLens(
setState({
datasourceStates: newDatasourceStates,
dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs),
})
);
});
).then(
({
datasourceStates: newDatasourceStates,
visualizationState: newVizState,
indexPatterns,
indexPatternRefs,
}) => {
dispatchLens(
setState({
visualization: { ...visualizationState, state: newVizState },
datasourceStates: Object.entries(newDatasourceStates).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
...datasourceState,
isLoading: false,
},
}),
{}
),
dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs),
})
);
}
);
}
}, [
datasourceStates,
visualizationState,
activeDatasourceId,
props.datasourceMap,
props.visualizationMap,
dispatchLens,
props.plugins.dataViews,
props.core.uiSettings,
@ -145,6 +165,19 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
onChangeIndexPattern,
indexPatternService: props.indexPatternService,
frame: props.frame,
// Visualization can handle dataViews, so need to pass to the data panel the full list of used dataViews
usedIndexPatterns: [
...((activeDatasourceId &&
props.datasourceMap[activeDatasourceId]?.getUsedDataViews(
datasourceStates[activeDatasourceId].state
)) ||
[]),
...((visualizationState.activeId &&
props.visualizationMap[visualizationState.activeId]?.getUsedDataViews?.(
visualizationState.state
)) ||
[]),
],
};
const [showDatasourceSwitcher, setDatasourceSwitcher] = useState(false);

View file

@ -30,7 +30,7 @@ import {
} from '../../state_management';
import type { LensInspector } from '../../lens_inspector_service';
import { ErrorBoundary, showMemoizedErrorNotification } from '../../lens_ui_errors';
import { IndexPatternServiceAPI } from '../../indexpattern_service/service';
import { IndexPatternServiceAPI } from '../../data_views_service/service';
export interface EditorFrameProps {
datasourceMap: DatasourceMap;
@ -120,6 +120,7 @@ export function EditorFrame(props: EditorFrameProps) {
core={props.core}
plugins={props.plugins}
datasourceMap={datasourceMap}
visualizationMap={visualizationMap}
showNoDataPopover={props.showNoDataPopover}
dropOntoWorkspace={dropOntoWorkspace}
hasSuggestionForField={hasSuggestionForField}

View file

@ -37,9 +37,9 @@ import {
getMissingVisualizationTypeError,
getUnknownVisualizationTypeError,
} from '../error_helper';
import type { DatasourceStates, DataViewsState } from '../../state_management';
import type { DatasourceStates, DataViewsState, VisualizationState } from '../../state_management';
import { readFromStorage } from '../../settings_storage';
import { loadIndexPatternRefs, loadIndexPatterns } from '../../indexpattern_service/loader';
import { loadIndexPatternRefs, loadIndexPatterns } from '../../data_views_service/loader';
function getIndexPatterns(
references?: SavedObjectReference[],
@ -158,6 +158,8 @@ export async function initializeSources(
{
dataViews,
datasourceMap,
visualizationMap,
visualizationState,
datasourceStates,
storage,
defaultIndexPatternId,
@ -167,6 +169,8 @@ export async function initializeSources(
}: {
dataViews: DataViewsContract;
datasourceMap: DatasourceMap;
visualizationMap: VisualizationMap;
visualizationState: VisualizationState;
datasourceStates: DatasourceStates;
defaultIndexPatternId: string;
storage: IStorageWrapper;
@ -192,7 +196,7 @@ export async function initializeSources(
return {
indexPatterns,
indexPatternRefs,
states: initializeDatasources({
datasourceStates: initializeDatasources({
datasourceMap,
datasourceStates,
initialContext,
@ -200,9 +204,34 @@ export async function initializeSources(
indexPatterns,
references,
}),
visualizationState: initializeVisualization({
visualizationMap,
visualizationState,
references,
}),
};
}
export function initializeVisualization({
visualizationMap,
visualizationState,
references,
}: {
visualizationState: VisualizationState;
visualizationMap: VisualizationMap;
references?: SavedObjectReference[];
}) {
if (visualizationState?.activeId) {
return (
visualizationMap[visualizationState.activeId]?.fromPersistableState?.(
visualizationState.state,
references
) ?? visualizationState.state
);
}
return visualizationState.state;
}
export function initializeDatasources({
datasourceMap,
datasourceStates,
@ -271,7 +300,7 @@ export async function persistedStateToExpression(
): Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }> {
const {
state: {
visualization: visualizationState,
visualization: persistedVisualizationState,
datasourceStates: persistedDatasourceStates,
adHocDataViews,
internalReferences,
@ -294,6 +323,14 @@ export async function persistedStateToExpression(
};
}
const visualization = visualizations[visualizationType!];
const visualizationState = initializeVisualization({
visualizationMap: visualizations,
visualizationState: {
state: persistedVisualizationState,
activeId: visualizationType,
},
references: [...references, ...(internalReferences || [])],
});
const datasourceStatesFromSO = Object.fromEntries(
Object.entries(persistedDatasourceStates).map(([id, state]) => [
id,
@ -404,15 +441,15 @@ export const validateDatasourceAndVisualization = (
currentDatasourceState: unknown | null,
currentVisualization: Visualization | null,
currentVisualizationState: unknown | undefined,
{ datasourceLayers, dataViews }: Pick<FramePublicAPI, 'datasourceLayers' | 'dataViews'>
frame: Pick<FramePublicAPI, 'datasourceLayers' | 'dataViews'>
): ErrorMessage[] | undefined => {
try {
const datasourceValidationErrors = currentDatasourceState
? currentDataSource?.getErrorMessages(currentDatasourceState, dataViews.indexPatterns)
? currentDataSource?.getErrorMessages(currentDatasourceState, frame.dataViews.indexPatterns)
: undefined;
const visualizationValidationErrors = currentVisualizationState
? currentVisualization?.getErrorMessages(currentVisualizationState, datasourceLayers)
? currentVisualization?.getErrorMessages(currentVisualizationState, frame)
: undefined;
if (datasourceValidationErrors?.length || visualizationValidationErrors?.length) {

View file

@ -243,6 +243,7 @@ export function SuggestionPanel({
visualizationMap[visualizationId],
suggestionVisualizationState,
{
...frame,
dataViews: frame.dataViews,
datasourceLayers: getDatasourceLayers(
suggestionDatasourceId

View file

@ -81,7 +81,7 @@ import { getLensInspectorService, LensInspector } from '../lens_inspector_servic
import { SharingSavedObjectProps, VisualizationDisplayOptions } from '../types';
import { getActiveDatasourceIdFromDoc, getIndexPatternsObjects, inferTimeField } from '../utils';
import { getLayerMetaInfo, combineQueryAndFilters } from '../app_plugin/show_underlying_data';
import { convertDataViewIntoLensIndexPattern } from '../indexpattern_service/loader';
import { convertDataViewIntoLensIndexPattern } from '../data_views_service/loader';
export type LensSavedObjectAttributes = Omit<Document, 'savedObjectId' | 'type'>;

View file

@ -34,7 +34,7 @@ import { createIndexPatternServiceMock } from '../mocks/data_views_service_mock'
import { createMockFramePublicAPI } from '../mocks';
import { DataViewsState } from '../state_management';
import { ExistingFieldsMap, FramePublicAPI, IndexPattern } from '../types';
import { IndexPatternServiceProps } from '../indexpattern_service/service';
import { IndexPatternServiceProps } from '../data_views_service/service';
import { FieldSpec, DataView } from '@kbn/data-views-plugin/public';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
@ -408,7 +408,7 @@ describe('IndexPattern Data Panel', () => {
stateChanges?: Partial<IndexPatternPrivateState>,
propChanges?: Partial<Props>
) {
const inst = mountWithIntl(<IndexPatternDataPanel {...props} />);
const inst = mountWithIntl<Props>(<IndexPatternDataPanel {...props} />);
await act(async () => {
inst.update();
@ -418,10 +418,10 @@ describe('IndexPattern Data Panel', () => {
await act(async () => {
inst.setProps({
...props,
...((propChanges as object) || {}),
...(propChanges || {}),
state: {
...props.state,
...((stateChanges as object) || {}),
...(stateChanges || {}),
},
});
inst.update();

View file

@ -47,7 +47,7 @@ import { LensFieldIcon } from '../shared_components/field_picker/lens_field_icon
import { getFieldType } from './pure_utils';
import { FieldGroups, FieldList } from './field_list';
import { fieldContainsData, fieldExists } from '../shared_components';
import { IndexPatternServiceAPI } from '../indexpattern_service/service';
import { IndexPatternServiceAPI } from '../data_views_service/service';
export type Props = Omit<
DatasourceDataPanelProps<IndexPatternPrivateState>,
@ -143,15 +143,16 @@ export function IndexPatternDataPanel({
indexPatternService,
frame,
onIndexPatternRefresh,
usedIndexPatterns,
}: Props) {
const { indexPatterns, indexPatternRefs, existingFields, isFirstExistenceFetch } =
frame.dataViews;
const { currentIndexPatternId } = state;
const indexPatternList = uniq(
Object.values(state.layers)
.map((l) => l.indexPatternId)
.concat(currentIndexPatternId)
(
usedIndexPatterns ?? Object.values(state.layers).map(({ indexPatternId }) => indexPatternId)
).concat(currentIndexPatternId)
)
.filter((id) => !!indexPatterns[id])
.sort()
@ -283,7 +284,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
onIndexPatternRefresh,
}: Omit<
DatasourceDataPanelProps,
'state' | 'setState' | 'showNoDataPopover' | 'core' | 'onChangeIndexPattern'
'state' | 'setState' | 'showNoDataPopover' | 'core' | 'onChangeIndexPattern' | 'usedIndexPatterns'
> & {
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;

View file

@ -55,8 +55,8 @@ jest.mock('./reference_editor', () => ({
ReferenceEditor: () => null,
}));
jest.mock('../loader');
jest.mock('../query_input', () => ({
QueryInput: () => null,
jest.mock('@kbn/unified-search-plugin/public', () => ({
QueryStringInput: () => null,
}));
jest.mock('../operations');

View file

@ -19,10 +19,8 @@ import {
} from '@elastic/eui';
import type { Query } from '@kbn/es-query';
import { GenericIndexPatternColumn, operationDefinitionMap } from '../operations';
import { validateQuery } from '../operations/definitions/filters';
import { QueryInput } from '../query_input';
import type { IndexPatternLayer } from '../types';
import { useDebouncedValue } from '../../shared_components';
import { QueryInput, useDebouncedValue, validateQuery } from '../../shared_components';
import type { IndexPattern } from '../../types';
const filterByLabel = i18n.translate('xpack.lens.indexPattern.filterBy.label', {

View file

@ -110,6 +110,9 @@ export function columnToOperation(
? 'version'
: undefined,
hasTimeShift: Boolean(timeShift),
interval: isColumnOfType<DateHistogramIndexPatternColumn>('date_histogram', column)
? column.params.interval
: undefined,
};
}
@ -751,6 +754,9 @@ export function getIndexPatternDatasource({
getUsedDataView: (state: IndexPatternPrivateState, layerId: string) => {
return state.layers[layerId].indexPatternId;
},
getUsedDataViews: (state) => {
return Object.values(state.layers).map(({ indexPatternId }) => indexPatternId);
},
};
return indexPatternDatasource;

View file

@ -2214,6 +2214,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
interval: 'auto',
},
},
{
@ -2225,6 +2226,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ratio',
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
],
@ -2289,6 +2291,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ordinal',
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
{
@ -2300,6 +2303,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
interval: 'auto',
},
},
{
@ -2311,6 +2315,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ratio',
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
],
@ -2396,6 +2401,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ordinal',
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
{
@ -2407,6 +2413,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
interval: 'auto',
},
},
{
@ -2418,6 +2425,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ratio',
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
],
@ -2526,6 +2534,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ordinal',
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
{
@ -2537,6 +2546,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
interval: 'auto',
},
},
{
@ -2548,6 +2558,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
],
@ -3126,6 +3137,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
interval: 'auto',
},
},
{
@ -3137,6 +3149,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
{
@ -3148,6 +3161,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
],
@ -3213,6 +3227,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
interval: 'auto',
},
},
{
@ -3224,6 +3239,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
{
@ -3235,6 +3251,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
interval: undefined,
},
},
],

View file

@ -14,7 +14,7 @@ import {
} from './loader';
import { IndexPatternPersistedState, IndexPatternPrivateState } from './types';
import { DateHistogramIndexPatternColumn, TermsIndexPatternColumn } from './operations';
import { sampleIndexPatterns } from '../indexpattern_service/mocks';
import { sampleIndexPatterns } from '../data_views_service/mocks';
const createMockStorage = (lastData?: Record<string, string>) => {
return {

View file

@ -12,8 +12,8 @@ import { EuiPopover, EuiLink } from '@elastic/eui';
import { createMockedIndexPattern } from '../../../mocks';
import { FilterPopover } from './filter_popover';
import { LabelInput } from '../shared_components';
import { QueryInput } from '../../../query_input';
import { QueryStringInput } from '@kbn/unified-search-plugin/public';
import { QueryInput } from '../../../../shared_components';
jest.mock('.', () => ({
isQueryValid: () => true,

View file

@ -10,10 +10,12 @@ import './filter_popover.scss';
import React from 'react';
import { EuiPopover, EuiSpacer } from '@elastic/eui';
import type { Query } from '@kbn/es-query';
// Need to keep it separate to make it work Jest mocks in dimension_panel tests
// import { QueryInput } from '../../../../shared_components/query_input';
import { isQueryValid, QueryInput } from '../../../../shared_components';
import { IndexPattern } from '../../../../types';
import { FilterValue, defaultLabel, isQueryValid } from '.';
import { FilterValue, defaultLabel } from '.';
import { LabelInput } from '../shared_components';
import { QueryInput } from '../../../query_input';
export const FilterPopover = ({
filter,

View file

@ -7,7 +7,6 @@
import './filters.scss';
import React, { useState } from 'react';
import { fromKueryExpression, luceneStringToDsl, toElasticsearchQuery } from '@kbn/es-query';
import { omit } from 'lodash';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiLink, htmlIdGenerator } from '@elastic/eui';
@ -15,12 +14,17 @@ import type { Query } from '@kbn/es-query';
import type { AggFunctionsMapping } from '@kbn/data-plugin/public';
import { queryFilterToAst } from '@kbn/data-plugin/common';
import { buildExpressionFunction } from '@kbn/expressions-plugin/public';
import {
DragDropBuckets,
DraggableBucketContainer,
isQueryValid,
NewBucketButton,
} from '../../../../shared_components';
import { IndexPattern } from '../../../../types';
import { updateColumnParam } from '../../layer_helpers';
import type { OperationDefinition } from '..';
import type { BaseIndexPatternColumn } from '../column_types';
import { FilterPopover } from './filter_popover';
import { DragDropBuckets, DraggableBucketContainer, NewBucketButton } from '../shared_components';
const generateId = htmlIdGenerator();
const OPERATION_NAME = 'filters';
@ -54,29 +58,6 @@ const defaultFilter: Filter = {
label: '',
};
export const validateQuery = (input: Query | undefined, indexPattern: IndexPattern) => {
let isValid = true;
let error: string | undefined;
try {
if (input) {
if (input.language === 'kuery') {
toElasticsearchQuery(fromKueryExpression(input.query), indexPattern);
} else {
luceneStringToDsl(input.query);
}
}
} catch (e) {
isValid = false;
error = e.message;
}
return { isValid, error };
};
export const isQueryValid = (input: Query, indexPattern: IndexPattern) =>
validateQuery(input, indexPattern).isValid;
export interface FiltersIndexPatternColumn extends BaseIndexPatternColumn {
operationType: typeof OPERATION_NAME;
params: {

View file

@ -10,13 +10,13 @@ import { createFormulaPublicApi, FormulaPublicApi } from './formula_public_api';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DateHistogramIndexPatternColumn, PersistedIndexPatternLayer } from '../../../types';
import { convertDataViewIntoLensIndexPattern } from '../../../../indexpattern_service/loader';
import { convertDataViewIntoLensIndexPattern } from '../../../../data_views_service/loader';
jest.mock('./parse', () => ({
insertOrReplaceFormulaColumn: jest.fn().mockReturnValue({}),
}));
jest.mock('../../../../indexpattern_service/loader', () => ({
jest.mock('../../../../data_views_service/loader', () => ({
convertDataViewIntoLensIndexPattern: jest.fn((v) => v),
}));

View file

@ -6,7 +6,7 @@
*/
import type { DataView } from '@kbn/data-views-plugin/public';
import { convertDataViewIntoLensIndexPattern } from '../../../../indexpattern_service/loader';
import { convertDataViewIntoLensIndexPattern } from '../../../../data_views_service/loader';
import type { IndexPattern } from '../../../../types';
import type { PersistedIndexPatternLayer } from '../../../types';

View file

@ -23,15 +23,15 @@ import {
keys,
} from '@elastic/eui';
import { IFieldFormat } from '@kbn/field-formats-plugin/common';
import { useDebounceWithOptions } from '../../../../shared_components';
import { RangeTypeLens, isValidRange } from './ranges';
import { FROM_PLACEHOLDER, TO_PLACEHOLDER, TYPING_DEBOUNCE_TIME } from './constants';
import {
NewBucketButton,
DragDropBuckets,
DraggableBucketContainer,
LabelInput,
} from '../shared_components';
NewBucketButton,
useDebounceWithOptions,
} from '../../../../shared_components';
import { RangeTypeLens, isValidRange } from './ranges';
import { FROM_PLACEHOLDER, TO_PLACEHOLDER, TYPING_DEBOUNCE_TIME } from './constants';
import { LabelInput } from '../shared_components';
import { isValidNumber } from '../helpers';
const generateId = htmlIdGenerator();

View file

@ -26,7 +26,7 @@ import {
SLICES,
} from './constants';
import { RangePopover } from './advanced_editor';
import { DragDropBuckets } from '../shared_components';
import { DragDropBuckets } from '../../../../shared_components';
import { getFieldByNameFactory } from '../../../pure_helpers';
import { IndexPattern } from '../../../../types';

View file

@ -4,6 +4,5 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './buckets';
export * from './label_input';
export * from './form_row';

View file

@ -18,12 +18,16 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ExistingFieldsMap, IndexPattern } from '../../../../types';
import { TooltipWrapper, useDebouncedValue } from '../../../../shared_components';
import {
DragDropBuckets,
NewBucketButton,
TooltipWrapper,
useDebouncedValue,
} from '../../../../shared_components';
import { FieldSelect } from '../../../dimension_panel/field_select';
import type { TermsIndexPatternColumn } from './types';
import type { OperationSupportMatrix } from '../../../dimension_panel';
import { supportedTypes } from './constants';
import { DragDropBuckets, NewBucketButton } from '../shared_components';
const generateId = htmlIdGenerator();
export const MAX_MULTI_FIELDS_SIZE = 3;

View file

@ -38,12 +38,13 @@ import {
} from './operations';
import { getInvalidFieldMessage, isColumnOfType } from './operations/definitions/helpers';
import { FiltersIndexPatternColumn, isQueryValid } from './operations/definitions/filters';
import { FiltersIndexPatternColumn } from './operations/definitions/filters';
import { hasField } from './pure_utils';
import { mergeLayer } from './state_helpers';
import { supportsRarityRanking } from './operations/definitions/terms';
import { DEFAULT_MAX_DOC_COUNT } from './operations/definitions/terms/constants';
import { getOriginalId } from '../../common/expressions';
import { isQueryValid } from '../shared_components';
export function isColumnInvalid(
layer: IndexPatternLayer,

View file

@ -13,7 +13,7 @@ import {
createIndexPatternService,
IndexPatternServiceProps,
IndexPatternServiceAPI,
} from '../indexpattern_service/service';
} from '../data_views_service/service';
export function createIndexPatternServiceMock({
core = coreMock.createStart(),

View file

@ -61,6 +61,7 @@ export function createMockDatasource(id: string): DatasourceMock {
isValidColumn: jest.fn(),
isEqual: jest.fn(),
getUsedDataView: jest.fn(),
getUsedDataViews: jest.fn(),
onRefreshIndexPattern: jest.fn(),
};
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { createMockDataViewsState } from '../data_views_service/mocks';
import { FramePublicAPI, FrameDatasourceAPI } from '../types';
export { mockDataPlugin } from './data_plugin_mock';
export {
@ -29,28 +30,36 @@ export { lensPluginMock } from './lens_plugin_mock';
export type FrameMock = jest.Mocked<FramePublicAPI>;
export const createMockFramePublicAPI = (): FrameMock => ({
datasourceLayers: {},
dateRange: { fromDate: '2022-03-17T08:25:00.000Z', toDate: '2022-04-17T08:25:00.000Z' },
dataViews: {
indexPatterns: {},
indexPatternRefs: [],
isFirstExistenceFetch: true,
existingFields: {},
export const createMockFramePublicAPI = ({
datasourceLayers,
dateRange,
dataViews,
activeData,
}: Partial<FramePublicAPI> = {}): FrameMock => ({
datasourceLayers: datasourceLayers ?? {},
dateRange: dateRange ?? {
fromDate: '2022-03-17T08:25:00.000Z',
toDate: '2022-04-17T08:25:00.000Z',
},
dataViews: createMockDataViewsState(dataViews),
activeData,
});
export type FrameDatasourceMock = jest.Mocked<FrameDatasourceAPI>;
export const createMockFrameDatasourceAPI = (): FrameDatasourceMock => ({
datasourceLayers: {},
dateRange: { fromDate: '2022-03-17T08:25:00.000Z', toDate: '2022-04-17T08:25:00.000Z' },
query: { query: '', language: 'lucene' },
filters: [],
dataViews: {
indexPatterns: {},
indexPatternRefs: [],
isFirstExistenceFetch: true,
existingFields: {},
export const createMockFrameDatasourceAPI = ({
datasourceLayers,
dateRange,
dataViews,
query,
filters,
}: Partial<FrameDatasourceAPI> = {}): FrameDatasourceMock => ({
datasourceLayers: datasourceLayers ?? {},
dateRange: dateRange ?? {
fromDate: '2022-03-17T08:25:00.000Z',
toDate: '2022-04-17T08:25:00.000Z',
},
query: query ?? { query: '', language: 'lucene' },
filters: filters ?? [],
dataViews: createMockDataViewsState(dataViews),
});

View file

@ -11,7 +11,7 @@ import { Visualization, VisualizationMap } from '../types';
export function createMockVisualization(id = 'testVis'): jest.Mocked<Visualization> {
return {
id,
clearLayer: jest.fn((state, _layerId) => state),
clearLayer: jest.fn((state, _layerId, _indexPatternId) => state),
removeLayer: jest.fn(),
getLayerIds: jest.fn((_state) => ['layer1']),
getSupportedLayers: jest.fn(() => [{ type: layerTypes.DATA, label: 'Data Layer' }]),

View file

@ -9,7 +9,7 @@ import React from 'react';
import { mount, shallow } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { EuiIcon } from '@elastic/eui';
import { DragDropBuckets, DraggableBucketContainer } from '.';
import { DragDropBuckets, DraggableBucketContainer } from './buckets';
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');

View file

@ -12,6 +12,12 @@ export { PalettePicker } from './palette_picker';
export { FieldPicker, LensFieldIcon, TruncatedLabel } from './field_picker';
export type { FieldOption, FieldOptionValue } from './field_picker';
export { ChangeIndexPattern, fieldExists, fieldContainsData } from './dataview_picker';
export { QueryInput, isQueryValid, validateQuery } from './query_input';
export {
NewBucketButton,
DraggableBucketContainer,
DragDropBuckets,
} from './drag_drop_bucket/buckets';
export { RangeInputField } from './range_input_field';
export {
BucketAxisBoundsControl,

View file

@ -0,0 +1,33 @@
/*
* 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 type { Query } from '@kbn/es-query';
import { toElasticsearchQuery, fromKueryExpression, luceneStringToDsl } from '@kbn/es-query';
import { IndexPattern } from '../../types';
export const validateQuery = (input: Query | undefined, indexPattern: IndexPattern) => {
let isValid = true;
let error: string | undefined;
try {
if (input) {
if (input.language === 'kuery') {
toElasticsearchQuery(fromKueryExpression(input.query), indexPattern);
} else {
luceneStringToDsl(input.query);
}
}
} catch (e) {
isValid = false;
error = e.message;
}
return { isValid, error };
};
export const isQueryValid = (input: Query, indexPattern: IndexPattern) =>
validateQuery(input, indexPattern).isValid;

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { QueryInput } from './query_input';
export { validateQuery, isQueryValid } from './helpers';

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
import { isEqual } from 'lodash';
import type { Query } from '@kbn/es-query';
import { QueryStringInput } from '@kbn/unified-search-plugin/public';
import { useDebouncedValue } from '../shared_components';
import { useDebouncedValue } from '../debounced_value';
export const QueryInput = ({
value,

View file

@ -97,7 +97,13 @@ export function loadInitial(
},
autoApplyDisabled: boolean
) {
const { lensServices, datasourceMap, embeddableEditorIncomingState, initialContext } = storeDeps;
const {
lensServices,
datasourceMap,
embeddableEditorIncomingState,
initialContext,
visualizationMap,
} = storeDeps;
const { resolvedDateRange, searchSessionId, isLinkedToOriginatingApp, ...emptyState } =
getPreloadedState(storeDeps);
const { attributeService, notifications, data, dashboardFeatureFlag } = lensServices;
@ -117,6 +123,8 @@ export function loadInitial(
return initializeSources(
{
datasourceMap,
visualizationMap,
visualizationState: lens.visualization,
datasourceStates: lens.datasourceStates,
initialContext,
adHocDataViews: lens.persistedDoc?.state.adHocDataViews,
@ -126,14 +134,14 @@ export function loadInitial(
isFullEditor: true,
}
)
.then(({ states, indexPatterns, indexPatternRefs }) => {
.then(({ datasourceStates, indexPatterns, indexPatternRefs }) => {
store.dispatch(
initEmpty({
newState: {
...emptyState,
dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs),
searchSessionId: data.search.session.getSessionId() || data.search.session.start(),
datasourceStates: Object.entries(states).reduce(
datasourceStates: Object.entries(datasourceStates).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {
@ -187,9 +195,16 @@ export function loadInitial(
const filters = data.query.filterManager.inject(doc.state.filters, doc.references);
// Don't overwrite any pinned filters
data.query.filterManager.setAppFilters(filters);
const docVisualizationState = {
activeId: doc.visualizationType,
state: doc.state.visualization,
};
return initializeSources(
{
datasourceMap,
visualizationMap,
visualizationState: docVisualizationState,
datasourceStates: docDatasourceStates,
references: [...doc.references, ...(doc.state.internalReferences || [])],
initialContext,
@ -200,7 +215,7 @@ export function loadInitial(
},
{ isFullEditor: true }
)
.then(({ states, indexPatterns, indexPatternRefs }) => {
.then(({ datasourceStates, visualizationState, indexPatterns, indexPatternRefs }) => {
const currentSessionId = data.search.session.getSessionId();
store.dispatch(
setState({
@ -219,10 +234,10 @@ export function loadInitial(
activeDatasourceId: getInitialDatasourceId(datasourceMap, doc),
visualization: {
activeId: doc.visualizationType,
state: doc.state.visualization,
state: visualizationState,
},
dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs),
datasourceStates: Object.entries(states).reduce(
datasourceStates: Object.entries(datasourceStates).reduce(
(state, [datasourceId, datasourceState]) => ({
...state,
[datasourceId]: {

View file

@ -227,6 +227,7 @@ describe('lensSlice', () => {
removeLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).filter((id: string) => id !== layerId),
insertLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId],
getCurrentIndexPatternId: jest.fn(() => 'indexPattern1'),
};
};
const datasourceStates = {

View file

@ -288,6 +288,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
}
) => {
const activeVisualization = visualizationMap[visualizationId];
const activeDataSource = datasourceMap[state.activeDatasourceId!];
const isOnlyLayer =
getRemoveOperation(
activeVisualization,
@ -309,9 +310,13 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
}
);
state.stagedPreview = undefined;
// reuse the activeDatasource current dataView id for the moment
const currentDataViewsId = activeDataSource.getCurrentIndexPatternId(
state.datasourceStates[state.activeDatasourceId!].state
);
state.visualization.state =
isOnlyLayer || !activeVisualization.removeLayer
? activeVisualization.clearLayer(state.visualization.state, layerId)
? activeVisualization.clearLayer(state.visualization.state, layerId, currentDataViewsId)
: activeVisualization.removeLayer(state.visualization.state, layerId);
},
[changeIndexPattern.type]: (
@ -352,15 +357,15 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
for (const visualizationId of visualizationIds) {
const activeVisualization =
visualizationId &&
state.visualization.activeId !== visualizationId &&
state.visualization.activeId === visualizationId &&
visualizationMap[visualizationId];
if (activeVisualization && layerId && activeVisualization?.onIndexPatternChange) {
newState.visualization = {
...state.visualization,
state: activeVisualization.onIndexPatternChange(
state.visualization.state,
layerId,
indexPatternId
indexPatternId,
layerId
),
};
}
@ -806,15 +811,20 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
}
const activeVisualization = visualizationMap[state.visualization.activeId];
const activeDatasource = datasourceMap[state.activeDatasourceId];
// reuse the active datasource dataView id for the new layer
const currentDataViewsId = activeDatasource.getCurrentIndexPatternId(
state.datasourceStates[state.activeDatasourceId!].state
);
const visualizationState = activeVisualization.appendLayer!(
state.visualization.state,
layerId,
layerType
layerType,
currentDataViewsId
);
const framePublicAPI = selectFramePublicAPI({ lens: current(state) }, datasourceMap);
const activeDatasource = datasourceMap[state.activeDatasourceId];
const { noDatasource } =
activeVisualization
.getSupportedLayers(visualizationState, framePublicAPI)

View file

@ -34,6 +34,7 @@ export const selectAutoApplyEnabled = (state: LensState) => !state.lens.autoAppl
export const selectChangesApplied = (state: LensState) =>
!state.lens.autoApplyDisabled || Boolean(state.lens.changesApplied);
export const selectDatasourceStates = (state: LensState) => state.lens.datasourceStates;
export const selectVisualizationState = (state: LensState) => state.lens.visualization;
export const selectActiveDatasourceId = (state: LensState) => state.lens.activeDatasourceId;
export const selectActiveData = (state: LensState) => state.lens.activeData;
export const selectDataViews = (state: LensState) => state.lens.dataViews;
@ -97,7 +98,9 @@ export const selectSavedObjectFormat = createSelector(
{ datasourceMap, visualizationMap, extractFilterReferences }
) => {
const activeVisualization =
visualization.state && visualization.activeId && visualizationMap[visualization.activeId];
visualization.state && visualization.activeId
? visualizationMap[visualization.activeId]
: null;
const activeDatasource =
datasourceStates && activeDatasourceId && !datasourceStates[activeDatasourceId].isLoading
? datasourceMap[activeDatasourceId]
@ -132,6 +135,20 @@ export const selectSavedObjectFormat = createSelector(
});
});
let persistibleVisualizationState = visualization.state;
if (activeVisualization.getPersistableState) {
const { state: persistableState, savedObjectReferences } =
activeVisualization.getPersistableState(visualization.state);
persistibleVisualizationState = persistableState;
savedObjectReferences.forEach((r) => {
if (r.type === 'index-pattern' && adHocDataViews[r.id]) {
internalReferences.push(r);
} else {
references.push(r);
}
});
}
const persistableAdHocDataViews = Object.fromEntries(
Object.entries(adHocDataViews).map(([id, dataView]) => {
const { references: dataViewReferences, state } =
@ -162,7 +179,7 @@ export const selectSavedObjectFormat = createSelector(
type: 'lens',
references,
state: {
visualization: visualization.state,
visualization: persistibleVisualizationState,
query,
filters: [...persistableFilters, ...adHocFilters],
datasourceStates: persistibleDatasourceStates,

View file

@ -48,7 +48,7 @@ import {
import type { LensInspector } from './lens_inspector_service';
import type { FormatSelectorOptions } from './indexpattern_datasource/dimension_panel/format_selector';
import type { DataViewsState } from './state_management/types';
import type { IndexPatternServiceAPI } from './indexpattern_service/service';
import type { IndexPatternServiceAPI } from './data_views_service/service';
import type { Document } from './persistence/saved_object_store';
export interface IndexPatternRef {
@ -118,8 +118,8 @@ export interface EditorFrameSetup {
registerDatasource: <T, P>(
datasource: Datasource<T, P> | (() => Promise<Datasource<T, P>>)
) => void;
registerVisualization: <T>(
visualization: Visualization<T> | (() => Promise<Visualization<T>>)
registerVisualization: <T, P>(
visualization: Visualization<T, P> | (() => Promise<Visualization<T, P>>)
) => void;
}
@ -456,6 +456,10 @@ export interface Datasource<T = unknown, P = unknown> {
* Get the used DataView value from state
*/
getUsedDataView: (state: T, layerId: string) => string;
/**
* Get all the used DataViews from state
*/
getUsedDataViews: (state: T) => string[];
}
export interface DatasourceFixAction<T> {
@ -517,6 +521,7 @@ export interface DatasourceDataPanelProps<T = unknown> {
uiActions: UiActionsStart;
indexPatternService: IndexPatternServiceAPI;
frame: FramePublicAPI;
usedIndexPatterns?: string[];
}
interface SharedDimensionProps {
@ -638,6 +643,7 @@ export interface Operation extends OperationMetadata {
}
export interface OperationMetadata {
interval?: string;
// The output of this operation will have this data type
dataType: DataType;
// A bucketed operation is grouped by duplicate values, otherwise each row is
@ -672,12 +678,10 @@ export interface VisualizationConfigProps<T = unknown> {
export type VisualizationLayerWidgetProps<T = unknown> = VisualizationConfigProps<T> & {
setState: (newState: T) => void;
onChangeIndexPattern: (indexPatternId: string, layerId: string) => void;
onChangeIndexPattern: (indexPatternId: string) => void;
};
export type VisualizationLayerHeaderContentProps<T = unknown> = VisualizationLayerWidgetProps<T> & {
defaultIndexPatternId: string;
};
export type VisualizationLayerHeaderContentProps<T = unknown> = VisualizationLayerWidgetProps<T>;
export interface VisualizationToolbarProps<T = unknown> {
setState: (newState: T) => void;
@ -892,18 +896,23 @@ export interface VisualizationDisplayOptions {
noPadding?: boolean;
}
export interface Visualization<T = unknown> {
export interface Visualization<T = unknown, P = unknown> {
/** Plugin ID, such as "lnsXY" */
id: string;
/**
* Initialize is allowed to modify the state stored in memory. The initialize function
* is called with a previous state in two cases:
* - Loadingn from a saved visualization
* - Loading from a saved visualization
* - When using suggestions, the suggested state is passed in
*/
initialize: (addNewLayer: () => string, state?: T, mainPalette?: PaletteOutput) => T;
/**
* Retrieve the used DataViews in the visualization
*/
getUsedDataViews?: (state?: T) => string[];
getMainPalette?: (state: T) => undefined | PaletteOutput;
/**
@ -926,15 +935,18 @@ export interface Visualization<T = unknown> {
switchVisualizationType?: (visualizationTypeId: string, state: T) => T;
/** Description is displayed as the clickable text in the chart switcher */
getDescription: (state: T) => { icon?: IconType; label: string };
/** Visualizations can have references as well */
getPersistableState?: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] };
/** Hydrate from persistable state and references to final state */
fromPersistableState?: (state: P, references?: SavedObjectReference[]) => T;
/** Frame needs to know which layers the visualization is currently using */
getLayerIds: (state: T) => string[];
/** Reset button on each layer triggers this */
clearLayer: (state: T, layerId: string) => T;
clearLayer: (state: T, layerId: string, indexPatternId: string) => T;
/** Optional, if the visualization supports multiple layers */
removeLayer?: (state: T, layerId: string) => T;
/** Track added layers in internal state */
appendLayer?: (state: T, layerId: string, type: LayerType, indexPatternId?: string) => T;
appendLayer?: (state: T, layerId: string, type: LayerType, indexPatternId: string) => T;
/** Retrieve a list of supported layer types with initialization data */
getSupportedLayers: (
@ -1088,7 +1100,7 @@ export interface Visualization<T = unknown> {
*/
getErrorMessages: (
state: T,
datasourceLayers?: DatasourceLayers
frame?: Pick<FramePublicAPI, 'datasourceLayers' | 'dataViews'>
) =>
| Array<{
shortMessage: string;
@ -1096,6 +1108,14 @@ export interface Visualization<T = unknown> {
}>
| undefined;
validateColumn?: (
state: T,
frame: Pick<FramePublicAPI, 'dataViews'>,
layerId: string,
columnId: string,
group?: VisualizationDimensionGroupConfig
) => { invalid: boolean; invalidMessage?: string };
/**
* The frame calls this function to display warnings about visualization
*/

View file

@ -22,7 +22,7 @@ import type {
IndexPatternRef,
} from './types';
import type { DatasourceStates, VisualizationState } from './state_management';
import { IndexPatternServiceAPI } from './indexpattern_service/service';
import { IndexPatternServiceAPI } from './data_views_service/service';
export function getVisualizeGeoFieldMessage(fieldType: string) {
return i18n.translate('xpack.lens.visualizeGeoFieldMessage', {
@ -113,9 +113,13 @@ export async function refreshIndexPatternsList({
export function getIndexPatternsIds({
activeDatasources,
datasourceStates,
visualizationState,
activeVisualization,
}: {
activeDatasources: Record<string, Datasource>;
datasourceStates: DatasourceStates;
visualizationState: unknown;
activeVisualization?: Visualization;
}): string[] {
let currentIndexPatternId: string | undefined;
const references: SavedObjectReference[] = [];
@ -125,6 +129,11 @@ export function getIndexPatternsIds({
currentIndexPatternId = indexPatternId;
references.push(...savedObjectReferences);
});
if (activeVisualization?.getPersistableState) {
const { savedObjectReferences } = activeVisualization.getPersistableState(visualizationState);
references.push(...savedObjectReferences);
}
const referencesIds = references
.filter(({ type }) => type === 'index-pattern')
.map(({ id }) => id);

View file

@ -70,7 +70,7 @@ describe('Datatable Visualization', () => {
layerType: layerTypes.DATA,
columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }],
};
expect(datatableVisualization.clearLayer(state, 'baz')).toMatchObject({
expect(datatableVisualization.clearLayer(state, 'baz', 'indexPattern1')).toMatchObject({
layerId: 'baz',
layerType: layerTypes.DATA,
columns: [],

View file

@ -70,7 +70,7 @@ describe('metric_visualization', () => {
describe('#clearLayer', () => {
it('returns a clean layer', () => {
(generateId as jest.Mock).mockReturnValueOnce('test-id1');
expect(metricVisualization.clearLayer(exampleState(), 'l1')).toEqual({
expect(metricVisualization.clearLayer(exampleState(), 'l1', 'indexPattern1')).toEqual({
accessor: undefined,
layerId: 'l1',
layerType: layerTypes.DATA,

View file

@ -515,7 +515,7 @@ describe('metric visualization', () => {
});
it('clears a layer', () => {
expect(visualization.clearLayer(fullState, 'some-id')).toMatchInlineSnapshot(`
expect(visualization.clearLayer(fullState, 'some-id', 'indexPattern1')).toMatchInlineSnapshot(`
Object {
"layerId": "first",
"layerType": "data",

View file

@ -8,6 +8,7 @@ Object {
"addTimeMarker": Array [
false,
],
"annotations": Array [],
"emphasizeFitting": Array [
true,
],

View file

@ -5,24 +5,21 @@
* 2.0.
*/
import { createMockFramePublicAPI } from '../../../mocks';
import { FramePublicAPI } from '../../../types';
import { getStaticDate } from './helpers';
const frame: FramePublicAPI = {
datasourceLayers: {},
dateRange: { fromDate: '2022-02-01T00:00:00.000Z', toDate: '2022-04-20T00:00:00.000Z' },
dataViews: {
indexPatterns: {},
indexPatternRefs: [],
existingFields: {},
isFirstExistenceFetch: true,
},
};
describe('annotations helpers', () => {
describe('getStaticDate', () => {
it('should return the middle of the date range on when nothing is configured', () => {
expect(getStaticDate([], frame)).toBe('2022-03-12T00:00:00.000Z');
expect(
getStaticDate(
[],
createMockFramePublicAPI({
dateRange: { fromDate: '2022-02-01T00:00:00.000Z', toDate: '2022-04-20T00:00:00.000Z' },
})
)
).toBe('2022-03-12T00:00:00.000Z');
});
it('should return the middle of the date range value on when there is no active data', () => {
expect(
@ -36,7 +33,9 @@ describe('annotations helpers', () => {
xAccessor: 'a',
},
],
frame
createMockFramePublicAPI({
dateRange: { fromDate: '2022-02-01T00:00:00.000Z', toDate: '2022-04-20T00:00:00.000Z' },
})
)
).toBe('2022-03-12T00:00:00.000Z');
});
@ -64,7 +63,7 @@ describe('annotations helpers', () => {
},
],
},
};
} as FramePublicAPI['activeData'];
expect(
getStaticDate(
[
@ -76,10 +75,10 @@ describe('annotations helpers', () => {
xAccessor: 'a',
},
],
{
...frame,
activeData: activeData as FramePublicAPI['activeData'],
}
createMockFramePublicAPI({
dateRange: { fromDate: '2022-02-01T00:00:00.000Z', toDate: '2022-04-20T00:00:00.000Z' },
activeData,
})
)
).toBe('2022-02-27T23:00:00.000Z');
});
@ -107,7 +106,7 @@ describe('annotations helpers', () => {
},
],
},
};
} as FramePublicAPI['activeData'];
expect(
getStaticDate(
[
@ -119,10 +118,10 @@ describe('annotations helpers', () => {
xAccessor: 'a',
},
],
{
...frame,
activeData: activeData as FramePublicAPI['activeData'],
}
createMockFramePublicAPI({
dateRange: { fromDate: '2022-02-01T00:00:00.000Z', toDate: '2022-04-20T00:00:00.000Z' },
activeData,
})
)
).toBe('2022-03-12T00:00:00.000Z');
});
@ -162,7 +161,7 @@ describe('annotations helpers', () => {
},
],
},
};
} as FramePublicAPI['activeData'];
expect(
getStaticDate(
[
@ -174,10 +173,10 @@ describe('annotations helpers', () => {
xAccessor: 'a',
},
],
{
...frame,
activeData: activeData as FramePublicAPI['activeData'],
}
createMockFramePublicAPI({
dateRange: { fromDate: '2022-02-01T00:00:00.000Z', toDate: '2022-04-20T00:00:00.000Z' },
activeData,
})
)
).toBe('2022-03-26T05:00:00.000Z');
});
@ -242,7 +241,7 @@ describe('annotations helpers', () => {
},
],
},
};
} as FramePublicAPI['activeData'];
expect(
getStaticDate(
[
@ -261,11 +260,11 @@ describe('annotations helpers', () => {
xAccessor: 'd',
},
],
{
...frame,
createMockFramePublicAPI({
activeData,
dateRange: { fromDate: '2020-02-01T00:00:00.000Z', toDate: '2022-09-20T00:00:00.000Z' },
activeData: activeData as FramePublicAPI['activeData'],
}
})
)
).toBe('2020-08-24T12:06:40.000Z');
});

View file

@ -120,6 +120,7 @@ export const getAnnotationsSupportedLayer = (
const getDefaultAnnotationConfig = (id: string, timestamp: string): EventAnnotationConfig => ({
label: defaultAnnotationLabel,
type: 'manual',
key: {
type: 'point_in_time',
timestamp,

View file

@ -32,8 +32,10 @@ export class XyVisualization {
const { getXyVisualization } = await import('../../async_services');
const [coreStart, { charts, data, fieldFormats, eventAnnotation }] =
await core.getStartServices();
const palettes = await charts.palettes.getPalettes();
const eventAnnotationService = await eventAnnotation.getService();
const [palettes, eventAnnotationService] = await Promise.all([
charts.palettes.getPalettes(),
eventAnnotation.getService(),
]);
const useLegacyTimeAxis = core.uiSettings.get(LEGACY_TIME_AXIS);
return getXyVisualization({
core: coreStart,

View file

@ -5,8 +5,17 @@
* 2.0.
*/
import { DistributiveOmit } from '@elastic/eui';
import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
import type { FramePublicAPI, DatasourcePublicAPI } from '../../types';
import type { SavedObjectReference } from '@kbn/core/public';
import { isQueryAnnotationConfig } from '@kbn/event-annotation-plugin/public';
import { i18n } from '@kbn/i18n';
import { validateQuery } from '../../shared_components';
import type {
FramePublicAPI,
DatasourcePublicAPI,
VisualizationDimensionGroupConfig,
} from '../../types';
import {
visualizationTypes,
XYLayerConfig,
@ -14,6 +23,9 @@ import {
XYReferenceLineLayerConfig,
SeriesType,
YConfig,
XYState,
XYPersistedState,
State,
} from './types';
import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization_helpers';
@ -102,3 +114,129 @@ export function hasHistogramSeries(
);
});
}
function getLayerReferenceName(layerId: string) {
return `xy-visualization-layer-${layerId}`;
}
export function extractReferences(state: XYState) {
const savedObjectReferences: SavedObjectReference[] = [];
const persistableLayers: Array<DistributiveOmit<XYLayerConfig, 'indexPatternId'>> = [];
state.layers.forEach((layer) => {
if (isAnnotationsLayer(layer)) {
const { indexPatternId, ...persistableLayer } = layer;
savedObjectReferences.push({
type: 'index-pattern',
id: indexPatternId,
name: getLayerReferenceName(layer.layerId),
});
persistableLayers.push(persistableLayer);
} else {
persistableLayers.push(layer);
}
});
return { savedObjectReferences, state: { ...state, layers: persistableLayers } };
}
export function injectReferences(
state: XYPersistedState,
references?: SavedObjectReference[]
): XYState {
if (!references || !references.length) {
return state as XYState;
}
const fallbackIndexPatternId = references.find(({ type }) => type === 'index-pattern')!.id;
return {
...state,
layers: state.layers.map((layer) => {
if (!isAnnotationsLayer(layer)) {
return layer as XYLayerConfig;
}
return {
...layer,
indexPatternId:
references.find(({ name }) => name === getLayerReferenceName(layer.layerId))?.id ||
fallbackIndexPatternId,
};
}),
};
}
export function validateColumn(
state: State,
frame: Pick<FramePublicAPI, 'dataViews'>,
layerId: string,
columnId: string,
group?: VisualizationDimensionGroupConfig
): { invalid: boolean; invalidMessages?: string[] } {
if (group?.invalid) {
return {
invalid: true,
invalidMessages: group.invalidMessage ? [group.invalidMessage] : undefined,
};
}
const validColumn = { invalid: false };
const layer = state.layers.find((l) => l.layerId === layerId);
if (!layer || !isAnnotationsLayer(layer)) {
return validColumn;
}
const annotation = layer.annotations.find(({ id }) => id === columnId);
if (!annotation || !isQueryAnnotationConfig(annotation)) {
return validColumn;
}
const { dataViews } = frame || {};
const layerDataView = dataViews.indexPatterns[layer.indexPatternId];
const invalidMessages: string[] = [];
if (annotation.timeField && !Boolean(layerDataView.getFieldByName(annotation.timeField))) {
invalidMessages.push(
i18n.translate('xpack.lens.xyChart.annotationError.timeFieldNotFound', {
defaultMessage: 'Time field {timeField} not found in data view {dataView}',
values: { timeField: annotation.timeField, dataView: layerDataView.title },
})
);
}
const { isValid, error } = validateQuery(annotation?.filter, layerDataView);
if (!isValid && error) {
invalidMessages.push(error);
}
if (annotation.textField && !Boolean(layerDataView.getFieldByName(annotation.textField))) {
invalidMessages.push(
i18n.translate('xpack.lens.xyChart.annotationError.textFieldNotFound', {
defaultMessage: 'Text field {textField} not found in data view {dataView}',
values: { textField: annotation.textField, dataView: layerDataView.title },
})
);
}
if (annotation.extraFields?.length) {
const missingTooltipFields = [];
for (const field of annotation.extraFields) {
if (!Boolean(layerDataView.getFieldByName(field))) {
missingTooltipFields.push(field);
}
}
if (missingTooltipFields.length) {
invalidMessages.push(
i18n.translate('xpack.lens.xyChart.annotationError.tooltipFieldNotFound', {
defaultMessage:
'Tooltip {missingFields, plural, one {field} other {fields}} {missingTooltipFields} not found in data view {dataView}',
values: {
missingTooltipFields: missingTooltipFields.join(', '),
missingFields: missingTooltipFields.length,
dataView: layerDataView.title,
},
})
);
}
}
if (!invalidMessages.length) {
return validColumn;
}
return {
invalid: true,
invalidMessages,
};
}

View file

@ -539,6 +539,38 @@ describe('#toExpression', () => {
expect(getYConfigColorForLayer(expression, 1)).toEqual([defaultReferenceLineColor]);
});
it('should ignore annotation layers with no event configured', () => {
const expression = xyVisualization.toExpression(
{
legend: { position: Position.Bottom, isVisible: true },
valueLabels: 'show',
preferredSeriesType: 'bar',
layers: [
{
layerId: 'first',
layerType: layerTypes.DATA,
seriesType: 'area',
splitAccessor: 'd',
xAccessor: 'a',
accessors: ['b', 'c'],
yConfig: [{ forAccessor: 'a' }],
},
{
layerId: 'first',
layerType: layerTypes.ANNOTATIONS,
annotations: [],
indexPatternId: 'my-indexPattern',
},
],
},
{ ...frame.datasourceLayers, referenceLine: mockDatasource.publicAPIMock },
undefined,
datasourceExpressionsByLayers
) as Ast;
expect(expression.chain[0].arguments.layers).toHaveLength(1);
});
it('should correctly set the current time marker visibility settings', () => {
const state: XYState = {
legend: { position: Position.Bottom, isVisible: true },

View file

@ -8,9 +8,14 @@
import { Ast, AstFunction } from '@kbn/interpreter';
import { Position, ScaleType } from '@elastic/charts';
import type { PaletteRegistry } from '@kbn/coloring';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import {
EventAnnotationServiceType,
isManualPointAnnotationConfig,
isRangeAnnotationConfig,
} from '@kbn/event-annotation-plugin/public';
import { LegendSize } from '@kbn/visualizations-plugin/public';
import { XYCurveType } from '@kbn/expression-xy-plugin/common';
import { EventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import {
State,
YConfig,
@ -241,6 +246,11 @@ export const buildExpression = (
});
}
const isValidAnnotation = (a: EventAnnotationConfig) =>
isManualPointAnnotationConfig(a) ||
isRangeAnnotationConfig(a) ||
(a.filter && a.filter?.query !== '');
return {
type: 'expression',
chain: [
@ -343,10 +353,39 @@ export const buildExpression = (
datasourceExpressionsByLayers[layer.layerId]
)
),
...validAnnotationsLayers.map((layer) =>
annotationLayerToExpression(layer, eventAnnotationService)
),
],
annotations:
validAnnotationsLayers.length &&
validAnnotationsLayers.flatMap((l) => l.annotations.filter(isValidAnnotation)).length
? [
{
type: 'expression',
chain: [
{
type: 'function',
function: 'event_annotations_result',
arguments: {
layers: validAnnotationsLayers.map((layer) =>
annotationLayerToExpression(layer, eventAnnotationService)
),
datatable: eventAnnotationService.toFetchExpression({
interval:
(validDataLayers[0]?.xAccessor &&
metadata[validDataLayers[0]?.layerId]?.[
validDataLayers[0]?.xAccessor
]?.interval) ||
'auto',
groups: validAnnotationsLayers.map((layer) => ({
indexPatternId: layer.indexPatternId,
annotations: layer.annotations.filter(isValidAnnotation),
})),
}),
},
},
],
},
]
: [],
},
},
],
@ -416,9 +455,7 @@ const annotationLayerToExpression = (
arguments: {
simpleView: [Boolean(layer.simpleView)],
layerId: [layer.layerId],
annotations: layer.annotations
? layer.annotations.map((ann): Ast => eventAnnotationService.toExpression(ann))
: [],
annotations: eventAnnotationService.toExpression(layer.annotations || []),
},
},
],

View file

@ -35,6 +35,7 @@ import {
IconChartBarHorizontal,
} from '@kbn/chart-icons';
import { DistributiveOmit } from '@elastic/eui';
import type { VisualizationType, Suggestion } from '../../types';
import type { ValueLabelConfig } from '../../../common/types';
@ -115,6 +116,8 @@ export interface XYAnnotationLayerConfig {
layerId: string;
layerType: 'annotations';
annotations: EventAnnotationConfig[];
hide?: boolean;
indexPatternId: string;
simpleView?: boolean;
}
@ -160,6 +163,12 @@ export interface XYState {
export type State = XYState;
export type XYPersistedState = Omit<XYState, 'layers'> & {
layers: Array<DistributiveOmit<XYLayerConfig, 'indexPatternId'>>;
};
export type PersistedState = XYPersistedState;
const groupLabelForBar = i18n.translate('xpack.lens.xyVisualization.barGroupLabel', {
defaultMessage: 'Bar',
});

View file

@ -35,9 +35,13 @@ import { eventAnnotationServiceMock } from '@kbn/event-annotation-plugin/public/
import { EventAnnotationConfig } from '@kbn/event-annotation-plugin/common';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { DataViewsState } from '../../state_management';
import { createMockedIndexPattern } from '../../indexpattern_datasource/mocks';
import { createMockDataViewsState } from '../../data_views_service/mocks';
const exampleAnnotation: EventAnnotationConfig = {
id: 'an1',
type: 'manual',
label: 'Event 1',
key: {
type: 'point_in_time',
@ -47,6 +51,7 @@ const exampleAnnotation: EventAnnotationConfig = {
};
const exampleAnnotation2: EventAnnotationConfig = {
icon: 'circle',
type: 'manual',
id: 'an2',
key: {
timestamp: '2022-04-18T11:01:59.135Z',
@ -237,7 +242,12 @@ describe('xy_visualization', () => {
describe('#appendLayer', () => {
it('adds a layer', () => {
const layers = xyVisualization.appendLayer!(exampleState(), 'foo', layerTypes.DATA).layers;
const layers = xyVisualization.appendLayer!(
exampleState(),
'foo',
layerTypes.DATA,
'indexPattern1'
).layers;
expect(layers.length).toEqual(exampleState().layers.length + 1);
expect(layers[layers.length - 1]).toMatchObject({ layerId: 'foo' });
});
@ -245,7 +255,7 @@ describe('xy_visualization', () => {
describe('#clearLayer', () => {
it('clears the specified layer', () => {
const layer = xyVisualization.clearLayer(exampleState(), 'first').layers[0];
const layer = xyVisualization.clearLayer(exampleState(), 'first', 'indexPattern1').layers[0];
expect(layer).toMatchObject({
accessors: [],
layerId: 'first',
@ -460,6 +470,7 @@ describe('xy_visualization', () => {
{
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
],
@ -471,10 +482,12 @@ describe('xy_visualization', () => {
).toEqual({
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [
exampleAnnotation,
{
icon: 'triangle',
type: 'manual',
id: 'newCol',
key: {
timestamp: '2022-04-15T00:00:00.000Z',
@ -495,6 +508,7 @@ describe('xy_visualization', () => {
{
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation2],
},
],
@ -517,6 +531,7 @@ describe('xy_visualization', () => {
).toEqual({
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation2, { ...exampleAnnotation2, id: 'newColId' }],
});
});
@ -530,6 +545,7 @@ describe('xy_visualization', () => {
{
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation, exampleAnnotation2],
},
],
@ -553,6 +569,7 @@ describe('xy_visualization', () => {
).toEqual({
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation2, exampleAnnotation],
});
});
@ -566,12 +583,14 @@ describe('xy_visualization', () => {
layers: [
{
layerId: 'first',
layerType: 'annotations',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
{
layerId: 'second',
layerType: 'annotations',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation2],
},
],
@ -596,11 +615,13 @@ describe('xy_visualization', () => {
{
layerId: 'first',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
{
layerId: 'second',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [{ ...exampleAnnotation, id: 'an2' }],
},
]);
@ -614,12 +635,14 @@ describe('xy_visualization', () => {
layers: [
{
layerId: 'first',
layerType: 'annotations',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
{
layerId: 'second',
layerType: 'annotations',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation2],
},
],
@ -644,11 +667,13 @@ describe('xy_visualization', () => {
{
layerId: 'first',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation2],
},
{
layerId: 'second',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
]);
@ -662,12 +687,14 @@ describe('xy_visualization', () => {
layers: [
{
layerId: 'first',
layerType: 'annotations',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
{
layerId: 'second',
layerType: 'annotations',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation2],
},
],
@ -692,11 +719,13 @@ describe('xy_visualization', () => {
{
layerId: 'first',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [],
},
{
layerId: 'second',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
]);
@ -710,12 +739,14 @@ describe('xy_visualization', () => {
layers: [
{
layerId: 'first',
layerType: 'annotations',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
{
layerId: 'second',
layerType: 'annotations',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [],
},
],
@ -740,11 +771,13 @@ describe('xy_visualization', () => {
{
layerId: 'first',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [],
},
{
layerId: 'second',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
]);
@ -1106,6 +1139,7 @@ describe('xy_visualization', () => {
{
layerId: 'ann',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation, { ...exampleAnnotation, id: 'an2' }],
},
],
@ -1124,6 +1158,7 @@ describe('xy_visualization', () => {
{
layerId: 'ann',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
]);
@ -1843,6 +1878,7 @@ describe('xy_visualization', () => {
{
layerId: 'annotations',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation],
},
],
@ -2278,7 +2314,7 @@ describe('xy_visualization', () => {
},
],
},
frame.datasourceLayers
{ datasourceLayers: frame.datasourceLayers, dataViews: {} as DataViewsState }
)
).toEqual([
{
@ -2334,7 +2370,7 @@ describe('xy_visualization', () => {
},
],
},
datasourceLayers
{ datasourceLayers, dataViews: {} as DataViewsState }
)
).toEqual([
{
@ -2390,7 +2426,7 @@ describe('xy_visualization', () => {
},
],
},
datasourceLayers
{ datasourceLayers, dataViews: {} as DataViewsState }
)
).toEqual([
{
@ -2399,6 +2435,98 @@ describe('xy_visualization', () => {
},
]);
});
describe('Annotation layers', () => {
function createStateWithAnnotationProps(annotation: Partial<EventAnnotationConfig>) {
return {
layers: [
{
layerId: 'layerId',
layerType: 'annotations',
indexPatternId: 'first',
annotations: [
{
label: 'Event',
id: '1',
type: 'query',
timeField: 'start_date',
...annotation,
},
],
},
],
} as XYState;
}
function getFrameMock() {
return createMockFramePublicAPI({
datasourceLayers: { first: mockDatasource.publicAPIMock },
dataViews: createMockDataViewsState({
indexPatterns: { first: createMockedIndexPattern() },
}),
});
}
it('should return error if current annotation contains non-existent field as timeField', () => {
const xyState = createStateWithAnnotationProps({
timeField: 'non-existent',
});
const errors = xyVisualization.getErrorMessages(xyState, getFrameMock());
expect(errors).toHaveLength(1);
expect(errors![0]).toEqual(
expect.objectContaining({
shortMessage: 'Time field non-existent not found in data view my-fake-index-pattern',
})
);
});
it('should return error if current annotation contains non existent field as textField', () => {
const xyState = createStateWithAnnotationProps({
textField: 'non-existent',
});
const errors = xyVisualization.getErrorMessages(xyState, getFrameMock());
expect(errors).toHaveLength(1);
expect(errors![0]).toEqual(
expect.objectContaining({
shortMessage: 'Text field non-existent not found in data view my-fake-index-pattern',
})
);
});
it('should contain error if current annotation contains at least one non-existent field as tooltip field', () => {
const xyState = createStateWithAnnotationProps({
extraFields: ['bytes', 'memory', 'non-existent'],
});
const errors = xyVisualization.getErrorMessages(xyState, getFrameMock());
expect(errors).toHaveLength(1);
expect(errors![0]).toEqual(
expect.objectContaining({
shortMessage: 'Tooltip field non-existent not found in data view my-fake-index-pattern',
})
);
});
it('should contain error if current annotation contains invalid query', () => {
const xyState = createStateWithAnnotationProps({
filter: { type: 'kibana_query', query: 'invalid: "', language: 'kuery' },
});
const errors = xyVisualization.getErrorMessages(xyState, getFrameMock());
expect(errors).toHaveLength(1);
expect(errors![0]).toEqual(
expect.objectContaining({
shortMessage: expect.stringContaining(
'Expected "(", "{", value, whitespace but """ found.'
),
})
);
});
it('should contain multiple errors if current annotation contains multiple non-existent fields', () => {
const xyState = createStateWithAnnotationProps({
timeField: 'non-existent',
textField: 'non-existent',
extraFields: ['bytes', 'memory', 'non-existent'],
filter: { type: 'kibana_query', query: 'invalid: "', language: 'kuery' },
});
const errors = xyVisualization.getErrorMessages(xyState, getFrameMock());
expect(errors).toHaveLength(4);
});
});
});
describe('#getWarningMessages', () => {
@ -2555,4 +2683,80 @@ describe('xy_visualization', () => {
});
});
});
describe('#fromPersistableState', () => {
it('should inject references on annotation layers', () => {
const baseState = exampleState();
expect(
xyVisualization.fromPersistableState!(
{
...baseState,
layers: [
...baseState.layers,
{
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
annotations: [exampleAnnotation2],
},
],
},
[
{
type: 'index-pattern',
name: `xy-visualization-layer-annotation`,
id: 'indexPattern1',
},
]
)
).toEqual({
...baseState,
layers: [
...baseState.layers,
{
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation2],
},
],
});
});
it('should fallback to the first dataView reference in case there are missing annotation references', () => {
const baseState = exampleState();
expect(
xyVisualization.fromPersistableState!(
{
...baseState,
layers: [
...baseState.layers,
{
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
annotations: [exampleAnnotation2],
},
],
},
[
{
type: 'index-pattern',
name: 'something-else',
id: 'indexPattern1',
},
]
)
).toEqual({
...baseState,
layers: [
...baseState.layers,
{
layerId: 'annotation',
layerType: layerTypes.ANNOTATIONS,
indexPatternId: 'indexPattern1',
annotations: [exampleAnnotation2],
},
],
});
});
});
});

View file

@ -14,7 +14,7 @@ import type { PaletteRegistry } from '@kbn/coloring';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { CoreStart, ThemeServiceStart } from '@kbn/core/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import { FillStyle } from '@kbn/expression-xy-plugin/common';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
@ -22,7 +22,7 @@ import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { getSuggestions } from './xy_suggestions';
import { XyToolbar } from './xy_config_panel';
import { DimensionEditor } from './xy_config_panel/dimension_editor';
import { LayerHeader } from './xy_config_panel/layer_header';
import { LayerHeader, LayerHeaderContent } from './xy_config_panel/layer_header';
import type { Visualization, AccessorConfig, FramePublicAPI } from '../../types';
import {
State,
@ -33,9 +33,15 @@ import {
YConfig,
YAxisMode,
SeriesType,
PersistedState,
} from './types';
import { layerTypes } from '../../../common';
import { isHorizontalChart } from './state_helpers';
import {
extractReferences,
injectReferences,
isHorizontalChart,
validateColumn,
} from './state_helpers';
import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression';
import { getAccessorColorConfigs, getColorAssignments } from './color_assignment';
import { getColumnToLabelMap } from './state_helpers';
@ -55,6 +61,7 @@ import {
import {
checkXAccessorCompatibility,
defaultSeriesType,
getAnnotationsLayers,
getAxisName,
getDataLayers,
getDescription,
@ -79,6 +86,7 @@ import { DimensionTrigger } from '../../shared_components/dimension_trigger';
import { defaultAnnotationLabel } from './annotations/helpers';
import { onDropForVisualization } from '../../editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils';
const XY_ID = 'lnsXY';
export const getXyVisualization = ({
core,
storage,
@ -97,8 +105,8 @@ export const getXyVisualization = ({
fieldFormats: FieldFormatsStart;
useLegacyTimeAxis: boolean;
kibanaTheme: ThemeServiceStart;
}): Visualization<State> => ({
id: 'lnsXY',
}): Visualization<State, PersistedState> => ({
id: XY_ID,
visualizationTypes,
getVisualizationTypeId(state) {
const type = getVisualizationType(state);
@ -121,7 +129,7 @@ export const getXyVisualization = ({
};
},
appendLayer(state, layerId, layerType) {
appendLayer(state, layerId, layerType, indexPatternId) {
const firstUsedSeriesType = getDataLayers(state.layers)?.[0]?.seriesType;
return {
...state,
@ -131,12 +139,13 @@ export const getXyVisualization = ({
seriesType: firstUsedSeriesType || state.preferredSeriesType,
layerId,
layerType,
indexPatternId,
}),
],
};
},
clearLayer(state, layerId) {
clearLayer(state, layerId, indexPatternId) {
return {
...state,
layers: state.layers.map((l) =>
@ -145,11 +154,20 @@ export const getXyVisualization = ({
: newLayerState({
seriesType: state.preferredSeriesType,
layerId,
indexPatternId,
})
),
};
},
getPersistableState(state) {
return extractReferences(state);
},
fromPersistableState(state, references) {
return injectReferences(state, references);
},
getDescription,
switchVisualizationType(seriesType: string, state: State) {
@ -204,7 +222,7 @@ export const getXyVisualization = ({
return state;
}
const newLayers = [...state.layers];
newLayers[layerIndex] = { ...layer };
newLayers[layerIndex] = { ...layer, indexPatternId };
return {
...state,
layers: newLayers,
@ -468,8 +486,10 @@ export const getXyVisualization = ({
return prevState;
}
if (isAnnotationsLayer(foundLayer)) {
const newLayer = { ...foundLayer };
newLayer.annotations = newLayer.annotations.filter(({ id }) => id !== columnId);
const newLayer = {
...foundLayer,
annotations: foundLayer.annotations.filter(({ id }) => id !== columnId),
};
const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l));
return {
@ -520,6 +540,24 @@ export const getXyVisualization = ({
};
},
renderLayerPanel(domElement, props) {
const { onChangeIndexPattern, ...otherProps } = props;
render(
<KibanaThemeProvider theme$={kibanaTheme.theme$}>
<I18nProvider>
<LayerHeaderContent
{...otherProps}
onChangeIndexPattern={(indexPatternId) => {
// TODO: should it trigger an action as in the datasource?
onChangeIndexPattern(indexPatternId);
}}
/>
</I18nProvider>
</KibanaThemeProvider>,
domElement
);
},
renderLayerHeader(domElement, props) {
render(
<KibanaThemeProvider theme$={kibanaTheme.theme$}>
@ -560,7 +598,22 @@ export const getXyVisualization = ({
render(
<KibanaThemeProvider theme$={kibanaTheme.theme$}>
<I18nProvider>{dimensionEditor}</I18nProvider>
<I18nProvider>
<KibanaContextProvider
services={{
appName: 'lens',
storage,
uiSettings: core.uiSettings,
data,
fieldFormats,
savedObjects: core.savedObjects,
docLinks: core.docLinks,
http: core.http,
}}
>
{dimensionEditor}
</KibanaContextProvider>
</I18nProvider>
</KibanaThemeProvider>,
domElement
);
@ -585,7 +638,53 @@ export const getXyVisualization = ({
eventAnnotationService
),
getErrorMessages(state, datasourceLayers) {
validateColumn(state, frame, layerId, columnId, group) {
const { invalid, invalidMessages } = validateColumn(state, frame, layerId, columnId, group);
if (!invalid) {
return { invalid };
}
return { invalid, invalidMessage: invalidMessages![0] };
},
getErrorMessages(state, frame) {
const { datasourceLayers, dataViews } = frame || {};
const errors: Array<{
shortMessage: string;
longMessage: React.ReactNode;
}> = [];
const annotationLayers = getAnnotationsLayers(state.layers);
if (dataViews) {
annotationLayers.forEach((layer) => {
layer.annotations.forEach((annotation) => {
const validatedColumn = validateColumn(
state,
{ dataViews },
layer.layerId,
annotation.id
);
if (validatedColumn?.invalid && validatedColumn.invalidMessages?.length) {
errors.push(
...validatedColumn.invalidMessages.map((invalidMessage) => ({
shortMessage: invalidMessage,
longMessage: (
<FormattedMessage
id="xpack.lens.xyChart.annotationError"
defaultMessage="Annotation {annotationName} has an error: {errorMessage}"
values={{
annotationName: annotation.label,
errorMessage: invalidMessage,
}}
/>
),
}))
);
}
});
});
}
// Data error handling below here
const hasNoAccessors = ({ accessors }: XYDataLayerConfig) =>
accessors == null || accessors.length === 0;
@ -594,11 +693,6 @@ export const getXyVisualization = ({
const hasNoSplitAccessor = ({ splitAccessor, seriesType }: XYDataLayerConfig) =>
seriesType.includes('percentage') && splitAccessor == null;
const errors: Array<{
shortMessage: string;
longMessage: React.ReactNode;
}> = [];
// check if the layers in the state are compatible with this type of chart
if (state && state.layers.length > 1) {
// Order is important here: Y Axis is fundamental to exist to make it valid
@ -696,6 +790,11 @@ export const getXyVisualization = ({
getUniqueLabels(state) {
return getUniqueLabels(state.layers);
},
getUsedDataViews(state) {
return (
state?.layers.filter(isAnnotationsLayer).map(({ indexPatternId }) => indexPatternId) ?? []
);
},
renderDimensionTrigger({
columnId,
label,

Some files were not shown because too many files have changed in this diff Show more