From 2f9e6eeacfac1d53a8ed91c8d7ddfe845a4e12cd Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Wed, 23 Mar 2022 22:16:50 +0100 Subject: [PATCH] [Lens] Manual Annotations (#126456) * Add event annotation service structure * adding annotation layer to lens. passing event annotation service * simplify initial Dimensions * add annotations to lens * no datasource layer * group the annotations into numerical icons * color icons in tooltip, add the annotation icon, fix date interval bug * display old time axis for annotations * error in annotation dimension when date histogram is removed * refactor: use the same methods for annotations and reference lines * wip * only check activeData for dataLayers * added new icons for annotations * refactor icons * uniqueLabels * unique Labels * diff config from args * change timestamp format * added expression event_annotation_group * names refactor * ea service adding help descriptions * rotate icon * added tests * fix button problem * dnd problem * dnd fix * tests for dimension trigger * tests for unique labels * [CI] Auto-commit changed files from 'node scripts/build_plugin_list_docs' * type * add new button test * remove noDatasource from config (only needed when initializing a layer or dimension in getSupportedLayers) * addressing Joe's and Michael comments * remove hexagon and square, address Stratoula's feedback * stroke for icons & icon fill * fix tests * fix small things * align the set with tsvb * align IconSelect * fix i18nrc * Update src/plugins/event_annotation/public/event_annotation_service/index.tsx Co-authored-by: Alexey Antonov * refactor empty button * CR * date cr * remove DimensionEditorSection * change to emptyShade for traingle fill * Update x-pack/plugins/lens/public/app_plugin/app.scss Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Alexey Antonov --- .i18nrc.json | 17 +- docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + src/plugins/event_annotation/README.md | 3 + .../common/event_annotation_group/index.ts | 52 +++ src/plugins/event_annotation/common/index.ts | 13 + .../common/manual_event_annotation/index.ts | 82 +++++ .../common/manual_event_annotation/types.ts | 15 + src/plugins/event_annotation/common/types.ts | 29 ++ src/plugins/event_annotation/jest.config.js | 18 + src/plugins/event_annotation/kibana.json | 17 + .../public/event_annotation_service/README.md | 3 + .../event_annotation_service/helpers.ts | 9 + .../public/event_annotation_service/index.tsx | 20 ++ .../event_annotation_service/service.tsx | 49 +++ .../public/event_annotation_service/types.ts | 14 + src/plugins/event_annotation/public/index.ts | 17 + src/plugins/event_annotation/public/mocks.ts | 12 + src/plugins/event_annotation/public/plugin.ts | 39 +++ src/plugins/event_annotation/server/index.ts | 10 + src/plugins/event_annotation/server/plugin.ts | 30 ++ src/plugins/event_annotation/tsconfig.json | 22 ++ x-pack/plugins/lens/common/constants.ts | 1 + .../layer_config/annotation_layer_config.ts | 67 ++++ .../xy_chart/layer_config/index.ts | 7 +- .../common/expressions/xy_chart/xy_args.ts | 5 +- .../common/expressions/xy_chart/xy_chart.ts | 8 +- x-pack/plugins/lens/kibana.json | 3 +- .../plugins/lens/public/app_plugin/app.scss | 7 + .../public/assets/annotation_icons/circle.tsx | 31 ++ .../public/assets/annotation_icons/index.tsx | 9 + .../assets/annotation_icons/triangle.tsx | 30 ++ .../public/assets/chart_bar_annotations.tsx | 37 ++ .../buttons/draggable_dimension_button.tsx | 5 +- .../buttons/drop_targets_utils.tsx | 12 +- .../buttons/empty_dimension_button.tsx | 79 +++-- .../config_panel/config_panel.test.tsx | 75 +++- .../config_panel/config_panel.tsx | 94 +++--- .../config_panel/layer_panel.test.tsx | 82 +++-- .../editor_frame/config_panel/layer_panel.tsx | 233 +++++++------ x-pack/plugins/lens/public/expressions.ts | 2 + .../dimension_editor.tsx | 136 ++++---- .../droppable/get_drop_props.ts | 13 +- .../indexpattern.test.ts | 4 - .../indexpattern_datasource/indexpattern.tsx | 2 +- .../lens/public/pie_visualization/toolbar.tsx | 24 +- x-pack/plugins/lens/public/plugin.ts | 14 +- .../shared_components/dimension_section.scss | 24 ++ .../shared_components/dimension_section.tsx | 29 ++ .../lens/public/shared_components/index.ts | 1 + .../public/state_management/lens_slice.ts | 89 +++-- x-pack/plugins/lens/public/types.ts | 39 ++- .../visualizations/gauge/visualization.tsx | 6 - .../__snapshots__/expression.test.tsx.snap | 213 ++++++++++++ .../annotations/config_panel/icon_set.ts | 97 ++++++ .../annotations/config_panel/index.scss | 3 + .../annotations/config_panel/index.tsx | 186 ++++++++++ .../annotations/expression.scss | 37 ++ .../annotations/expression.tsx | 233 +++++++++++++ .../annotations/helpers.test.ts | 210 ++++++++++++ .../xy_visualization/annotations/helpers.tsx | 240 +++++++++++++ .../xy_visualization/annotations_helpers.tsx | 253 ++++++++++++++ .../xy_visualization/color_assignment.ts | 35 +- .../xy_visualization/expression.test.tsx | 238 +++++++++++-- .../public/xy_visualization/expression.tsx | 110 ++++-- .../expression_reference_lines.tsx | 228 ++----------- .../lens/public/xy_visualization/index.ts | 7 +- .../reference_line_helpers.tsx | 24 +- .../public/xy_visualization/state_helpers.ts | 5 +- .../xy_visualization/to_expression.test.ts | 2 + .../public/xy_visualization/to_expression.ts | 179 +++++++--- .../xy_visualization/visualization.test.ts | 319 +++++++++++++++++- .../public/xy_visualization/visualization.tsx | 111 ++++-- .../visualization_helpers.tsx | 41 ++- .../xy_config_panel/color_picker.tsx | 9 +- .../xy_config_panel/layer_header.tsx | 16 +- .../xy_config_panel/reference_line_panel.tsx | 1 + .../xy_config_panel/shared/icon_select.tsx | 32 +- .../shared/line_style_settings.tsx | 7 +- .../shared/marker_decoration_settings.tsx | 33 +- .../xy_visualization/xy_suggestions.test.ts | 58 +++- .../public/xy_visualization/xy_suggestions.ts | 5 +- .../lens/server/expressions/expressions.ts | 2 + x-pack/plugins/lens/tsconfig.json | 110 ++++-- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 87 files changed, 3885 insertions(+), 806 deletions(-) create mode 100644 src/plugins/event_annotation/README.md create mode 100644 src/plugins/event_annotation/common/event_annotation_group/index.ts create mode 100644 src/plugins/event_annotation/common/index.ts create mode 100644 src/plugins/event_annotation/common/manual_event_annotation/index.ts create mode 100644 src/plugins/event_annotation/common/manual_event_annotation/types.ts create mode 100644 src/plugins/event_annotation/common/types.ts create mode 100644 src/plugins/event_annotation/jest.config.js create mode 100644 src/plugins/event_annotation/kibana.json create mode 100644 src/plugins/event_annotation/public/event_annotation_service/README.md create mode 100644 src/plugins/event_annotation/public/event_annotation_service/helpers.ts create mode 100644 src/plugins/event_annotation/public/event_annotation_service/index.tsx create mode 100644 src/plugins/event_annotation/public/event_annotation_service/service.tsx create mode 100644 src/plugins/event_annotation/public/event_annotation_service/types.ts create mode 100644 src/plugins/event_annotation/public/index.ts create mode 100644 src/plugins/event_annotation/public/mocks.ts create mode 100644 src/plugins/event_annotation/public/plugin.ts create mode 100644 src/plugins/event_annotation/server/index.ts create mode 100644 src/plugins/event_annotation/server/plugin.ts create mode 100644 src/plugins/event_annotation/tsconfig.json create mode 100644 x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts create mode 100644 x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx create mode 100644 x-pack/plugins/lens/public/assets/annotation_icons/index.tsx create mode 100644 x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx create mode 100644 x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/dimension_section.scss create mode 100644 x-pack/plugins/lens/public/shared_components/dimension_section.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/config_panel/icon_set.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/config_panel/index.scss create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/config_panel/index.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx diff --git a/.i18nrc.json b/.i18nrc.json index 402932902f249..71b68d2c51d85 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -31,6 +31,7 @@ "expressions": "src/plugins/expressions", "expressionShape": "src/plugins/expression_shape", "expressionTagcloud": "src/plugins/chart_expressions/expression_tagcloud", + "eventAnnotation": "src/plugins/event_annotation", "fieldFormats": "src/plugins/field_formats", "flot": "packages/kbn-flot-charts/lib", "home": "src/plugins/home", @@ -50,7 +51,10 @@ "kibana-react": "src/plugins/kibana_react", "kibanaOverview": "src/plugins/kibana_overview", "lists": "packages/kbn-securitysolution-list-utils/src", - "management": ["src/legacy/core_plugins/management", "src/plugins/management"], + "management": [ + "src/legacy/core_plugins/management", + "src/plugins/management" + ], "monaco": "packages/kbn-monaco/src", "navigation": "src/plugins/navigation", "newsfeed": "src/plugins/newsfeed", @@ -62,8 +66,13 @@ "sharedUX": "src/plugins/shared_ux", "sharedUXComponents": "packages/kbn-shared-ux-components/src", "statusPage": "src/legacy/core_plugins/status_page", - "telemetry": ["src/plugins/telemetry", "src/plugins/telemetry_management_section"], - "timelion": ["src/plugins/vis_types/timelion"], + "telemetry": [ + "src/plugins/telemetry", + "src/plugins/telemetry_management_section" + ], + "timelion": [ + "src/plugins/vis_types/timelion" + ], "uiActions": "src/plugins/ui_actions", "uiActionsExamples": "examples/ui_action_examples", "usageCollection": "src/plugins/usage_collection", @@ -83,4 +92,4 @@ "visualizations": "src/plugins/visualizations" }, "translations": [] -} +} \ No newline at end of file diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index bf81ab1e0bec4..aefaf4eab40fa 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -94,6 +94,10 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |This plugin contains reusable code in the form of self-contained modules (or libraries). Each of these modules exports a set of functionality relevant to the domain of the module. +|{kib-repo}blob/{branch}/src/plugins/event_annotation/README.md[eventAnnotation] +|The Event Annotation service contains expressions for event annotations + + |{kib-repo}blob/{branch}/src/plugins/expression_error/README.md[expressionError] |Expression Error plugin adds an error renderer to the expression plugin. The renderer will display the error image. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 396ffd4599284..526c1ff5dad82 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -124,3 +124,4 @@ pageLoadAssetSize: sessionView: 77750 cloudSecurityPosture: 19109 visTypeGauge: 24113 + eventAnnotation: 19334 diff --git a/src/plugins/event_annotation/README.md b/src/plugins/event_annotation/README.md new file mode 100644 index 0000000000000..a7a85d3ab3641 --- /dev/null +++ b/src/plugins/event_annotation/README.md @@ -0,0 +1,3 @@ +# Event Annotation service + +The Event Annotation service contains expressions for event annotations diff --git a/src/plugins/event_annotation/common/event_annotation_group/index.ts b/src/plugins/event_annotation/common/event_annotation_group/index.ts new file mode 100644 index 0000000000000..85f1d9dff900c --- /dev/null +++ b/src/plugins/event_annotation/common/event_annotation_group/index.ts @@ -0,0 +1,52 @@ +/* + * 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 'src/plugins/expressions/common'; +import { i18n } from '@kbn/i18n'; +import type { EventAnnotationOutput } from '../manual_event_annotation/types'; + +export interface EventAnnotationGroupOutput { + type: 'event_annotation_group'; + annotations: EventAnnotationOutput[]; +} + +export interface EventAnnotationGroupArgs { + annotations: EventAnnotationOutput[]; +} + +export function eventAnnotationGroup(): ExpressionFunctionDefinition< + 'event_annotation_group', + null, + EventAnnotationGroupArgs, + EventAnnotationGroupOutput +> { + return { + name: 'event_annotation_group', + aliases: [], + type: 'event_annotation_group', + inputTypes: ['null'], + help: i18n.translate('eventAnnotation.group.description', { + defaultMessage: 'Event annotation group', + }), + args: { + annotations: { + types: ['manual_event_annotation'], + help: i18n.translate('eventAnnotation.group.args.annotationConfigs', { + defaultMessage: 'Annotation configs', + }), + multi: true, + }, + }, + fn: (input, args) => { + return { + type: 'event_annotation_group', + annotations: args.annotations, + }; + }, + }; +} diff --git a/src/plugins/event_annotation/common/index.ts b/src/plugins/event_annotation/common/index.ts new file mode 100644 index 0000000000000..332fa19150aad --- /dev/null +++ b/src/plugins/event_annotation/common/index.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export type { EventAnnotationArgs, EventAnnotationOutput } from './manual_event_annotation/types'; +export { manualEventAnnotation } from './manual_event_annotation'; +export { eventAnnotationGroup } from './event_annotation_group'; +export type { EventAnnotationGroupArgs } from './event_annotation_group'; +export type { EventAnnotationConfig } from './types'; diff --git a/src/plugins/event_annotation/common/manual_event_annotation/index.ts b/src/plugins/event_annotation/common/manual_event_annotation/index.ts new file mode 100644 index 0000000000000..108df93b34180 --- /dev/null +++ b/src/plugins/event_annotation/common/manual_event_annotation/index.ts @@ -0,0 +1,82 @@ +/* + * 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 'src/plugins/expressions/common'; +import { i18n } from '@kbn/i18n'; +import type { EventAnnotationArgs, EventAnnotationOutput } from './types'; +export const manualEventAnnotation: ExpressionFunctionDefinition< + 'manual_event_annotation', + null, + EventAnnotationArgs, + EventAnnotationOutput +> = { + name: 'manual_event_annotation', + aliases: [], + type: 'manual_event_annotation', + help: i18n.translate('eventAnnotation.manualAnnotation.description', { + defaultMessage: `Configure manual annotation`, + }), + inputTypes: ['null'], + args: { + time: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.time', { + defaultMessage: `Timestamp for annotation`, + }), + }, + label: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.label', { + defaultMessage: `The name of the annotation`, + }), + }, + color: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.color', { + defaultMessage: 'The color of the line', + }), + }, + lineStyle: { + types: ['string'], + options: ['solid', 'dotted', 'dashed'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.lineStyle', { + defaultMessage: 'The style of the annotation line', + }), + }, + lineWidth: { + types: ['number'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.lineWidth', { + defaultMessage: 'The width of the annotation line', + }), + }, + icon: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.icon', { + defaultMessage: 'An optional icon used for annotation lines', + }), + }, + textVisibility: { + types: ['boolean'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.textVisibility', { + defaultMessage: 'Visibility of the label on the annotation line', + }), + }, + isHidden: { + types: ['boolean'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.isHidden', { + defaultMessage: `Switch to hide annotation`, + }), + }, + }, + fn: function fn(input: unknown, args: EventAnnotationArgs) { + return { + type: 'manual_event_annotation', + ...args, + }; + }, +}; diff --git a/src/plugins/event_annotation/common/manual_event_annotation/types.ts b/src/plugins/event_annotation/common/manual_event_annotation/types.ts new file mode 100644 index 0000000000000..e1bed4a592d23 --- /dev/null +++ b/src/plugins/event_annotation/common/manual_event_annotation/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StyleProps } from '../types'; + +export type EventAnnotationArgs = { + time: string; +} & StyleProps; + +export type EventAnnotationOutput = EventAnnotationArgs & { type: 'manual_event_annotation' }; diff --git a/src/plugins/event_annotation/common/types.ts b/src/plugins/event_annotation/common/types.ts new file mode 100644 index 0000000000000..95275804d1d1f --- /dev/null +++ b/src/plugins/event_annotation/common/types.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +export type LineStyle = 'solid' | 'dashed' | 'dotted'; +export type AnnotationType = 'manual'; +export type KeyType = 'point_in_time'; + +export interface StyleProps { + label: string; + color?: string; + icon?: string; + lineWidth?: number; + lineStyle?: LineStyle; + textVisibility?: boolean; + isHidden?: boolean; +} + +export type EventAnnotationConfig = { + id: string; + key: { + type: KeyType; + timestamp: string; + }; +} & StyleProps; diff --git a/src/plugins/event_annotation/jest.config.js b/src/plugins/event_annotation/jest.config.js new file mode 100644 index 0000000000000..a6ea4a6b430df --- /dev/null +++ b/src/plugins/event_annotation/jest.config.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/event_annotation'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/event_annotation', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/event_annotation/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/event_annotation/kibana.json b/src/plugins/event_annotation/kibana.json new file mode 100644 index 0000000000000..5a0c49be09ba3 --- /dev/null +++ b/src/plugins/event_annotation/kibana.json @@ -0,0 +1,17 @@ +{ + "id": "eventAnnotation", + "version": "kibana", + "server": true, + "ui": true, + "description": "The Event Annotation service contains expressions for event annotations", + "extraPublicDirs": [ + "common" + ], + "requiredPlugins": [ + "expressions" + ], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + } +} \ No newline at end of file diff --git a/src/plugins/event_annotation/public/event_annotation_service/README.md b/src/plugins/event_annotation/public/event_annotation_service/README.md new file mode 100644 index 0000000000000..a7a85d3ab3641 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/README.md @@ -0,0 +1,3 @@ +# Event Annotation service + +The Event Annotation service contains expressions for event annotations diff --git a/src/plugins/event_annotation/public/event_annotation_service/helpers.ts b/src/plugins/event_annotation/public/event_annotation_service/helpers.ts new file mode 100644 index 0000000000000..aed33da840574 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/helpers.ts @@ -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 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 { euiLightVars } from '@kbn/ui-theme'; +export const defaultAnnotationColor = euiLightVars.euiColorAccent; diff --git a/src/plugins/event_annotation/public/event_annotation_service/index.tsx b/src/plugins/event_annotation/public/event_annotation_service/index.tsx new file mode 100644 index 0000000000000..e967a7cb0f0a2 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EventAnnotationServiceType } from './types'; + +export class EventAnnotationService { + private eventAnnotationService?: EventAnnotationServiceType; + public async getService() { + if (!this.eventAnnotationService) { + const { getEventAnnotationService } = await import('./service'); + this.eventAnnotationService = getEventAnnotationService(); + } + return this.eventAnnotationService; + } +} diff --git a/src/plugins/event_annotation/public/event_annotation_service/service.tsx b/src/plugins/event_annotation/public/event_annotation_service/service.tsx new file mode 100644 index 0000000000000..3d81ea6a3e3a6 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/service.tsx @@ -0,0 +1,49 @@ +/* + * 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 { EventAnnotationServiceType } from './types'; +import { defaultAnnotationColor } from './helpers'; + +export function hasIcon(icon: string | undefined): icon is string { + return icon != null && icon !== 'empty'; +} + +export function getEventAnnotationService(): EventAnnotationServiceType { + return { + toExpression: ({ + label, + isHidden, + color, + lineStyle, + lineWidth, + icon, + textVisibility, + time, + }) => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'manual_event_annotation', + arguments: { + time: [time], + label: [label], + color: [color || defaultAnnotationColor], + lineWidth: [lineWidth || 1], + lineStyle: [lineStyle || 'solid'], + icon: hasIcon(icon) ? [icon] : ['triangle'], + textVisibility: [textVisibility || false], + isHidden: [Boolean(isHidden)], + }, + }, + ], + }; + }, + }; +} diff --git a/src/plugins/event_annotation/public/event_annotation_service/types.ts b/src/plugins/event_annotation/public/event_annotation_service/types.ts new file mode 100644 index 0000000000000..bb0b6eb4cc200 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/types.ts @@ -0,0 +1,14 @@ +/* + * 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 { ExpressionAstExpression } from '../../../expressions/common/ast'; +import { EventAnnotationArgs } from '../../common'; + +export interface EventAnnotationServiceType { + toExpression: (props: EventAnnotationArgs) => ExpressionAstExpression; +} diff --git a/src/plugins/event_annotation/public/index.ts b/src/plugins/event_annotation/public/index.ts new file mode 100644 index 0000000000000..c15429c94cbe4 --- /dev/null +++ b/src/plugins/event_annotation/public/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +// TODO: https://github.com/elastic/kibana/issues/110891 +/* eslint-disable @kbn/eslint/no_export_all */ + +import { EventAnnotationPlugin } from './plugin'; +export const plugin = () => new EventAnnotationPlugin(); +export type { EventAnnotationPluginSetup, EventAnnotationPluginStart } from './plugin'; +export * from './event_annotation_service/types'; +export { EventAnnotationService } from './event_annotation_service'; +export { defaultAnnotationColor } from './event_annotation_service/helpers'; diff --git a/src/plugins/event_annotation/public/mocks.ts b/src/plugins/event_annotation/public/mocks.ts new file mode 100644 index 0000000000000..e78d4e8f75de7 --- /dev/null +++ b/src/plugins/event_annotation/public/mocks.ts @@ -0,0 +1,12 @@ +/* + * 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 './event_annotation_service/service'; + +// not really mocking but avoiding async loading +export const eventAnnotationServiceMock = getEventAnnotationService(); diff --git a/src/plugins/event_annotation/public/plugin.ts b/src/plugins/event_annotation/public/plugin.ts new file mode 100644 index 0000000000000..83cdc0546a7f5 --- /dev/null +++ b/src/plugins/event_annotation/public/plugin.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Plugin, CoreSetup } from 'kibana/public'; +import { ExpressionsSetup } from '../../expressions/public'; +import { manualEventAnnotation, eventAnnotationGroup } from '../common'; +import { EventAnnotationService } from './event_annotation_service'; + +interface SetupDependencies { + expressions: ExpressionsSetup; +} + +/** @public */ +export type EventAnnotationPluginSetup = EventAnnotationService; + +/** @public */ +export type EventAnnotationPluginStart = EventAnnotationService; + +/** @public */ +export class EventAnnotationPlugin + implements Plugin +{ + private readonly eventAnnotationService = new EventAnnotationService(); + + public setup(core: CoreSetup, dependencies: SetupDependencies): EventAnnotationPluginSetup { + dependencies.expressions.registerFunction(manualEventAnnotation); + dependencies.expressions.registerFunction(eventAnnotationGroup); + return this.eventAnnotationService; + } + + public start(): EventAnnotationPluginStart { + return this.eventAnnotationService; + } +} diff --git a/src/plugins/event_annotation/server/index.ts b/src/plugins/event_annotation/server/index.ts new file mode 100644 index 0000000000000..d9d13045ed10a --- /dev/null +++ b/src/plugins/event_annotation/server/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { EventAnnotationServerPlugin } from './plugin'; +export const plugin = () => new EventAnnotationServerPlugin(); diff --git a/src/plugins/event_annotation/server/plugin.ts b/src/plugins/event_annotation/server/plugin.ts new file mode 100644 index 0000000000000..ef4e0216fb5ac --- /dev/null +++ b/src/plugins/event_annotation/server/plugin.ts @@ -0,0 +1,30 @@ +/* + * 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 { CoreSetup, Plugin } from 'kibana/server'; +import { manualEventAnnotation, eventAnnotationGroup } from '../common'; +import { ExpressionsServerSetup } from '../../expressions/server'; + +interface SetupDependencies { + expressions: ExpressionsServerSetup; +} + +export class EventAnnotationServerPlugin implements Plugin { + public setup(core: CoreSetup, dependencies: SetupDependencies) { + dependencies.expressions.registerFunction(manualEventAnnotation); + dependencies.expressions.registerFunction(eventAnnotationGroup); + + return {}; + } + + public start() { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/event_annotation/tsconfig.json b/src/plugins/event_annotation/tsconfig.json new file mode 100644 index 0000000000000..ca3d65a13b214 --- /dev/null +++ b/src/plugins/event_annotation/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*" + ], + "references": [ + { + "path": "../../core/tsconfig.json" + }, + { + "path": "../expressions/tsconfig.json" + } + ] +} \ No newline at end of file diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 1504e33ecacab..d0bfecbd386be 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -45,6 +45,7 @@ export const LegendDisplay = { export const layerTypes = { DATA: 'data', REFERENCELINE: 'referenceLine', + ANNOTATIONS: 'annotations', } as const; // might collide with user-supplied field names, try to make as unique as possible diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts new file mode 100644 index 0000000000000..45b4bf31c0cdc --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts @@ -0,0 +1,67 @@ +/* + * 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 { + EventAnnotationConfig, + EventAnnotationOutput, +} from '../../../../../../../src/plugins/event_annotation/common'; +import type { ExpressionFunctionDefinition } from '../../../../../../../src/plugins/expressions/common'; +import { layerTypes } from '../../../constants'; + +export interface XYAnnotationLayerConfig { + layerId: string; + layerType: typeof layerTypes.ANNOTATIONS; + annotations: EventAnnotationConfig[]; + hide?: boolean; +} + +export interface AnnotationLayerArgs { + annotations: EventAnnotationOutput[]; + layerId: string; + layerType: typeof layerTypes.ANNOTATIONS; + hide?: boolean; +} +export type XYAnnotationLayerArgsResult = AnnotationLayerArgs & { + type: 'lens_xy_annotation_layer'; +}; +export function annotationLayerConfig(): ExpressionFunctionDefinition< + 'lens_xy_annotation_layer', + null, + AnnotationLayerArgs, + XYAnnotationLayerArgsResult +> { + return { + name: 'lens_xy_annotation_layer', + aliases: [], + type: 'lens_xy_annotation_layer', + inputTypes: ['null'], + help: 'Annotation layer in lens', + args: { + layerId: { + types: ['string'], + help: '', + }, + layerType: { types: ['string'], options: [layerTypes.ANNOTATIONS], help: '' }, + hide: { + types: ['boolean'], + default: false, + help: 'Show details', + }, + annotations: { + types: ['manual_event_annotation'], + help: '', + multi: true, + }, + }, + fn: (input, args) => { + return { + type: 'lens_xy_annotation_layer', + ...args, + }; + }, + }; +} diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts index 0b27ce7d6ed85..df27229bdb81f 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts @@ -6,7 +6,12 @@ */ import { XYDataLayerConfig } from './data_layer_config'; import { XYReferenceLineLayerConfig } from './reference_line_layer_config'; +import { XYAnnotationLayerConfig } from './annotation_layer_config'; export * from './data_layer_config'; export * from './reference_line_layer_config'; +export * from './annotation_layer_config'; -export type XYLayerConfig = XYDataLayerConfig | XYReferenceLineLayerConfig; +export type XYLayerConfig = + | XYDataLayerConfig + | XYReferenceLineLayerConfig + | XYAnnotationLayerConfig; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts index 940896a2079e6..4520f0c99c3e9 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts @@ -9,13 +9,14 @@ import type { AxisExtentConfigResult, AxisTitlesVisibilityConfigResult } from '. import type { FittingFunction } from './fitting_function'; import type { EndValue } from './end_value'; import type { GridlinesConfigResult } from './grid_lines_config'; -import type { DataLayerArgs } from './layer_config'; +import type { AnnotationLayerArgs, DataLayerArgs } from './layer_config'; import type { LegendConfigResult } from './legend_config'; import type { TickLabelsConfigResult } from './tick_labels_config'; import type { LabelsOrientationConfigResult } from './labels_orientation_config'; import type { ValueLabelConfig } from '../../types'; export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; +export type XYLayerArgs = DataLayerArgs | AnnotationLayerArgs; // Arguments to XY chart expression, with computed properties export interface XYArgs { @@ -28,7 +29,7 @@ export interface XYArgs { yRightExtent: AxisExtentConfigResult; legend: LegendConfigResult; valueLabels: ValueLabelConfig; - layers: DataLayerArgs[]; + layers: XYLayerArgs[]; fittingFunction?: FittingFunction; endValue?: EndValue; emphasizeFitting?: boolean; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts index d0f278d382be9..6d73e8eb9ba5f 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts @@ -128,8 +128,12 @@ export const xyChart: ExpressionFunctionDefinition< }), }, layers: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - types: ['lens_xy_data_layer', 'lens_xy_referenceLine_layer'] as any, + types: [ + 'lens_xy_data_layer', + 'lens_xy_referenceLine_layer', + 'lens_xy_annotation_layer', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, help: 'Layers of visual series', multi: true, }, diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 17a58a0f96770..18f33adf40840 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -21,7 +21,8 @@ "presentationUtil", "dataViewFieldEditor", "expressionGauge", - "expressionHeatmap" + "expressionHeatmap", + "eventAnnotation" ], "optionalPlugins": [ "usageCollection", diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 83b0a39be9229..5e859c1a93818 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -38,6 +38,13 @@ fill: makeGraphicContrastColor($euiColorVis0, $euiColorDarkShade); } } +.lensAnnotationIconNoFill { + fill: none; +} + +.lensAnnotationIconFill { + fill: $euiColorGhost; +} // Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future. .lnsNavItem__goBack { diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx new file mode 100644 index 0000000000000..fe19dc7e4c8fc --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx @@ -0,0 +1,31 @@ +/* + * 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 * as React from 'react'; +import { EuiIconProps } from '@elastic/eui'; +import classnames from 'classnames'; + +export const IconCircle = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx new file mode 100644 index 0000000000000..9e641d495582f --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx @@ -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 { IconCircle } from './circle'; +export { IconTriangle } from './triangle'; diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx new file mode 100644 index 0000000000000..9924c049004cf --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx @@ -0,0 +1,30 @@ +/* + * 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 * as React from 'react'; +import { EuiIconProps } from '@elastic/eui'; +import classnames from 'classnames'; + +export const IconTriangle = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); diff --git a/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx new file mode 100644 index 0000000000000..63fc9023533f6 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx @@ -0,0 +1,37 @@ +/* + * 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 React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const LensIconChartBarAnnotations = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? {title} : null} + + + + + +); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx index e88b04588d2e0..f0e0911b708fd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx @@ -18,6 +18,7 @@ import { getCustomDropTarget, getAdditionalClassesOnDroppable, getAdditionalClassesOnEnter, + getDropProps, } from './drop_targets_utils'; export function DraggableDimensionButton({ @@ -59,8 +60,8 @@ export function DraggableDimensionButton({ }) { const { dragging } = useContext(DragContext); - const dropProps = layerDatasource.getDropProps({ - ...layerDatasourceDropProps, + const dropProps = getDropProps(layerDatasource, { + ...(layerDatasourceDropProps || {}), dragging, columnId, filterOperations: group.filterOperations, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx index 7d92eb9d22cbb..a293af4d11bfe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx @@ -9,7 +9,7 @@ import React from 'react'; import classNames from 'classnames'; import { EuiIcon, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DropType } from '../../../../types'; +import { Datasource, DropType, GetDropProps } from '../../../../types'; function getPropsForDropType(type: 'swap' | 'duplicate' | 'combine') { switch (type) { @@ -129,3 +129,13 @@ export const getAdditionalClassesOnDroppable = (dropType?: string) => { return 'lnsDragDrop-notCompatible'; } }; + +export const getDropProps = ( + layerDatasource: Datasource, + layerDatasourceDropProps: GetDropProps +) => { + if (layerDatasource) { + return layerDatasource.getDropProps(layerDatasourceDropProps); + } + return; +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx index 1ba3ff8f6ac34..f2118bda216b8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx @@ -14,7 +14,11 @@ import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../../types'; import { LayerDatasourceDropProps } from '../types'; -import { getCustomDropTarget, getAdditionalClassesOnDroppable } from './drop_targets_utils'; +import { + getCustomDropTarget, + getAdditionalClassesOnDroppable, + getDropProps, +} from './drop_targets_utils'; const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', { defaultMessage: 'Empty dimension', @@ -24,32 +28,47 @@ interface EmptyButtonProps { columnId: string; onClick: (id: string) => void; group: VisualizationDimensionGroupConfig; + labels?: { + ariaLabel: (label: string) => string; + label: JSX.Element | string; + }; } -const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( - + i18n.translate('xpack.lens.indexPattern.addColumnAriaLabel', { defaultMessage: 'Add or drag-and-drop a field to {groupLabel}', - values: { groupLabel: group.groupLabel }, - })} - data-test-subj="lns-empty-dimension" - onClick={() => { - onClick(columnId); - }} - > + values: { groupLabel: l }, + }), + label: ( - -); + ), +}; + +const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => { + const { buttonAriaLabel, buttonLabel } = group.labels || {}; + return ( + { + onClick(columnId); + }} + > + {buttonLabel || defaultButtonLabels.label} + + ); +}; const SuggestedValueButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( contentProps={{ className: 'lnsLayerPanel__triggerTextContent', }} - aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnAriaLabel', { - defaultMessage: 'Add or drag-and-drop a field to {groupLabel}', - values: { groupLabel: group.groupLabel }, + aria-label={i18n.translate('xpack.lens.indexPattern.suggestedValueAriaLabel', { + defaultMessage: 'Suggested value: {value} for {groupLabel}', + values: { value: group.suggestedValue?.(), groupLabel: group.groupLabel }, })} data-test-subj="lns-empty-dimension-suggested-value" onClick={() => { @@ -112,8 +131,8 @@ export function EmptyDimensionButton({ setNewColumnId(generateId()); }, [itemIndex]); - const dropProps = layerDatasource.getDropProps({ - ...layerDatasourceDropProps, + const dropProps = getDropProps(layerDatasource, { + ...(layerDatasourceDropProps || {}), dragging, columnId: newColumnId, filterOperations: group.filterOperations, @@ -151,6 +170,12 @@ export function EmptyDimensionButton({ [value, onDrop] ); + const buttonProps: EmptyButtonProps = { + columnId: value.columnId, + onClick, + group, + }; + return (
{typeof group.suggestedValue?.() === 'number' ? ( - + ) : ( - + )}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index cd26cd3197587..b234b18f5262f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -20,7 +20,7 @@ import { LayerPanel } from './layer_panel'; import { coreMock } from 'src/core/public/mocks'; import { generateId } from '../../../id_generator'; import { mountWithProvider } from '../../../mocks'; -import { layerTypes } from '../../../../common'; +import { LayerType, layerTypes } from '../../../../common'; import { ReactWrapper } from 'enzyme'; import { addLayer } from '../../../state_management'; @@ -231,14 +231,17 @@ describe('ConfigPanel', () => { }); describe('initial default value', () => { - function clickToAddLayer(instance: ReactWrapper) { + function clickToAddLayer( + instance: ReactWrapper, + layerType: LayerType = layerTypes.REFERENCELINE + ) { act(() => { instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); }); instance.update(); act(() => { instance - .find(`[data-test-subj="lnsLayerAddButton-${layerTypes.REFERENCELINE}"]`) + .find(`[data-test-subj="lnsLayerAddButton-${layerType}"]`) .first() .simulate('click'); }); @@ -288,8 +291,6 @@ describe('ConfigPanel', () => { { groupId: 'testGroup', columnId: 'myColumn', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -319,8 +320,6 @@ describe('ConfigPanel', () => { { groupId: 'testGroup', columnId: 'myColumn', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -335,9 +334,7 @@ describe('ConfigPanel', () => { expect(lensStore.dispatch).toHaveBeenCalledTimes(1); expect(datasourceMap.testDatasource.initializeDimension).toHaveBeenCalledWith({}, 'newId', { columnId: 'myColumn', - dataType: 'number', groupId: 'testGroup', - label: 'Initial value', staticValue: 100, }); }); @@ -354,8 +351,6 @@ describe('ConfigPanel', () => { { groupId: 'a', columnId: 'newId', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -374,11 +369,65 @@ describe('ConfigPanel', () => { { groupId: 'a', columnId: 'newId', - dataType: 'number', - label: 'Initial value', staticValue: 100, } ); }); + + it('When visualization is `noDatasource` should not run datasource methods', async () => { + const datasourceMap = mockDatasourceMap(); + + const visualizationMap = mockVisualizationMap(); + visualizationMap.testVis.setDimension = jest.fn(); + visualizationMap.testVis.getSupportedLayers = jest.fn(() => [ + { + type: layerTypes.DATA, + label: 'Data Layer', + initialDimensions: [ + { + groupId: 'testGroup', + columnId: 'myColumn', + staticValue: 100, + }, + ], + }, + { + type: layerTypes.REFERENCELINE, + label: 'Reference layer', + }, + { + type: layerTypes.ANNOTATIONS, + label: 'Annotations Layer', + noDatasource: true, + initialDimensions: [ + { + groupId: 'a', + columnId: 'newId', + staticValue: 100, + }, + ], + }, + ]); + + datasourceMap.testDatasource.initializeDimension = jest.fn(); + const props = getDefaultProps({ visualizationMap, datasourceMap }); + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance, layerTypes.ANNOTATIONS); + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + + expect(visualizationMap.testVis.setDimension).toHaveBeenCalledWith({ + columnId: 'newId', + frame: { + activeData: undefined, + datasourceLayers: { + a: expect.anything(), + }, + }, + groupId: 'a', + layerId: 'newId', + prevState: undefined, + }); + expect(datasourceMap.testDatasource.initializeDimension).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index d3574abe4f57a..163d1b8ce8e61 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -135,61 +135,57 @@ export function LayerPanels( [dispatchLens] ); - const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers; - return ( - {layerIds.map((layerId, layerIndex) => - datasourcePublicAPIs[layerId] ? ( - { - // avoid state update if the datasource does not support initializeDimension - if ( - activeDatasourceId != null && - datasourceMap[activeDatasourceId]?.initializeDimension - ) { - dispatchLens( - setLayerDefaultDimension({ - layerId, - columnId, - groupId, - }) - ); - } - }} - onRemoveLayer={() => { + {layerIds.map((layerId, layerIndex) => ( + { + // avoid state update if the datasource does not support initializeDimension + if ( + activeDatasourceId != null && + datasourceMap[activeDatasourceId]?.initializeDimension + ) { dispatchLens( - removeOrClearLayer({ - visualizationId: activeVisualization.id, + setLayerDefaultDimension({ layerId, - layerIds, + columnId, + groupId, }) ); - removeLayerRef(layerId); - }} - toggleFullscreen={toggleFullscreen} - /> - ) : null - )} + } + }} + onRemoveLayer={() => { + dispatchLens( + removeOrClearLayer({ + visualizationId: activeVisualization.id, + layerId, + layerIds, + }) + ); + removeLayerRef(layerId); + }} + toggleFullscreen={toggleFullscreen} + /> + ))} Hello!, + style: {}, + }, +}; + describe('LayerPanel', () => { let mockVisualization: jest.Mocked; let mockVisualization2: jest.Mocked; let mockDatasource: DatasourceMock; + mockDatasource = createMockDatasource('testDatasource'); let frame: FramePublicAPI; function getDefaultProps() { @@ -611,17 +623,6 @@ describe('LayerPanel', () => { nextLabel: '', }); - const draggingField = { - field: { name: 'dragged' }, - indexPatternId: 'a', - id: '1', - humanData: { label: 'Label' }, - ghost: { - children: , - style: {}, - }, - }; - const { instance } = await mountWithProvider( @@ -666,17 +667,6 @@ describe('LayerPanel', () => { columnId !== 'a' ? { dropTypes: ['field_replace'], nextLabel: '' } : undefined ); - const draggingField = { - field: { name: 'dragged' }, - indexPatternId: 'a', - id: '1', - humanData: { label: 'Label' }, - ghost: { - children: , - style: {}, - }, - }; - const { instance } = await mountWithProvider( @@ -985,4 +975,52 @@ describe('LayerPanel', () => { ); }); }); + describe('dimension trigger', () => { + it('should render datasource dimension trigger if there is layer datasource', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + await mountWithProvider(); + expect(mockDatasource.renderDimensionTrigger).toHaveBeenCalled(); + }); + + it('should render visualization dimension trigger if there is no layer datasource', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const props = getDefaultProps(); + const propsWithVisOnlyLayer = { + ...props, + framePublicAPI: { ...props.framePublicAPI, datasourceLayers: {} }, + }; + + mockVisualization.renderDimensionTrigger = jest.fn(); + mockVisualization.getUniqueLabels = jest.fn(() => ({ + x: 'A', + })); + + await mountWithProvider(); + expect(mockDatasource.renderDimensionTrigger).not.toHaveBeenCalled(); + expect(mockVisualization.renderDimensionTrigger).toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 404a40832fc2f..366d3f93bf842 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -81,10 +81,10 @@ export function LayerPanel( updateDatasourceAsync, visualizationState, } = props; - const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; - const dateRange = useLensSelector(selectResolvedDateRange); + const datasourceStates = useLensSelector(selectDatasourceStates); const isFullscreen = useLensSelector(selectIsFullscreenDatasource); + const dateRange = useLensSelector(selectResolvedDateRange); useEffect(() => { setActiveDimension(initialActiveDimensionState); @@ -104,8 +104,10 @@ export function LayerPanel( activeData: props.framePublicAPI.activeData, }; - const datasourceId = datasourcePublicAPI.datasourceId; - const layerDatasourceState = datasourceStates[datasourceId].state; + const datasourcePublicAPI = framePublicAPI.datasourceLayers?.[layerId]; + const datasourceId = datasourcePublicAPI?.datasourceId; + const layerDatasourceState = datasourceStates?.[datasourceId]?.state; + const layerDatasource = props.datasourceMap[datasourceId]; const layerDatasourceDropProps = useMemo( () => ({ @@ -118,12 +120,9 @@ export function LayerPanel( [layerId, layerDatasourceState, datasourceId, updateDatasource] ); - const layerDatasource = props.datasourceMap[datasourceId]; - const layerDatasourceConfigProps = { ...layerDatasourceDropProps, frame: props.framePublicAPI, - activeData: props.framePublicAPI.activeData, dateRange, }; @@ -137,11 +136,15 @@ export function LayerPanel( activeVisualization, ] ); + + const columnLabelMap = + !layerDatasource && activeVisualization.getUniqueLabels + ? activeVisualization.getUniqueLabels(props.visualizationState) + : layerDatasource?.uniqueLabels?.(layerDatasourceConfigProps?.state); + const isEmptyLayer = !groups.some((d) => d.accessors.length > 0); const { activeId, activeGroup } = activeDimension; - const columnLabelMap = layerDatasource.uniqueLabels(layerDatasourceConfigProps.state); - const { setDimension, removeDimension } = activeVisualization; const allAccessors = groups.flatMap((group) => @@ -154,7 +157,7 @@ export function LayerPanel( registerNewRef: registerNewButtonRef, } = useFocusUpdate(allAccessors); - const layerDatasourceOnDrop = layerDatasource.onDrop; + const layerDatasourceOnDrop = layerDatasource?.onDrop; const onDrop = useMemo(() => { return ( @@ -180,16 +183,18 @@ export function LayerPanel( const filterOperations = group?.filterOperations || (() => false); - const dropResult = layerDatasourceOnDrop({ - ...layerDatasourceDropProps, - droppedItem, - columnId, - layerId: targetLayerId, - filterOperations, - dimensionGroups: groups, - groupId, - dropType, - }); + const dropResult = layerDatasource + ? layerDatasourceOnDrop({ + ...layerDatasourceDropProps, + droppedItem, + columnId, + layerId: targetLayerId, + filterOperations, + dimensionGroups: groups, + groupId, + dropType, + }) + : false; if (dropResult) { let previousColumn = typeof droppedItem.column === 'string' ? droppedItem.column : undefined; @@ -241,6 +246,7 @@ export function LayerPanel( removeDimension, layerDatasourceDropProps, setNextFocusedButtonId, + layerDatasource, ]); const isDimensionPanelOpen = Boolean(activeId); @@ -340,43 +346,45 @@ export function LayerPanel( /> - {layerDatasource && ( - { - const newState = - typeof updater === 'function' ? updater(layerDatasourceState) : updater; - // Look for removed columns - const nextPublicAPI = layerDatasource.getPublicAPI({ - state: newState, - layerId, - }); - const nextTable = new Set( - nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) - ); - const removed = datasourcePublicAPI - .getTableSpec() - .map(({ columnId }) => columnId) - .filter((columnId) => !nextTable.has(columnId)); - let nextVisState = props.visualizationState; - removed.forEach((columnId) => { - nextVisState = activeVisualization.removeDimension({ + <> + + { + const newState = + typeof updater === 'function' ? updater(layerDatasourceState) : updater; + // Look for removed columns + const nextPublicAPI = layerDatasource.getPublicAPI({ + state: newState, layerId, - columnId, - prevState: nextVisState, - frame: framePublicAPI, }); - }); + const nextTable = new Set( + nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) + ); + const removed = datasourcePublicAPI + .getTableSpec() + .map(({ columnId }) => columnId) + .filter((columnId) => !nextTable.has(columnId)); + let nextVisState = props.visualizationState; + removed.forEach((columnId) => { + nextVisState = activeVisualization.removeDimension({ + layerId, + columnId, + prevState: nextVisState, + frame: framePublicAPI, + }); + }); - props.updateAll(datasourceId, newState, nextVisState); - }, - }} - /> + props.updateAll(datasourceId, newState, nextVisState); + }, + }} + /> + )} @@ -401,7 +409,6 @@ export function LayerPanel( : i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', { defaultMessage: 'Requires field', }); - const isOptional = !group.required && !group.suggestedValue; return ( {group.accessors.map((accessorConfig, accessorIndex) => { const { columnId } = accessorConfig; - return ( { setActiveDimension({ @@ -478,42 +484,66 @@ export function LayerPanel( }} onRemoveClick={(id: string) => { trackUiEvent('indexpattern_dimension_removed'); - props.updateAll( - datasourceId, - layerDatasource.removeColumn({ - layerId, - columnId: id, - prevState: layerDatasourceState, - }), - activeVisualization.removeDimension({ - layerId, - columnId: id, - prevState: props.visualizationState, - frame: framePublicAPI, - }) - ); + if (datasourceId && layerDatasource) { + props.updateAll( + datasourceId, + layerDatasource.removeColumn({ + layerId, + columnId: id, + prevState: layerDatasourceState, + }), + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + frame: framePublicAPI, + }) + ); + } else { + props.updateVisualization( + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + frame: framePublicAPI, + }) + ); + } removeButtonRef(id); }} invalid={ - !layerDatasource.isValidColumn( + layerDatasource && + !layerDatasource?.isValidColumn( layerDatasourceState, layerId, columnId ) } > - + {layerDatasource ? ( + + ) : ( + <> + {activeVisualization?.renderDimensionTrigger?.({ + columnId, + label: columnLabelMap[columnId], + hideTooltip, + invalid: group.invalid, + invalidMessage: group.invalidMessage, + })} + + )}
@@ -536,7 +566,7 @@ export function LayerPanel( setActiveDimension({ activeGroup: group, activeId: id, - isNew: !group.supportStaticValue, + isNew: !group.supportStaticValue && Boolean(layerDatasource), }); }} onDrop={onDrop} @@ -555,22 +585,25 @@ export function LayerPanel( isFullscreen={isFullscreen} groupLabel={activeGroup?.groupLabel || ''} handleClose={() => { - if ( - layerDatasource.canCloseDimensionEditor && - !layerDatasource.canCloseDimensionEditor(layerDatasourceState) - ) { - return false; - } - if (layerDatasource.updateStateOnCloseDimension) { - const newState = layerDatasource.updateStateOnCloseDimension({ - state: layerDatasourceState, - layerId, - columnId: activeId!, - }); - if (newState) { - props.updateDatasource(datasourceId, newState); + if (layerDatasource) { + if ( + layerDatasource.canCloseDimensionEditor && + !layerDatasource.canCloseDimensionEditor(layerDatasourceState) + ) { + return false; + } + if (layerDatasource.updateStateOnCloseDimension) { + const newState = layerDatasource.updateStateOnCloseDimension({ + state: layerDatasourceState, + layerId, + columnId: activeId!, + }); + if (newState) { + props.updateDatasource(datasourceId, newState); + } } } + setActiveDimension(initialActiveDimensionState); if (isFullscreen) { toggleFullscreen(); @@ -579,7 +612,7 @@ export function LayerPanel( }} panel={
- {activeGroup && activeId && ( + {activeGroup && activeId && layerDatasource && ( - + - - color)} - type={FIXED_PROGRESSION} - onClick={() => { - setIsPaletteOpen(!isPaletteOpen); - }} - /> - - - { - setIsPaletteOpen(!isPaletteOpen); - }} - size="xs" - flush="both" - > - {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', { - defaultMessage: 'Edit', - })} - - setIsPaletteOpen(!isPaletteOpen)} - > - {activePalette && ( - { - // make sure to always have a list of stops - if (newPalette.params && !newPalette.params.stops) { - newPalette.params.stops = displayStops; - } - (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; - setState({ - ...state, - palette: newPalette as HeatmapVisualizationState['palette'], - }); - }} - /> - )} - - - - + + + color)} + type={FIXED_PROGRESSION} + onClick={() => { + setIsPaletteOpen(!isPaletteOpen); + }} + /> + + + { + setIsPaletteOpen(!isPaletteOpen); + }} + size="xs" + flush="both" + > + {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', { + defaultMessage: 'Edit', + })} + + setIsPaletteOpen(!isPaletteOpen)} + > + {activePalette && ( + { + // make sure to always have a list of stops + if (newPalette.params && !newPalette.params.stops) { + newPalette.params.stops = displayStops; + } + (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; + setState({ + ...state, + palette: newPalette as HeatmapVisualizationState['palette'], + }); + }} + /> + )} + + + + + ); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts index f3c48bace4a5f..3318b8c30909e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -89,12 +89,13 @@ export function getDropProps(props: GetDropProps) { ) { const sourceColumn = state.layers[dragging.layerId].columns[dragging.columnId]; const targetColumn = state.layers[layerId].columns[columnId]; - const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; - const isSameGroup = groupId === dragging.groupId; if (isSameGroup) { - return getDropPropsForSameGroup(targetColumn); - } else if (filterOperations(sourceColumn)) { + return getDropPropsForSameGroup(!targetColumn); + } + const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; + + if (filterOperations(sourceColumn)) { return getDropPropsForCompatibleGroup( props.dimensionGroups, dragging.columnId, @@ -164,8 +165,8 @@ function getDropPropsForField({ return; } -function getDropPropsForSameGroup(targetColumn?: GenericIndexPatternColumn): DropProps { - return targetColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; +function getDropPropsForSameGroup(isNew?: boolean): DropProps { + return !isNew ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; } function getDropPropsForCompatibleGroup( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index f19658d468d5f..6bdd41d8db631 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -2626,9 +2626,7 @@ describe('IndexPattern Data Source', () => { expect( indexPatternDatasource.initializeDimension!(state, 'first', { columnId: 'newStatic', - label: 'MyNewColumn', groupId: 'a', - dataType: 'number', }) ).toBe(state); }); @@ -2655,9 +2653,7 @@ describe('IndexPattern Data Source', () => { expect( indexPatternDatasource.initializeDimension!(state, 'first', { columnId: 'newStatic', - label: 'MyNewColumn', groupId: 'a', - dataType: 'number', staticValue: 0, // use a falsy value to check also this corner case }) ).toEqual({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index cf77d1c9c1cc2..d0b644e2bf9b4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -230,7 +230,7 @@ export function getIndexPatternDatasource({ }); }, - initializeDimension(state, layerId, { columnId, groupId, label, dataType, staticValue }) { + initializeDimension(state, layerId, { columnId, groupId, staticValue }) { const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId]; if (staticValue == null) { return state; diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 2c038b0937999..d1f16ac5f9c41 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -22,8 +22,12 @@ import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PartitionChartsMeta } from './partition_charts_meta'; import { LegendDisplay, PieVisualizationState, SharedPieLayerState } from '../../common'; import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; -import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components'; -import { PalettePicker } from '../shared_components'; +import { + ToolbarPopover, + LegendSettingsPopover, + useDebouncedValue, + PalettePicker, +} from '../shared_components'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; import { shouldShowValuesInLegend } from './render_helpers'; @@ -298,14 +302,12 @@ export function DimensionEditor( } ) { return ( - <> - { - props.setState({ ...props.state, palette: newPalette }); - }} - /> - + { + props.setState({ ...props.state, palette: newPalette }); + }} + /> ); } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 4d883c3a27c5e..d2bb7cdbb4344 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -33,6 +33,7 @@ import type { NavigationPublicPluginStart } from '../../../../src/plugins/naviga import type { UrlForwardingSetup } from '../../../../src/plugins/url_forwarding/public'; import type { GlobalSearchPluginSetup } from '../../global_search/public'; import type { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/charts/public'; +import type { EventAnnotationPluginSetup } from '../../../../src/plugins/event_annotation/public'; import type { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public'; import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service'; @@ -104,6 +105,7 @@ export interface LensPluginSetupDependencies { embeddable?: EmbeddableSetup; visualizations: VisualizationsSetup; charts: ChartsPluginSetup; + eventAnnotation: EventAnnotationPluginSetup; globalSearch?: GlobalSearchPluginSetup; usageCollection?: UsageCollectionSetup; discover?: DiscoverSetup; @@ -120,6 +122,7 @@ export interface LensPluginStartDependencies { visualizations: VisualizationsStart; embeddable: EmbeddableStart; charts: ChartsPluginStart; + eventAnnotation: EventAnnotationPluginSetup; savedObjectsTagging?: SavedObjectTaggingPluginStart; presentationUtil: PresentationUtilPluginStart; dataViewFieldEditor: IndexPatternFieldEditorStart; @@ -235,6 +238,7 @@ export class LensPlugin { embeddable, visualizations, charts, + eventAnnotation, globalSearch, usageCollection, }: LensPluginSetupDependencies @@ -251,7 +255,8 @@ export class LensPlugin { charts, expressions, fieldFormats, - plugins.fieldFormats.deserialize + plugins.fieldFormats.deserialize, + eventAnnotation ); const visualizationMap = await this.editorFrameService!.loadVisualizations(); @@ -311,7 +316,8 @@ export class LensPlugin { charts, expressions, fieldFormats, - deps.fieldFormats.deserialize + deps.fieldFormats.deserialize, + eventAnnotation ), ensureDefaultDataView(), ]); @@ -368,7 +374,8 @@ export class LensPlugin { charts: ChartsPluginSetup, expressions: ExpressionsServiceSetup, fieldFormats: FieldFormatsSetup, - formatFactory: FormatFactory + formatFactory: FormatFactory, + eventAnnotation: EventAnnotationPluginSetup ) { const { DatatableVisualization, @@ -402,6 +409,7 @@ export class LensPlugin { charts, editorFrame: editorFrameSetupInterface, formatFactory, + eventAnnotation, }; this.indexpatternDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.scss b/x-pack/plugins/lens/public/shared_components/dimension_section.scss new file mode 100644 index 0000000000000..7781c91785d67 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dimension_section.scss @@ -0,0 +1,24 @@ +.lnsDimensionEditorSection { + padding-top: $euiSize; + padding-bottom: $euiSize; +} + +.lnsDimensionEditorSection:first-child { + padding-top: 0; +} + +.lnsDimensionEditorSection:first-child .lnsDimensionEditorSection__border { + display: none; +} + +.lnsDimensionEditorSection__border { + position: relative; + &:before { + content: ''; + position: absolute; + top: -$euiSize; + right: -$euiSize; + left: -$euiSize; + border-top: 1px solid $euiColorLightShade; + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.tsx b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx new file mode 100644 index 0000000000000..d56e08db4b037 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx @@ -0,0 +1,29 @@ +/* + * 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 { EuiTitle } from '@elastic/eui'; +import React from 'react'; +import './dimension_section.scss'; + +export const DimensionEditorSection = ({ + children, + title, +}: { + title?: string; + children?: React.ReactNode | React.ReactNode[]; +}) => { + return ( +
+
+ {title && ( + +

{title}

+
+ )} + {children} +
+ ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index 6140e54b43dc7..b2428532a72c9 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -17,5 +17,6 @@ export { LegendActionPopover } from './legend_action_popover'; export { NameInput } from './name_input'; export { ValueLabelsSettings } from './value_labels_settings'; export { AxisTitleSettings } from './axis_title_settings'; +export { DimensionEditorSection } from './dimension_section'; export * from './static_header'; export * from './vis_label'; diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 56ff89f506c85..959db8ca006fe 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -619,30 +619,39 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { return state; } - const activeDatasource = datasourceMap[state.activeDatasourceId]; const activeVisualization = visualizationMap[state.visualization.activeId]; - - const datasourceState = activeDatasource.insertLayer( - state.datasourceStates[state.activeDatasourceId].state, - layerId - ); - const visualizationState = activeVisualization.appendLayer!( state.visualization.state, layerId, layerType ); + const framePublicAPI = { + // any better idea to avoid `as`? + activeData: state.activeData + ? (current(state.activeData) as TableInspectorAdapter) + : undefined, + datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), + }; + + const activeDatasource = datasourceMap[state.activeDatasourceId]; + const { noDatasource } = + activeVisualization + .getSupportedLayers(visualizationState, framePublicAPI) + .find(({ type }) => type === layerType) || {}; + + const datasourceState = + !noDatasource && activeDatasource + ? activeDatasource.insertLayer( + state.datasourceStates[state.activeDatasourceId].state, + layerId + ) + : state.datasourceStates[state.activeDatasourceId].state; + const { activeDatasourceState, activeVisualizationState } = addInitialValueIfAvailable({ datasourceState, visualizationState, - framePublicAPI: { - // any better idea to avoid `as`? - activeData: state.activeData - ? (current(state.activeData) as TableInspectorAdapter) - : undefined, - datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), - }, + framePublicAPI, activeVisualization, activeDatasource, layerId, @@ -710,39 +719,49 @@ function addInitialValueIfAvailable({ framePublicAPI: FramePublicAPI; visualizationState: unknown; datasourceState: unknown; - activeDatasource: Datasource; + activeDatasource?: Datasource; activeVisualization: Visualization; layerId: string; layerType: string; columnId?: string; groupId?: string; }) { - const layerInfo = activeVisualization - .getSupportedLayers(visualizationState, framePublicAPI) - .find(({ type }) => type === layerType); + const { initialDimensions, noDatasource } = + activeVisualization + .getSupportedLayers(visualizationState, framePublicAPI) + .find(({ type }) => type === layerType) || {}; - if (layerInfo?.initialDimensions && activeDatasource?.initializeDimension) { + if (initialDimensions) { const info = groupId - ? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId) - : // pick the first available one if not passed - layerInfo.initialDimensions[0]; + ? initialDimensions.find(({ groupId: id }) => id === groupId) + : initialDimensions[0]; // pick the first available one if not passed if (info) { - return { - activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { - ...info, - columnId: columnId || info.columnId, - }), - activeVisualizationState: activeVisualization.setDimension({ - groupId: info.groupId, - layerId, - columnId: columnId || info.columnId, - prevState: visualizationState, - frame: framePublicAPI, - }), - }; + const activeVisualizationState = activeVisualization.setDimension({ + groupId: info.groupId, + layerId, + columnId: columnId || info.columnId, + prevState: visualizationState, + frame: framePublicAPI, + }); + + if (!noDatasource && activeDatasource?.initializeDimension) { + return { + activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { + ...info, + columnId: columnId || info.columnId, + }), + activeVisualizationState, + }; + } else { + return { + activeDatasourceState: datasourceState, + activeVisualizationState, + }; + } } } + return { activeDatasourceState: datasourceState, activeVisualizationState: visualizationState, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 9bea94bd723d3..cfa23320dc561 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -198,6 +198,12 @@ interface ChartSettings { }; } +export type GetDropProps = DatasourceDimensionDropProps & { + groupId: string; + dragging: DragContextState['dragging']; + prioritizedOperation?: string; +}; + /** * Interface for the datasource registry */ @@ -227,10 +233,8 @@ export interface Datasource { layerId: string, value: { columnId: string; - label: string; - dataType: string; - staticValue?: unknown; groupId: string; + staticValue?: unknown; } ) => T; @@ -251,11 +255,7 @@ export interface Datasource { props: DatasourceLayerPanelProps ) => ((cleanupElement: Element) => void) | void; getDropProps: ( - props: DatasourceDimensionDropProps & { - groupId: string; - dragging: DragContextState['dragging']; - prioritizedOperation?: string; - } + props: GetDropProps ) => { dropTypes: DropType[]; nextLabel?: string } | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; /** @@ -585,6 +585,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { supportStaticValue?: boolean; paramEditorCustomProps?: ParamEditorCustomProps; supportFieldFormat?: boolean; + labels?: { buttonAriaLabel: string; buttonLabel: string }; }; interface VisualizationDimensionChangeProps { @@ -786,14 +787,13 @@ export interface Visualization { type: LayerType; label: string; icon?: IconType; + noDatasource?: boolean; disabled?: boolean; toolTipContent?: string; initialDimensions?: Array<{ - groupId: string; columnId: string; - dataType: string; - label: string; - staticValue: unknown; + groupId: string; + staticValue?: unknown; }>; }>; getLayerType: (layerId: string, state?: T) => LayerType | undefined; @@ -858,7 +858,20 @@ export interface Visualization { domElement: Element, props: VisualizationDimensionEditorProps ) => ((cleanupElement: Element) => void) | void; - + /** + * Renders dimension trigger. Used only for noDatasource layers + */ + renderDimensionTrigger?: (props: { + columnId: string; + label: string; + hideTooltip?: boolean; + invalid?: boolean; + invalidMessage?: string; + }) => JSX.Element | null; + /** + * Creates map of columns ids and unique lables. Used only for noDatasource layers + */ + getUniqueLabels?: (state: T) => Record; /** * The frame will call this function on all visualizations at different times. The * main use cases where visualization suggestions are requested are: diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index e1885fafab5e0..1770bac893b67 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -399,22 +399,16 @@ export const getGaugeVisualization = ({ { groupId: 'min', columnId: generateId(), - dataType: 'number', - label: 'minAccessor', staticValue: minValue, }, { groupId: 'max', columnId: generateId(), - dataType: 'number', - label: 'maxAccessor', staticValue: maxValue, }, { groupId: 'goal', columnId: generateId(), - dataType: 'number', - label: 'goalAccessor', staticValue: goalValue, }, ] diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index 504a553c5a631..fdde8eb6ad3f2 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -1,5 +1,218 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xy_expression XYChart component annotations should render basic annotation 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render grouped annotations preserving the shared styles 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": Array [ + 9, + 3, + ], + "opacity": 1, + "stroke": "red", + "strokeWidth": 3, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render grouped annotations with default styles 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render simplified annotation when hide is true 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + exports[`xy_expression XYChart component it renders area 1`] = ` & { + formatFactory: FormatFactory; + paletteService: PaletteRegistry; + } +) => { + const { state, setState, layerId, accessor } = props; + const isHorizontal = isHorizontalChart(state.layers); + + const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ + value: state, + onChange: setState, + }); + + const index = localState.layers.findIndex((l) => l.layerId === layerId); + const localLayer = localState.layers.find( + (l) => l.layerId === layerId + ) as XYAnnotationLayerConfig; + + const currentAnnotations = localLayer.annotations?.find((c) => c.id === accessor); + + const setAnnotations = useCallback( + (annotations: Partial | undefined) => { + if (annotations == null) { + return; + } + const newConfigs = [...(localLayer.annotations || [])]; + const existingIndex = newConfigs.findIndex((c) => c.id === accessor); + if (existingIndex !== -1) { + newConfigs[existingIndex] = { ...newConfigs[existingIndex], ...annotations }; + } else { + return; // that should never happen because annotations are created before annotations panel is opened + } + setLocalState(updateLayer(localState, { ...localLayer, annotations: newConfigs }, index)); + }, + [accessor, index, localState, localLayer, setLocalState] + ); + + return ( + <> + + { + if (date) { + setAnnotations({ + key: { + ...(currentAnnotations?.key || { type: 'point_in_time' }), + timestamp: date.toISOString(), + }, + }); + } + }} + label={i18n.translate('xpack.lens.xyChart.annotationDate', { + defaultMessage: 'Annotation date', + })} + /> + + + { + setAnnotations({ label: value }); + }} + /> + + + + setAnnotations({ isHidden: ev.target.checked })} + /> + + + ); +}; + +const ConfigPanelDatePicker = ({ + value, + label, + onChange, +}: { + value: moment.Moment; + label: string; + onChange: (val: moment.Moment | null) => void; +}) => { + return ( + + + + ); +}; + +const ConfigPanelHideSwitch = ({ + value, + onChange, +}: { + value: boolean; + onChange: (event: EuiSwitchEvent) => void; +}) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss new file mode 100644 index 0000000000000..fc2b1204bb1d0 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss @@ -0,0 +1,37 @@ +.lnsXyDecorationRotatedWrapper { + display: inline-block; + overflow: hidden; + line-height: 1.5; + + .lnsXyDecorationRotatedWrapper__label { + display: inline-block; + white-space: nowrap; + transform: translate(0, 100%) rotate(-90deg); + transform-origin: 0 0; + + &::after { + content: ''; + float: left; + margin-top: 100%; + } + } +} + +.lnsXyAnnotationNumberIcon { + border-radius: $euiSize; + min-width: $euiSize; + height: $euiSize; + background-color: currentColor; +} + +.lnsXyAnnotationNumberIcon__text { + font-weight: 500; + font-size: 9px; + letter-spacing: -.5px; + line-height: 11px; +} + +.lnsXyAnnotationIcon_rotate90 { + transform: rotate(45deg); + transform-origin: center; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx new file mode 100644 index 0000000000000..c36488f29d238 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx @@ -0,0 +1,233 @@ +/* + * 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 './expression.scss'; +import React from 'react'; +import { snakeCase } from 'lodash'; +import { + AnnotationDomainType, + AnnotationTooltipFormatter, + LineAnnotation, + Position, +} from '@elastic/charts'; +import type { FieldFormat } from 'src/plugins/field_formats/common'; +import type { EventAnnotationArgs } from 'src/plugins/event_annotation/common'; +import moment from 'moment'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; +import type { AnnotationLayerArgs } from '../../../common/expressions'; +import { hasIcon } from '../xy_config_panel/shared/icon_select'; +import { + mapVerticalToHorizontalPlacement, + LINES_MARKER_SIZE, + MarkerBody, + Marker, + AnnotationIcon, +} from '../annotations_helpers'; + +const getRoundedTimestamp = (timestamp: number, firstTimestamp?: number, minInterval?: number) => { + if (!firstTimestamp || !minInterval) { + return timestamp; + } + return timestamp - ((timestamp - firstTimestamp) % minInterval); +}; + +export interface AnnotationsProps { + groupedAnnotations: CollectiveConfig[]; + formatter?: FieldFormat; + isHorizontal: boolean; + paddingMap: Partial>; + hide?: boolean; + minInterval?: number; + isBarChart?: boolean; +} + +interface CollectiveConfig extends EventAnnotationArgs { + roundedTimestamp: number; + axisMode: 'bottom'; + customTooltipDetails?: AnnotationTooltipFormatter | undefined; +} + +const groupVisibleConfigsByInterval = ( + layers: AnnotationLayerArgs[], + minInterval?: number, + firstTimestamp?: number +) => { + return layers + .flatMap(({ annotations }) => annotations.filter((a) => !a.isHidden)) + .reduce>((acc, current) => { + const roundedTimestamp = getRoundedTimestamp( + moment(current.time).valueOf(), + firstTimestamp, + minInterval + ); + return { + ...acc, + [roundedTimestamp]: acc[roundedTimestamp] ? [...acc[roundedTimestamp], current] : [current], + }; + }, {}); +}; + +const createCustomTooltipDetails = + ( + config: EventAnnotationArgs[], + formatter?: FieldFormat + ): AnnotationTooltipFormatter | undefined => + () => { + return ( +
+ {config.map(({ icon, label, time, color }) => ( +
+ + {hasIcon(icon) && ( + + + + )} + {label} + + {formatter?.convert(time) || String(time)} +
+ ))} +
+ ); + }; + +function getCommonProperty( + configArr: EventAnnotationArgs[], + propertyName: K, + fallbackValue: T +) { + const firstStyle = configArr[0][propertyName]; + if (configArr.every((config) => firstStyle === config[propertyName])) { + return firstStyle; + } + return fallbackValue; +} + +const getCommonStyles = (configArr: EventAnnotationArgs[]) => { + return { + color: getCommonProperty( + configArr, + 'color', + defaultAnnotationColor + ), + lineWidth: getCommonProperty(configArr, 'lineWidth', 1), + lineStyle: getCommonProperty(configArr, 'lineStyle', 'solid'), + textVisibility: getCommonProperty(configArr, 'textVisibility', false), + }; +}; + +export const getAnnotationsGroupedByInterval = ( + layers: AnnotationLayerArgs[], + minInterval?: number, + firstTimestamp?: number, + formatter?: FieldFormat +) => { + const visibleGroupedConfigs = groupVisibleConfigsByInterval(layers, minInterval, firstTimestamp); + let collectiveConfig: CollectiveConfig; + return Object.entries(visibleGroupedConfigs).map(([roundedTimestamp, configArr]) => { + collectiveConfig = { + ...configArr[0], + roundedTimestamp: Number(roundedTimestamp), + axisMode: 'bottom', + }; + if (configArr.length > 1) { + const commonStyles = getCommonStyles(configArr); + collectiveConfig = { + ...collectiveConfig, + ...commonStyles, + icon: String(configArr.length), + customTooltipDetails: createCustomTooltipDetails(configArr, formatter), + }; + } + return collectiveConfig; + }); +}; + +export const Annotations = ({ + groupedAnnotations, + formatter, + isHorizontal, + paddingMap, + hide, + minInterval, + isBarChart, +}: AnnotationsProps) => { + return ( + <> + {groupedAnnotations.map((annotation) => { + const markerPositionVertical = Position.Top; + const markerPosition = isHorizontal + ? mapVerticalToHorizontalPlacement(markerPositionVertical) + : markerPositionVertical; + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + const id = snakeCase(annotation.label); + const { roundedTimestamp, time: exactTimestamp } = annotation; + const isGrouped = Boolean(annotation.customTooltipDetails); + const header = + formatter?.convert(isGrouped ? roundedTimestamp : exactTimestamp) || + moment(isGrouped ? roundedTimestamp : exactTimestamp).toISOString(); + const strokeWidth = annotation.lineWidth || 1; + return ( + + ) : undefined + } + markerBody={ + !hide ? ( + + ) : undefined + } + markerPosition={markerPosition} + dataValues={[ + { + dataValue: moment( + isBarChart && minInterval ? roundedTimestamp + minInterval / 2 : roundedTimestamp + ).valueOf(), + header, + details: annotation.label, + }, + ]} + customTooltipDetails={annotation.customTooltipDetails} + style={{ + line: { + strokeWidth, + stroke: annotation.color || defaultAnnotationColor, + dash: + annotation.lineStyle === 'dashed' + ? [strokeWidth * 3, strokeWidth] + : annotation.lineStyle === 'dotted' + ? [strokeWidth, strokeWidth] + : undefined, + opacity: 1, + }, + }} + /> + ); + })} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts new file mode 100644 index 0000000000000..fbf13db7fa7a5 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts @@ -0,0 +1,210 @@ +/* + * 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 { FramePublicAPI } from '../../types'; +import { getStaticDate } from './helpers'; + +describe('annotations helpers', () => { + describe('getStaticDate', () => { + it('should return `now` value on when nothing is configured', () => { + jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-08T11:01:58.135Z').valueOf()); + expect(getStaticDate([], undefined)).toBe('2022-04-08T11:01:58.135Z'); + }); + it('should return `now` value on when there is no active data', () => { + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + undefined + ) + ).toBe('2022-04-08T11:01:58.135Z'); + }); + + it('should return timestamp value for single active data point', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1646002800000, + b: 1050, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2022-02-27T23:00:00.000Z'); + }); + + it('should correctly calculate middle value for active data', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1648206000000, + b: 19, + }, + { + a: 1648249200000, + b: 73, + }, + { + a: 1648292400000, + b: 69, + }, + { + a: 1648335600000, + b: 7, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2022-03-26T05:00:00.000Z'); + }); + + it('should calculate middle date point correctly for multiple layers', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1648206000000, + b: 19, + }, + { + a: 1648249200000, + b: 73, + }, + { + a: 1648292400000, + b: 69, + }, + { + a: 1648335600000, + b: 7, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + layerId2: { + type: 'datatable', + rows: [ + { + d: 1548206000000, + c: 19, + }, + { + d: 1548249200000, + c: 73, + }, + ], + columns: [ + { + id: 'd', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'c', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + { + layerId: 'layerId2', + accessors: ['c'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'd', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2020-08-24T12:06:40.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx new file mode 100644 index 0000000000000..321090c94241a --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { layerTypes } from '../../../common'; +import type { + XYDataLayerConfig, + XYAnnotationLayerConfig, + XYLayerConfig, +} from '../../../common/expressions'; +import type { FramePublicAPI, Visualization } from '../../types'; +import { isHorizontalChart } from '../state_helpers'; +import type { XYState } from '../types'; +import { + checkScaleOperation, + getAnnotationsLayers, + getAxisName, + getDataLayers, + isAnnotationsLayer, +} from '../visualization_helpers'; +import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations'; +import { generateId } from '../../id_generator'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; +import { defaultAnnotationLabel } from './config_panel'; + +const MAX_DATE = 8640000000000000; +const MIN_DATE = -8640000000000000; + +export function getStaticDate( + dataLayers: XYDataLayerConfig[], + activeData: FramePublicAPI['activeData'] +) { + const fallbackValue = moment().toISOString(); + + const dataLayersId = dataLayers.map(({ layerId }) => layerId); + if ( + !activeData || + Object.entries(activeData) + .filter(([key]) => dataLayersId.includes(key)) + .every(([, { rows }]) => !rows || !rows.length) + ) { + return fallbackValue; + } + + const minDate = dataLayersId.reduce((acc, lId) => { + const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!; + const firstTimestamp = activeData[lId]?.rows?.[0]?.[xAccessor]; + return firstTimestamp && firstTimestamp < acc ? firstTimestamp : acc; + }, MAX_DATE); + + const maxDate = dataLayersId.reduce((acc, lId) => { + const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!; + const lastTimestamp = activeData[lId]?.rows?.[activeData?.[lId]?.rows?.length - 1]?.[xAccessor]; + return lastTimestamp && lastTimestamp > acc ? lastTimestamp : acc; + }, MIN_DATE); + const middleDate = (minDate + maxDate) / 2; + return moment(middleDate).toISOString(); +} + +export const getAnnotationsSupportedLayer = ( + state?: XYState, + frame?: Pick +) => { + const dataLayers = getDataLayers(state?.layers || []); + + const hasDateHistogram = Boolean( + dataLayers.length && + dataLayers.every( + (dataLayer) => + dataLayer.xAccessor && + checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer) + ) + ); + const initialDimensions = + state && hasDateHistogram + ? [ + { + groupId: 'xAnnotations', + columnId: generateId(), + }, + ] + : undefined; + + return { + type: layerTypes.ANNOTATIONS, + label: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabel', { + defaultMessage: 'Annotations', + }), + icon: LensIconChartBarAnnotations, + disabled: !hasDateHistogram, + toolTipContent: !hasDateHistogram + ? i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', { + defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.', + }) + : undefined, + initialDimensions, + noDatasource: true, + }; +}; + +export const setAnnotationsDimension: Visualization['setDimension'] = ({ + prevState, + layerId, + columnId, + previousColumn, + frame, +}) => { + const foundLayer = prevState.layers.find((l) => l.layerId === layerId); + if (!foundLayer || !isAnnotationsLayer(foundLayer)) { + return prevState; + } + const dataLayers = getDataLayers(prevState.layers); + const newLayer = { ...foundLayer } as XYAnnotationLayerConfig; + + const hasConfig = newLayer.annotations?.some(({ id }) => id === columnId); + const previousConfig = previousColumn + ? newLayer.annotations?.find(({ id }) => id === previousColumn) + : false; + if (!hasConfig) { + const newTimestamp = getStaticDate(dataLayers, frame?.activeData); + newLayer.annotations = [ + ...(newLayer.annotations || []), + { + label: defaultAnnotationLabel, + key: { + type: 'point_in_time', + timestamp: newTimestamp, + }, + icon: 'triangle', + ...previousConfig, + id: columnId, + }, + ]; + } + return { + ...prevState, + layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), + }; +}; + +export const getAnnotationsAccessorColorConfig = (layer: XYAnnotationLayerConfig) => { + return layer.annotations.map((annotation) => { + return { + columnId: annotation.id, + triggerIcon: annotation.isHidden ? ('invisible' as const) : ('color' as const), + color: annotation?.color || defaultAnnotationColor, + }; + }); +}; + +export const getAnnotationsConfiguration = ({ + state, + frame, + layer, +}: { + state: XYState; + frame: FramePublicAPI; + layer: XYAnnotationLayerConfig; +}) => { + const dataLayers = getDataLayers(state.layers); + + const hasDateHistogram = Boolean( + dataLayers.length && + dataLayers.every( + (dataLayer) => + dataLayer.xAccessor && + checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer) + ) + ); + + const groupLabel = getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }); + + const emptyButtonLabels = { + buttonAriaLabel: i18n.translate('xpack.lens.indexPattern.addColumnAriaLabelClick', { + defaultMessage: 'Add an annotation to {groupLabel}', + values: { groupLabel }, + }), + buttonLabel: i18n.translate('xpack.lens.configure.emptyConfigClick', { + defaultMessage: 'Add an annotation', + }), + }; + + return { + groups: [ + { + groupId: 'xAnnotations', + groupLabel, + accessors: getAnnotationsAccessorColorConfig(layer), + dataTestSubj: 'lnsXY_xAnnotationsPanel', + invalid: !hasDateHistogram, + invalidMessage: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', { + defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.', + }), + required: false, + requiresPreviousColumnOnDuplicate: true, + supportsMoreColumns: true, + supportFieldFormat: false, + enableDimensionEditor: true, + filterOperations: () => false, + labels: emptyButtonLabels, + }, + ], + }; +}; + +export const getUniqueLabels = (layers: XYLayerConfig[]) => { + const annotationLayers = getAnnotationsLayers(layers); + const columnLabelMap = {} as Record; + const counts = {} as Record; + + const makeUnique = (label: string) => { + let uniqueLabel = label; + + while (counts[uniqueLabel] >= 0) { + const num = ++counts[uniqueLabel]; + uniqueLabel = i18n.translate('xpack.lens.uniqueLabel', { + defaultMessage: '{label} [{num}]', + values: { label, num }, + }); + } + + counts[uniqueLabel] = 0; + return uniqueLabel; + }; + + annotationLayers.forEach((layer) => { + if (!layer.annotations) { + return; + } + layer.annotations.forEach((l) => { + columnLabelMap[l.id] = makeUnique(l.label); + }); + }); + return columnLabelMap; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx new file mode 100644 index 0000000000000..ddbdfc91f4a3e --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx @@ -0,0 +1,253 @@ +/* + * 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 './expression_reference_lines.scss'; +import React from 'react'; +import { EuiFlexGroup, EuiIcon, EuiIconProps, EuiText } from '@elastic/eui'; +import { Position } from '@elastic/charts'; +import classnames from 'classnames'; +import type { IconPosition, YAxisMode, YConfig } from '../../common/expressions'; +import { hasIcon } from './xy_config_panel/shared/icon_select'; +import { annotationsIconSet } from './annotations/config_panel/icon_set'; + +export const LINES_MARKER_SIZE = 20; + +export const computeChartMargins = ( + referenceLinePaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = referenceLinePaddings.bottom; + } + if ( + referenceLinePaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = referenceLinePaddings.left; + } + if ( + referenceLinePaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = referenceLinePaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (referenceLinePaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = referenceLinePaddings.top; + } + return result; +}; + +// Note: it does not take into consideration whether the reference line is in view or not + +export const getLinesCausedPaddings = ( + visualConfigs: Array< + Pick | undefined + >, + axesMap: Record<'left' | 'right', unknown> +) => { + // collect all paddings for the 4 axis: if any text is detected double it. + const paddings: Partial> = {}; + const icons: Partial> = {}; + visualConfigs?.forEach((config) => { + if (!config) { + return; + } + const { axisMode, icon, iconPosition, textVisibility } = config; + if (axisMode && (hasIcon(icon) || textVisibility)) { + const placement = getBaseIconPlacement(iconPosition, axesMap, axisMode); + paddings[placement] = Math.max( + paddings[placement] || 0, + LINES_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text + ); + icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); + } + }); + // post-process the padding based on the icon presence: + // if no icon is present for the placement, just reduce the padding + (Object.keys(paddings) as Position[]).forEach((placement) => { + if (!icons[placement]) { + paddings[placement] = LINES_MARKER_SIZE; + } + }); + return paddings; +}; + +export function mapVerticalToHorizontalPlacement(placement: Position) { + switch (placement) { + case Position.Top: + return Position.Right; + case Position.Bottom: + return Position.Left; + case Position.Left: + return Position.Bottom; + case Position.Right: + return Position.Top; + } +} + +// if there's just one axis, put it on the other one +// otherwise use the same axis +// this function assume the chart is vertical +export function getBaseIconPlacement( + iconPosition: IconPosition | undefined, + axesMap?: Record, + axisMode?: YAxisMode +) { + if (iconPosition === 'auto') { + if (axisMode === 'bottom') { + return Position.Top; + } + if (axesMap) { + if (axisMode === 'left') { + return axesMap.right ? Position.Left : Position.Right; + } + return axesMap.left ? Position.Right : Position.Left; + } + } + + if (iconPosition === 'left') { + return Position.Left; + } + if (iconPosition === 'right') { + return Position.Right; + } + if (iconPosition === 'below') { + return Position.Bottom; + } + return Position.Top; +} + +export function MarkerBody({ + label, + isHorizontal, +}: { + label: string | undefined; + isHorizontal: boolean; +}) { + if (!label) { + return null; + } + if (isHorizontal) { + return ( +
+ {label} +
+ ); + } + return ( +
+
+ {label} +
+
+ ); +} + +const isNumericalString = (value: string) => !isNaN(Number(value)); + +function NumberIcon({ number }: { number: number }) { + return ( + + + {number < 10 ? number : `9+`} + + + ); +} + +interface MarkerConfig { + axisMode?: YAxisMode; + icon?: string; + textVisibility?: boolean; + iconPosition?: IconPosition; +} + +export const AnnotationIcon = ({ + type, + rotateClassName = '', + isHorizontal, + renderedInChart, + ...rest +}: { + type: string; + rotateClassName?: string; + isHorizontal?: boolean; + renderedInChart?: boolean; +} & EuiIconProps) => { + if (isNumericalString(type)) { + return ; + } + const iconConfig = annotationsIconSet.find((i) => i.value === type); + if (!iconConfig) { + return null; + } + return ( + + ); +}; + +export function Marker({ + config, + isHorizontal, + hasReducedPadding, + label, + rotateClassName, +}: { + config: MarkerConfig; + isHorizontal: boolean; + hasReducedPadding: boolean; + label?: string; + rotateClassName?: string; +}) { + if (hasIcon(config.icon)) { + return ( + + ); + } + + // if there's some text, check whether to show it as marker, or just show some padding for the icon + if (config.textVisibility) { + if (hasReducedPadding) { + return ; + } + return ; + } + return null; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index 82c1106e72a08..f8d5805279a2e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -13,7 +13,9 @@ import type { AccessorConfig, FramePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import { FormatFactory, LayerType } from '../../common'; import type { XYLayerConfig } from '../../common/expressions'; -import { isDataLayer, isReferenceLayer } from './visualization_helpers'; +import { isReferenceLayer, isAnnotationsLayer } from './visualization_helpers'; +import { getAnnotationsAccessorColorConfig } from './annotations/helpers'; +import { getReferenceLineAccessorColorConfig } from './reference_line_helpers'; const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object'; @@ -42,15 +44,13 @@ export function getColorAssignments( ): ColorAssignments { const layersPerPalette: Record = {}; - layers - .filter((layer) => isDataLayer(layer)) - .forEach((layer) => { - const palette = layer.palette?.name || 'default'; - if (!layersPerPalette[palette]) { - layersPerPalette[palette] = []; - } - layersPerPalette[palette].push(layer); - }); + layers.forEach((layer) => { + const palette = layer.palette?.name || 'default'; + if (!layersPerPalette[palette]) { + layersPerPalette[palette] = []; + } + layersPerPalette[palette].push(layer); + }); return mapValues(layersPerPalette, (paletteLayers) => { const seriesPerLayer = paletteLayers.map((layer, layerIndex) => { @@ -102,17 +102,6 @@ export function getColorAssignments( }); } -const getReferenceLineAccessorColorConfig = (layer: XYLayerConfig) => { - return layer.accessors.map((accessor) => { - const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); - return { - columnId: accessor, - triggerIcon: 'color' as const, - color: currentYConfig?.color || defaultReferenceLineColor, - }; - }); -}; - export function getAccessorColorConfig( colorAssignments: ColorAssignments, frame: Pick, @@ -122,7 +111,9 @@ export function getAccessorColorConfig( if (isReferenceLayer(layer)) { return getReferenceLineAccessorColorConfig(layer); } - + if (isAnnotationsLayer(layer)) { + return getAnnotationsAccessorColorConfig(layer); + } const layerContainsSplits = Boolean(layer.splitAccessor); const currentPalette: PaletteOutput = layer.palette || { type: 'palette', name: 'default' }; const totalSeriesCount = colorAssignments[currentPalette.name]?.totalSeriesCount; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 654a0f1b94a14..03a180cc20a08 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -20,12 +20,13 @@ import { HorizontalAlignment, VerticalAlignment, LayoutDirection, + LineAnnotation, } from '@elastic/charts'; import { PaletteOutput } from 'src/plugins/charts/public'; import { calculateMinInterval, XYChart, XYChartRenderProps } from './expression'; import type { LensMultiTable } from '../../common'; import { layerTypes } from '../../common'; -import { xyChart } from '../../common/expressions'; +import { AnnotationLayerArgs, xyChart } from '../../common/expressions'; import { dataLayerConfig, legendConfig, @@ -41,12 +42,14 @@ import { } from '../../common/expressions'; import { Datatable, DatatableRow } from '../../../../../src/plugins/expressions/public'; import React from 'react'; -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { XyEndzones } from './x_domain'; +import { eventAnnotationServiceMock } from '../../../../../src/plugins/event_annotation/public/mocks'; +import { EventAnnotationOutput } from 'src/plugins/event_annotation/common'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -536,6 +539,7 @@ describe('xy_expression', () => { onSelectRange, syncColors: false, useLegacyTimeAxis: false, + eventAnnotationService: eventAnnotationServiceMock, }; }); @@ -546,7 +550,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -613,7 +617,9 @@ describe('xy_expression', () => { }} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'time' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'time' }, + ], }} minInterval={undefined} /> @@ -802,7 +808,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'time', isHistogram: true, @@ -878,7 +884,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', xScaleType: 'time', isHistogram: true, @@ -975,7 +981,7 @@ describe('xy_expression', () => { }, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'area', }, ], @@ -1006,7 +1012,7 @@ describe('xy_expression', () => { }, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', }, ], @@ -1083,7 +1089,9 @@ describe('xy_expression', () => { }} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'linear' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'linear' }, + ], }} /> ); @@ -1102,7 +1110,12 @@ describe('xy_expression', () => { args={{ ...args, layers: [ - { ...args.layers[0], seriesType: 'line', xScaleType: 'linear', isHistogram: true }, + { + ...(args.layers[0] as DataLayerArgs), + seriesType: 'line', + xScaleType: 'linear', + isHistogram: true, + }, ], }} /> @@ -1150,7 +1163,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1165,7 +1178,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1180,7 +1193,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1678,7 +1694,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1693,7 +1712,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1710,7 +1732,9 @@ describe('xy_expression', () => { data={data} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'bar_horizontal_stacked' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar_horizontal_stacked' }, + ], }} /> ); @@ -1732,7 +1756,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), xAccessor: undefined, splitAccessor: 'e', seriesType: 'bar_stacked', @@ -1762,7 +1786,7 @@ describe('xy_expression', () => { accessors: ['b'], seriesType: 'bar', isHistogram: true, - }; + } as DataLayerArgs; delete firstLayer.splitAccessor; const component = shallow( @@ -1772,7 +1796,11 @@ describe('xy_expression', () => { test('it does not apply histogram mode to more than one bar series for unstacked bar chart', () => { const { data, args } = sampleArgs(); - const firstLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true }; + const firstLayer: DataLayerArgs = { + ...args.layers[0], + seriesType: 'bar', + isHistogram: true, + } as DataLayerArgs; delete firstLayer.splitAccessor; const component = shallow( @@ -1787,13 +1815,13 @@ describe('xy_expression', () => { ...args.layers[0], seriesType: 'line', isHistogram: true, - }; + } as DataLayerArgs; delete firstLayer.splitAccessor; const secondLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'line', isHistogram: true, - }; + } as DataLayerArgs; delete secondLayer.splitAccessor; const component = shallow( { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar_stacked', isHistogram: true, }, @@ -1836,7 +1864,9 @@ describe('xy_expression', () => { data={data} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'bar', isHistogram: true }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', isHistogram: true }, + ], }} /> ); @@ -2232,7 +2262,10 @@ describe('xy_expression', () => { ); expect(component.find(LineSeries).at(0).prop('xScaleType')).toEqual(ScaleType.Ordinal); @@ -2246,7 +2279,7 @@ describe('xy_expression', () => { ); expect(component.find(LineSeries).at(0).prop('yScaleType')).toEqual(ScaleType.Sqrt); @@ -2268,7 +2301,7 @@ describe('xy_expression', () => { ); expect(getFormatSpy).toHaveBeenCalledWith({ @@ -2678,7 +2711,9 @@ describe('xy_expression', () => { data={{ ...data }} args={{ ...args, - layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), accessors: ['a'], splitAccessor: undefined }, + ], legend: { ...args.legend, isVisible: true, showSingleSeries: true }, }} /> @@ -2696,7 +2731,13 @@ describe('xy_expression', () => { data={{ ...data }} args={{ ...args, - layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }], + layers: [ + { + ...(args.layers[0] as DataLayerArgs), + accessors: ['a'], + splitAccessor: undefined, + }, + ], legend: { ...args.legend, isVisible: true, isInside: true }, }} /> @@ -2782,7 +2823,7 @@ describe('xy_expression', () => { test('it should apply None fitting function if not specified', () => { const { data, args } = sampleArgs(); - args.layers[0].accessors = ['a']; + (args.layers[0] as DataLayerArgs).accessors = ['a']; const component = shallow( @@ -2920,6 +2961,139 @@ describe('xy_expression', () => { }, ]); }); + + describe('annotations', () => { + const sampleStyledAnnotation: EventAnnotationOutput = { + time: '2022-03-18T08:25:00.000Z', + label: 'Event 1', + icon: 'triangle', + type: 'manual_event_annotation', + color: 'red', + lineStyle: 'dashed', + lineWidth: 3, + }; + const sampleAnnotationLayers: AnnotationLayerArgs[] = [ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + { + time: '2022-03-18T08:25:17.140Z', + label: 'Annotation', + type: 'manual_event_annotation', + }, + ], + }, + ]; + function sampleArgsWithAnnotation(annotationLayers = sampleAnnotationLayers) { + const { args } = sampleArgs(); + return { + data: dateHistogramData, + args: { + ...args, + layers: [dateHistogramLayer, ...annotationLayers], + } as XYArgs, + }; + } + test('should render basic annotation', () => { + const { data, args } = sampleArgsWithAnnotation(); + const component = mount(); + expect(component.find('LineAnnotation')).toMatchSnapshot(); + }); + test('should render simplified annotation when hide is true', () => { + const { data, args } = sampleArgsWithAnnotation(); + args.layers[0].hide = true; + const component = mount(); + expect(component.find('LineAnnotation')).toMatchSnapshot(); + }); + + test('should render grouped annotations preserving the shared styles', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + sampleStyledAnnotation, + { ...sampleStyledAnnotation, time: '2022-03-18T08:25:00.020Z', label: 'Event 2' }, + { + ...sampleStyledAnnotation, + time: '2022-03-18T08:25:00.001Z', + label: 'Event 3', + }, + ], + }, + ]); + const component = mount(); + const groupedAnnotation = component.find(LineAnnotation); + + expect(groupedAnnotation.length).toEqual(1); + // styles are passed because they are shared, dataValues & header is rounded to the interval + expect(groupedAnnotation).toMatchSnapshot(); + // renders numeric icon for grouped annotations + const marker = mount(
{groupedAnnotation.prop('marker')}
); + const numberIcon = marker.find('NumberIcon'); + expect(numberIcon.length).toEqual(1); + expect(numberIcon.text()).toEqual('3'); + + // checking tooltip + const renderLinks = mount(
{groupedAnnotation.prop('customTooltipDetails')!()}
); + expect(renderLinks.text()).toEqual( + ' Event 1 2022-03-18T08:25:00.000Z Event 2 2022-03-18T08:25:00.020Z Event 3 2022-03-18T08:25:00.001Z' + ); + }); + test('should render grouped annotations with default styles', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [sampleStyledAnnotation], + }, + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + { + ...sampleStyledAnnotation, + icon: 'square', + color: 'blue', + lineStyle: 'dotted', + lineWidth: 10, + time: '2022-03-18T08:25:00.001Z', + label: 'Event 2', + }, + ], + }, + ]); + const component = mount(); + const groupedAnnotation = component.find(LineAnnotation); + + expect(groupedAnnotation.length).toEqual(1); + // styles are default because they are different for both annotations + expect(groupedAnnotation).toMatchSnapshot(); + }); + test('should not render hidden annotations', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + sampleStyledAnnotation, + { ...sampleStyledAnnotation, time: '2022-03-18T08:30:00.020Z', label: 'Event 2' }, + { + ...sampleStyledAnnotation, + time: '2022-03-18T08:35:00.001Z', + label: 'Event 3', + isHidden: true, + }, + ], + }, + ]); + const component = mount(); + const annotations = component.find(LineAnnotation); + + expect(annotations.length).toEqual(2); + }); + }); }); describe('calculateMinInterval', () => { @@ -2927,7 +3101,7 @@ describe('xy_expression', () => { beforeEach(() => { xyProps = sampleArgs(); - xyProps.args.layers[0].xScaleType = 'time'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'time'; }); it('should use first valid layer and determine interval', async () => { xyProps.data.tables.first.columns[2].meta.source = 'esaggs'; @@ -2942,7 +3116,7 @@ describe('xy_expression', () => { }); it('should return interval of number histogram if available on first x axis columns', async () => { - xyProps.args.layers[0].xScaleType = 'linear'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'linear'; xyProps.data.tables.first.columns[2].meta = { source: 'esaggs', type: 'number', @@ -2984,7 +3158,7 @@ describe('xy_expression', () => { }); it('should return undefined if x axis is not a date', async () => { - xyProps.args.layers[0].xScaleType = 'ordinal'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'ordinal'; xyProps.data.tables.first.columns.splice(2, 1); const result = await calculateMinInterval(xyProps); expect(result).toEqual(undefined); diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 72a3f5f4f6976..8b62b8d0c120c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -50,11 +50,17 @@ import { i18n } from '@kbn/i18n'; import { RenderMode } from 'src/plugins/expressions'; import { ThemeServiceStart } from 'kibana/public'; import { FieldFormat } from 'src/plugins/field_formats/common'; +import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public'; import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import type { ILensInterpreterRenderHandlers, LensFilterEvent, LensBrushEvent } from '../types'; import type { LensMultiTable, FormatFactory } from '../../common'; -import type { DataLayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; +import type { + DataLayerArgs, + SeriesType, + XYChartProps, + XYLayerArgs, +} from '../../common/expressions'; import { visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart, getSeriesColor } from './state_helpers'; @@ -72,13 +78,17 @@ import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axe import { getColorAssignments } from './color_assignment'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './get_legend_action'; -import { - computeChartMargins, - getReferenceLineRequiredPaddings, - ReferenceLineAnnotations, -} from './expression_reference_lines'; +import { ReferenceLineAnnotations } from './expression_reference_lines'; + +import { computeChartMargins, getLinesCausedPaddings } from './annotations_helpers'; + +import { Annotations, getAnnotationsGroupedByInterval } from './annotations/expression'; import { computeOverallDataDomain } from './reference_line_helpers'; -import { getReferenceLayers, isDataLayer } from './visualization_helpers'; +import { + getReferenceLayers, + getDataLayersArgs, + getAnnotationsLayersArgs, +} from './visualization_helpers'; declare global { interface Window { @@ -104,6 +114,7 @@ export type XYChartRenderProps = XYChartProps & { onSelectRange: (data: LensBrushEvent['data']) => void; renderMode: RenderMode; syncColors: boolean; + eventAnnotationService: EventAnnotationServiceType; }; export function calculateMinInterval({ args: { layers }, data }: XYChartProps) { @@ -140,6 +151,7 @@ export const getXyChartRenderer = (dependencies: { timeZone: string; useLegacyTimeAxis: boolean; kibanaTheme: ThemeServiceStart; + eventAnnotationService: EventAnnotationServiceType; }): ExpressionRenderDefinition => ({ name: 'lens_xy_chart_renderer', displayName: 'XY chart', @@ -170,6 +182,7 @@ export const getXyChartRenderer = (dependencies: { chartsActiveCursorService={dependencies.chartsActiveCursorService} chartsThemeService={dependencies.chartsThemeService} paletteService={dependencies.paletteService} + eventAnnotationService={dependencies.eventAnnotationService} timeZone={dependencies.timeZone} useLegacyTimeAxis={dependencies.useLegacyTimeAxis} minInterval={calculateMinInterval(config)} @@ -265,7 +278,9 @@ export function XYChart({ }); if (filteredLayers.length === 0) { - const icon: IconType = getIconForSeriesType(layers?.[0]?.seriesType || 'bar'); + const icon: IconType = getIconForSeriesType( + getDataLayersArgs(layers)?.[0]?.seriesType || 'bar' + ); return ; } @@ -353,7 +368,23 @@ export function XYChart({ }; const referenceLineLayers = getReferenceLayers(layers); - const referenceLinePaddings = getReferenceLineRequiredPaddings(referenceLineLayers, yAxesMap); + const annotationsLayers = getAnnotationsLayersArgs(layers); + const firstTable = data.tables[filteredLayers[0].layerId]; + + const xColumnId = firstTable.columns.find((col) => col.id === filteredLayers[0].xAccessor)?.id; + + const groupedAnnotations = getAnnotationsGroupedByInterval( + annotationsLayers, + minInterval, + xColumnId ? firstTable.rows[0]?.[xColumnId] : undefined, + xAxisFormatter + ); + const visualConfigs = [ + ...referenceLineLayers.flatMap(({ yConfig }) => yConfig), + ...groupedAnnotations, + ].filter(Boolean); + + const linesPaddings = getLinesCausedPaddings(visualConfigs, yAxesMap); const getYAxesStyle = (groupId: 'left' | 'right') => { const tickVisible = @@ -369,9 +400,9 @@ export function XYChart({ ? args.labelsOrientation?.yRight || 0 : args.labelsOrientation?.yLeft || 0, padding: - referenceLinePaddings[groupId] != null + linesPaddings[groupId] != null ? { - inner: referenceLinePaddings[groupId], + inner: linesPaddings[groupId], } : undefined, }, @@ -382,9 +413,9 @@ export function XYChart({ : axisTitlesVisibilitySettings?.yLeft, // if labels are not visible add the padding to the title padding: - !tickVisible && referenceLinePaddings[groupId] != null + !tickVisible && linesPaddings[groupId] != null ? { - inner: referenceLinePaddings[groupId], + inner: linesPaddings[groupId], } : undefined, }, @@ -458,7 +489,7 @@ export function XYChart({ const valueLabelsStyling = shouldShowValueLabels && valueLabels !== 'hide' && getValueLabelsStyling(shouldRotate); - const colorAssignments = getColorAssignments(args.layers, data, formatFactory); + const colorAssignments = getColorAssignments(getDataLayersArgs(args.layers), data, formatFactory); const clickHandler: ElementClickListener = ([[geometry, series]]) => { // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue @@ -591,16 +622,13 @@ export function XYChart({ tickLabel: { visible: tickLabelsVisibilitySettings?.x, rotation: labelsOrientation?.x, - padding: - referenceLinePaddings.bottom != null - ? { inner: referenceLinePaddings.bottom } - : undefined, + padding: linesPaddings.bottom != null ? { inner: linesPaddings.bottom } : undefined, }, axisTitle: { visible: axisTitlesVisibilitySettings.x, padding: - !tickLabelsVisibilitySettings?.x && referenceLinePaddings.bottom != null - ? { inner: referenceLinePaddings.bottom } + !tickLabelsVisibilitySettings?.x && linesPaddings.bottom != null + ? { inner: linesPaddings.bottom } : undefined, }, }; @@ -633,7 +661,7 @@ export function XYChart({ chartMargins: { ...chartTheme.chartPaddings, ...computeChartMargins( - referenceLinePaddings, + linesPaddings, tickLabelsVisibilitySettings, axisTitlesVisibilitySettings, yAxesMap, @@ -1005,29 +1033,37 @@ export function XYChart({ right: Boolean(yAxesMap.right), }} isHorizontal={shouldRotate} - paddingMap={referenceLinePaddings} + paddingMap={linesPaddings} + /> + ) : null} + {groupedAnnotations.length ? ( + 0} + minInterval={minInterval} /> ) : null}
); } -function getFilteredLayers(layers: DataLayerArgs[], data: LensMultiTable) { - return layers.filter((layer) => { +function getFilteredLayers(layers: XYLayerArgs[], data: LensMultiTable) { + return getDataLayersArgs(layers).filter((layer) => { const { layerId, xAccessor, accessors, splitAccessor } = layer; - return ( - isDataLayer(layer) && - !( - !accessors.length || - !data.tables[layerId] || - data.tables[layerId].rows.length === 0 || - (xAccessor && - data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || - // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty - (!xAccessor && - splitAccessor && - data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) - ) + return !( + !accessors.length || + !data.tables[layerId] || + data.tables[layerId].rows.length === 0 || + (xAccessor && + data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || + // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty + (!xAccessor && + splitAccessor && + data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) ); }); } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx index 2d22f6a6ed76e..7817db573e419 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx @@ -8,183 +8,19 @@ import './expression_reference_lines.scss'; import React from 'react'; import { groupBy } from 'lodash'; -import { EuiIcon } from '@elastic/eui'; import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { FieldFormat } from 'src/plugins/field_formats/common'; -import { euiLightVars } from '@kbn/ui-theme'; -import type { IconPosition, ReferenceLineLayerArgs, YAxisMode } from '../../common/expressions'; +import type { ReferenceLineLayerArgs } from '../../common/expressions'; import type { LensMultiTable } from '../../common/types'; -import { hasIcon } from './xy_config_panel/shared/icon_select'; - -export const REFERENCE_LINE_MARKER_SIZE = 20; - -export const computeChartMargins = ( - referenceLinePaddings: Partial>, - labelVisibility: Partial>, - titleVisibility: Partial>, - axesMap: Record<'left' | 'right', unknown>, - isHorizontal: boolean -) => { - const result: Partial> = {}; - if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; - result[placement] = referenceLinePaddings.bottom; - } - if ( - referenceLinePaddings.left && - (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; - result[placement] = referenceLinePaddings.left; - } - if ( - referenceLinePaddings.right && - (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; - result[placement] = referenceLinePaddings.right; - } - // there's no top axis, so just check if a margin has been computed - if (referenceLinePaddings.top) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; - result[placement] = referenceLinePaddings.top; - } - return result; -}; - -// Note: it does not take into consideration whether the reference line is in view or not -export const getReferenceLineRequiredPaddings = ( - referenceLineLayers: ReferenceLineLayerArgs[], - axesMap: Record<'left' | 'right', unknown> -) => { - // collect all paddings for the 4 axis: if any text is detected double it. - const paddings: Partial> = {}; - const icons: Partial> = {}; - referenceLineLayers.forEach((layer) => { - layer.yConfig?.forEach(({ axisMode, icon, iconPosition, textVisibility }) => { - if (axisMode && (hasIcon(icon) || textVisibility)) { - const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap); - paddings[placement] = Math.max( - paddings[placement] || 0, - REFERENCE_LINE_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text - ); - icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); - } - }); - }); - // post-process the padding based on the icon presence: - // if no icon is present for the placement, just reduce the padding - (Object.keys(paddings) as Position[]).forEach((placement) => { - if (!icons[placement]) { - paddings[placement] = REFERENCE_LINE_MARKER_SIZE; - } - }); - - return paddings; -}; - -function mapVerticalToHorizontalPlacement(placement: Position) { - switch (placement) { - case Position.Top: - return Position.Right; - case Position.Bottom: - return Position.Left; - case Position.Left: - return Position.Bottom; - case Position.Right: - return Position.Top; - } -} - -// if there's just one axis, put it on the other one -// otherwise use the same axis -// this function assume the chart is vertical -function getBaseIconPlacement( - iconPosition: IconPosition | undefined, - axisMode: YAxisMode | undefined, - axesMap: Record -) { - if (iconPosition === 'auto') { - if (axisMode === 'bottom') { - return Position.Top; - } - if (axisMode === 'left') { - return axesMap.right ? Position.Left : Position.Right; - } - return axesMap.left ? Position.Right : Position.Left; - } - - if (iconPosition === 'left') { - return Position.Left; - } - if (iconPosition === 'right') { - return Position.Right; - } - if (iconPosition === 'below') { - return Position.Bottom; - } - return Position.Top; -} - -function getMarkerBody(label: string | undefined, isHorizontal: boolean) { - if (!label) { - return; - } - if (isHorizontal) { - return ( -
- {label} -
- ); - } - return ( -
-
- {label} -
-
- ); -} - -interface MarkerConfig { - axisMode?: YAxisMode; - icon?: string; - textVisibility?: boolean; -} - -function getMarkerToShow( - markerConfig: MarkerConfig, - label: string | undefined, - isHorizontal: boolean, - hasReducedPadding: boolean -) { - // show an icon if present - if (hasIcon(markerConfig.icon)) { - return ; - } - // if there's some text, check whether to show it as marker, or just show some padding for the icon - if (markerConfig.textVisibility) { - if (hasReducedPadding) { - return getMarkerBody( - label, - (!isHorizontal && markerConfig.axisMode === 'bottom') || - (isHorizontal && markerConfig.axisMode !== 'bottom') - ); - } - return ; - } -} +import { defaultReferenceLineColor } from './color_assignment'; +import { + MarkerBody, + Marker, + LINES_MARKER_SIZE, + mapVerticalToHorizontalPlacement, + getBaseIconPlacement, +} from './annotations_helpers'; export interface ReferenceLineAnnotationsProps { layers: ReferenceLineLayerArgs[]; @@ -241,32 +77,40 @@ export const ReferenceLineAnnotations = ({ const formatter = formatters[groupId || 'bottom']; - const defaultColor = euiLightVars.euiColorDarkShade; - // get the position for vertical chart const markerPositionVertical = getBaseIconPlacement( yConfig.iconPosition, - yConfig.axisMode, - axesMap + axesMap, + yConfig.axisMode ); // the padding map is built for vertical chart - const hasReducedPadding = - paddingMap[markerPositionVertical] === REFERENCE_LINE_MARKER_SIZE; + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; const props = { groupId, - marker: getMarkerToShow( - yConfig, - columnToLabelMap[yConfig.forAccessor], - isHorizontal, - hasReducedPadding + marker: ( + ), - markerBody: getMarkerBody( - yConfig.textVisibility && !hasReducedPadding - ? columnToLabelMap[yConfig.forAccessor] - : undefined, - (!isHorizontal && yConfig.axisMode === 'bottom') || - (isHorizontal && yConfig.axisMode !== 'bottom') + markerBody: ( + ), // rotate the position if required markerPosition: isHorizontal @@ -284,7 +128,7 @@ export const ReferenceLineAnnotations = ({ const sharedStyle = { strokeWidth: yConfig.lineWidth || 1, - stroke: yConfig.color || defaultColor, + stroke: yConfig.color || defaultReferenceLineColor, dash: dashStyle, }; @@ -355,7 +199,7 @@ export const ReferenceLineAnnotations = ({ })} style={{ ...sharedStyle, - fill: yConfig.color || defaultColor, + fill: yConfig.color || defaultReferenceLineColor, opacity: 0.1, }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index 9697ba149e16e..cfeb1387f689c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -6,6 +6,7 @@ */ import type { CoreSetup } from 'kibana/public'; +import { EventAnnotationPluginSetup } from '../../../../../src/plugins/event_annotation/public'; import type { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import type { EditorFrameSetup } from '../types'; import type { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; @@ -19,6 +20,7 @@ export interface XyVisualizationPluginSetupPlugins { formatFactory: FormatFactory; editorFrame: EditorFrameSetup; charts: ChartsPluginSetup; + eventAnnotation: EventAnnotationPluginSetup; } export class XyVisualization { @@ -28,8 +30,9 @@ export class XyVisualization { ) { editorFrame.registerVisualization(async () => { const { getXyChartRenderer, getXyVisualization } = await import('../async_services'); - const [, { charts, fieldFormats }] = await core.getStartServices(); + const [, { charts, fieldFormats, eventAnnotation }] = await core.getStartServices(); const palettes = await charts.palettes.getPalettes(); + const eventAnnotationService = await eventAnnotation.getService(); const useLegacyTimeAxis = core.uiSettings.get(LEGACY_TIME_AXIS); expressions.registerRenderer( getXyChartRenderer({ @@ -37,6 +40,7 @@ export class XyVisualization { chartsThemeService: charts.theme, chartsActiveCursorService: charts.activeCursor, paletteService: palettes, + eventAnnotationService, timeZone: getTimeZone(core.uiSettings), useLegacyTimeAxis, kibanaTheme: core.theme, @@ -44,6 +48,7 @@ export class XyVisualization { ); return getXyVisualization({ paletteService: palettes, + eventAnnotationService, fieldFormats, useLegacyTimeAxis, kibanaTheme: core.theme, diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx index ac50a81da5423..8b6a96ce24d44 100644 --- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx @@ -14,7 +14,7 @@ import type { YConfig, } from '../../common/expressions'; import { Datatable } from '../../../../../src/plugins/expressions/public'; -import type { AccessorConfig, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; +import type { DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; import { groupAxesByType } from './axes_configuration'; import { isHorizontalChart, isPercentageSeries, isStackedChart } from './state_helpers'; import type { XYState } from './types'; @@ -27,6 +27,7 @@ import { } from './visualization_helpers'; import { generateId } from '../id_generator'; import { LensIconChartBarReferenceLine } from '../assets/chart_bar_reference_line'; +import { defaultReferenceLineColor } from './color_assignment'; export interface ReferenceLineBase { label: 'x' | 'yRight' | 'yLeft'; @@ -360,18 +361,29 @@ export const setReferenceDimension: Visualization['setDimension'] = ({ }; }; +const getSingleColorConfig = (id: string, color = defaultReferenceLineColor) => ({ + columnId: id, + triggerIcon: 'color' as const, + color, +}); + +export const getReferenceLineAccessorColorConfig = (layer: XYReferenceLineLayerConfig) => { + return layer.accessors.map((accessor) => { + const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); + return getSingleColorConfig(accessor, currentYConfig?.color); + }); +}; + export const getReferenceConfiguration = ({ state, frame, layer, sortedAccessors, - mappedAccessors, }: { state: XYState; frame: FramePublicAPI; layer: XYReferenceLineLayerConfig; sortedAccessors: string[]; - mappedAccessors: AccessorConfig[]; }) => { const idToIndex = sortedAccessors.reduce>((memo, id, index) => { memo[id] = index; @@ -420,11 +432,7 @@ export const getReferenceConfiguration = ({ groups: groupsToShow.map(({ config = [], id, label, dataTestSubj, valid }) => ({ groupId: id, groupLabel: getAxisName(label, { isHorizontal }), - accessors: config.map(({ forAccessor, color }) => ({ - columnId: forAccessor, - color: color || mappedAccessors.find(({ columnId }) => columnId === forAccessor)?.color, - triggerIcon: 'color' as const, - })), + accessors: config.map(({ forAccessor, color }) => getSingleColorConfig(forAccessor, color)), filterOperations: isNumericMetric, supportsMoreColumns: true, required: false, diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts index dee7899740173..e0984e62cb9cc 100644 --- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts +++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts @@ -16,7 +16,7 @@ import type { XYReferenceLineLayerConfig, } from '../../common/expressions'; import { visualizationTypes } from './types'; -import { getDataLayers, isDataLayer } from './visualization_helpers'; +import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization_helpers'; export function isHorizontalSeries(seriesType: SeriesType) { return ( @@ -53,6 +53,9 @@ export function getIconForSeries(type: SeriesType): EuiIconType { } export const getSeriesColor = (layer: XYLayerConfig, accessor: string) => { + if (isAnnotationsLayer(layer)) { + return layer?.annotations?.find((ann) => ann.id === accessor)?.color || null; + } if (isDataLayer(layer) && layer.splitAccessor) { return null; } diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index fa992d8829b20..2e3db8f2f6f93 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -15,6 +15,7 @@ import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { defaultReferenceLineColor } from './color_assignment'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; +import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks'; describe('#toExpression', () => { const xyVisualization = getXyVisualization({ @@ -22,6 +23,7 @@ describe('#toExpression', () => { fieldFormats: fieldFormatsServiceMock.createStartContract(), kibanaTheme: themeServiceMock.createStartContract(), useLegacyTimeAxis: false, + eventAnnotationService: eventAnnotationServiceMock, }); let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index a9c166a9c13eb..ade90ff98e553 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -8,29 +8,40 @@ import { Ast } from '@kbn/interpreter'; import { ScaleType } from '@elastic/charts'; import { PaletteRegistry } from 'src/plugins/charts/public'; +import { EventAnnotationServiceType } from 'src/plugins/event_annotation/public'; import { State } from './types'; import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import type { ValidLayer, - XYDataLayerConfig, + XYAnnotationLayerConfig, XYReferenceLineLayerConfig, YConfig, + XYDataLayerConfig, } from '../../common/expressions'; import { layerTypes } from '../../common'; import { hasIcon } from './xy_config_panel/shared/icon_select'; import { defaultReferenceLineColor } from './color_assignment'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; -import { isDataLayer } from './visualization_helpers'; +import { + getLayerTypeOptions, + getDataLayers, + getReferenceLayers, + getAnnotationsLayers, +} from './visualization_helpers'; +import { defaultAnnotationLabel } from './annotations/config_panel'; +import { getUniqueLabels } from './annotations/helpers'; export const getSortedAccessors = ( datasource: DatasourcePublicAPI, layer: XYDataLayerConfig | XYReferenceLineLayerConfig ) => { const originalOrder = datasource - .getTableSpec() - .map(({ columnId }: { columnId: string }) => columnId) - .filter((columnId: string) => layer.accessors.includes(columnId)); + ? datasource + .getTableSpec() + .map(({ columnId }: { columnId: string }) => columnId) + .filter((columnId: string) => layer.accessors.includes(columnId)) + : layer.accessors; // When we add a column it could be empty, and therefore have no order return Array.from(new Set(originalOrder.concat(layer.accessors))); }; @@ -39,7 +50,8 @@ export const toExpression = ( state: State, datasourceLayers: Record, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + eventAnnotationService: EventAnnotationServiceType ): Ast | null => { if (!state || !state.layers.length) { return null; @@ -49,38 +61,58 @@ export const toExpression = ( state.layers.forEach((layer) => { metadata[layer.layerId] = {}; const datasource = datasourceLayers[layer.layerId]; - datasource.getTableSpec().forEach((column) => { - const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId); - metadata[layer.layerId][column.columnId] = operation; - }); + if (datasource) { + datasource.getTableSpec().forEach((column) => { + const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId); + metadata[layer.layerId][column.columnId] = operation; + }); + } }); - return buildExpression(state, metadata, datasourceLayers, paletteService, attributes); + return buildExpression( + state, + metadata, + datasourceLayers, + paletteService, + attributes, + eventAnnotationService + ); +}; + +const simplifiedLayerExpression = { + [layerTypes.DATA]: (layer: XYDataLayerConfig) => ({ ...layer, hide: true }), + [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => ({ + ...layer, + hide: true, + yConfig: layer.yConfig?.map(({ lineWidth, ...rest }) => ({ + ...rest, + lineWidth: 1, + icon: undefined, + textVisibility: false, + })), + }), + [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => ({ + ...layer, + hide: true, + annotations: layer.annotations?.map(({ lineWidth, ...rest }) => ({ + ...rest, + lineWidth: 1, + icon: undefined, + textVisibility: false, + })), + }), }; export function toPreviewExpression( state: State, datasourceLayers: Record, - paletteService: PaletteRegistry + paletteService: PaletteRegistry, + eventAnnotationService: EventAnnotationServiceType ) { return toExpression( { ...state, - layers: state.layers.map((layer) => - isDataLayer(layer) - ? { ...layer, hide: true } - : // cap the reference line to 1px - { - ...layer, - hide: true, - yConfig: layer.yConfig?.map(({ lineWidth, ...config }) => ({ - ...config, - lineWidth: 1, - icon: undefined, - textVisibility: false, - })), - } - ), + layers: state.layers.map((layer) => getLayerTypeOptions(layer, simplifiedLayerExpression)), // hide legend for preview legend: { ...state.legend, @@ -90,7 +122,8 @@ export function toPreviewExpression( }, datasourceLayers, paletteService, - {} + {}, + eventAnnotationService ); } @@ -125,23 +158,35 @@ export const buildExpression = ( metadata: Record>, datasourceLayers: Record, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + eventAnnotationService: EventAnnotationServiceType ): Ast | null => { - const validLayers = state.layers + const validDataLayers = getDataLayers(state.layers) .filter((layer): layer is ValidLayer => Boolean(layer.accessors.length)) - .map((layer) => { - if (!datasourceLayers) { - return layer; - } - const sortedAccessors = getSortedAccessors(datasourceLayers[layer.layerId], layer); + .map((layer) => ({ + ...layer, + accessors: getSortedAccessors(datasourceLayers[layer.layerId], layer), + })); + + // sorting doesn't change anything so we don't sort reference layers (TODO: should we make it work?) + const validReferenceLayers = getReferenceLayers(state.layers).filter((layer) => + Boolean(layer.accessors.length) + ); + const uniqueLabels = getUniqueLabels(state.layers); + const validAnnotationsLayers = getAnnotationsLayers(state.layers) + .filter((layer) => Boolean(layer.annotations.length)) + .map((layer) => { return { ...layer, - accessors: sortedAccessors, + annotations: layer.annotations.map((c) => ({ + ...c, + label: uniqueLabels[c.id], + })), }; }); - if (!validLayers.length) { + if (!validDataLayers.length) { return null; } @@ -309,20 +354,25 @@ export const buildExpression = ( valueLabels: [state?.valueLabels || 'hide'], hideEndzones: [state?.hideEndzones || false], valuesInLegend: [state?.valuesInLegend || false], - layers: validLayers.map((layer) => { - if (isDataLayer(layer)) { - return dataLayerToExpression( + layers: [ + ...validDataLayers.map((layer) => + dataLayerToExpression( layer, datasourceLayers[layer.layerId], metadata, paletteService - ); - } - return referenceLineLayerToExpression( - layer, - datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId] - ); - }), + ) + ), + ...validReferenceLayers.map((layer) => + referenceLineLayerToExpression( + layer, + datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId] + ) + ), + ...validAnnotationsLayers.map((layer) => + annotationLayerToExpression(layer, eventAnnotationService) + ), + ], }, }, ], @@ -355,6 +405,41 @@ const referenceLineLayerToExpression = ( }; }; +const annotationLayerToExpression = ( + layer: XYAnnotationLayerConfig, + eventAnnotationService: EventAnnotationServiceType +): Ast => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_annotation_layer', + arguments: { + hide: [Boolean(layer.hide)], + layerId: [layer.layerId], + layerType: [layerTypes.ANNOTATIONS], + annotations: layer.annotations + ? layer.annotations.map( + (ann): Ast => + eventAnnotationService.toExpression({ + time: ann.key.timestamp, + label: ann.label || defaultAnnotationLabel, + textVisibility: ann.textVisibility, + icon: ann.icon, + lineStyle: ann.lineStyle, + lineWidth: ann.lineWidth, + color: ann.color, + isHidden: Boolean(ann.isHidden), + }) + ) + : [], + }, + }, + ], + }; +}; + const dataLayerToExpression = ( layer: ValidLayer, datasourceLayer: DatasourcePublicAPI, diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 07e411b1993c9..b93cf317e1b2f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -8,7 +8,7 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; import { Operation, VisualizeEditorContext, Suggestion, OperationDescriptor } from '../types'; -import type { State, XYSuggestion } from './types'; +import type { State, XYState, XYSuggestion } from './types'; import type { SeriesType, XYDataLayerConfig, @@ -23,6 +23,18 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { Datatable } from 'src/plugins/expressions'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; +import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks'; +import { EventAnnotationConfig } from 'src/plugins/event_annotation/common'; + +const exampleAnnotation: EventAnnotationConfig = { + id: 'an1', + label: 'Event 1', + key: { + type: 'point_in_time', + timestamp: '2022-03-18T08:25:17.140Z', + }, + icon: 'circle', +}; function exampleState(): State { return { @@ -49,6 +61,7 @@ const xyVisualization = getXyVisualization({ fieldFormats: fieldFormatsMock, useLegacyTimeAxis: false, kibanaTheme: themeServiceMock.createStartContract(), + eventAnnotationService: eventAnnotationServiceMock, }); describe('xy_visualization', () => { @@ -149,7 +162,7 @@ describe('xy_visualization', () => { expect(initialState.layers).toHaveLength(1); expect((initialState.layers[0] as XYDataLayerConfig).xAccessor).not.toBeDefined(); - expect(initialState.layers[0].accessors).toHaveLength(0); + expect((initialState.layers[0] as XYDataLayerConfig).accessors).toHaveLength(0); expect(initialState).toMatchInlineSnapshot(` Object { @@ -227,12 +240,63 @@ describe('xy_visualization', () => { describe('#getSupportedLayers', () => { it('should return a double layer types', () => { - expect(xyVisualization.getSupportedLayers()).toHaveLength(2); + expect(xyVisualization.getSupportedLayers()).toHaveLength(3); }); it('should return the icon for the visualization type', () => { expect(xyVisualization.getSupportedLayers()[0].icon).not.toBeUndefined(); }); + describe('annotations', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'a') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, + }; + } + return null; + }); + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + it('when there is no date histogram annotation layer is disabled', () => { + const supportedAnnotationLayer = xyVisualization + .getSupportedLayers(exampleState()) + .find((a) => a.type === 'annotations'); + expect(supportedAnnotationLayer?.disabled).toBeTruthy(); + }); + it('for data with date histogram annotation layer is enabled and calculates initial dimensions', () => { + const supportedAnnotationLayer = xyVisualization + .getSupportedLayers(exampleState(), frame) + .find((a) => a.type === 'annotations'); + expect(supportedAnnotationLayer?.disabled).toBeFalsy(); + expect(supportedAnnotationLayer?.noDatasource).toBeTruthy(); + expect(supportedAnnotationLayer?.initialDimensions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupId: 'xAnnotations', columnId: expect.any(String) }), + ]) + ); + }); + }); }); describe('#getLayerType', () => { @@ -358,6 +422,45 @@ describe('xy_visualization', () => { ], }); }); + + describe('annotations', () => { + it('should add a dimension to a annotation layer', () => { + jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-18T11:01:58.135Z').valueOf()); + expect( + xyVisualization.setDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ], + }, + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'newCol', + }).layers[0] + ).toEqual({ + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [ + exampleAnnotation, + { + icon: 'triangle', + id: 'newCol', + key: { + timestamp: '2022-04-18T11:01:58.135Z', + type: 'point_in_time', + }, + label: 'Event', + }, + ], + }); + }); + }); }); describe('#updateLayersConfigurationFromContext', () => { @@ -472,9 +575,10 @@ describe('xy_visualization', () => { layerId: 'first', context: newContext, }); - expect(state?.layers[0]).toHaveProperty('seriesType', 'area'); - expect(state?.layers[0]).toHaveProperty('layerType', 'referenceLine'); - expect(state?.layers[0].yConfig).toStrictEqual([ + const firstLayer = state?.layers[0] as XYDataLayerConfig; + expect(firstLayer).toHaveProperty('seriesType', 'area'); + expect(firstLayer).toHaveProperty('layerType', 'referenceLine'); + expect(firstLayer.yConfig).toStrictEqual([ { axisMode: 'right', color: '#68BC00', @@ -695,6 +799,45 @@ describe('xy_visualization', () => { accessors: [], }); }); + it('removes annotation dimension', () => { + expect( + xyVisualization.removeDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'ann', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation, { ...exampleAnnotation, id: 'an2' }], + }, + ], + }, + layerId: 'ann', + columnId: 'an2', + }).layers + ).toEqual([ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'ann', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ]); + }); }); describe('#getConfiguration', () => { @@ -1069,7 +1212,7 @@ describe('xy_visualization', () => { it('should support static value', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].accessors = []; + (state.layers[1] as XYReferenceLineLayerConfig).accessors = []; (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; expect( xyVisualization.getConfiguration({ @@ -1082,7 +1225,7 @@ describe('xy_visualization', () => { it('should return no referenceLine groups for a empty data layer', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].accessors = []; + (state.layers[0] as XYDataLayerConfig).accessors = []; (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; const options = xyVisualization.getConfiguration({ @@ -1358,6 +1501,83 @@ describe('xy_visualization', () => { }); }); + describe('annotations', () => { + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'a') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, + }; + } + return null; + }); + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + + function getStateWithAnnotationLayer(): State { + return { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + splitAccessor: undefined, + xAccessor: 'a', + accessors: ['b'], + }, + { + layerId: 'annotations', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ], + }; + } + + it('returns configuration correctly', () => { + const state = getStateWithAnnotationLayer(); + const config = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'annotations', + }); + expect(config.groups[0].accessors).toEqual([ + { color: '#f04e98', columnId: 'an1', triggerIcon: 'color' }, + ]); + expect(config.groups[0].invalid).toEqual(false); + }); + + it('When data layer is empty, should return invalid state', () => { + const state = getStateWithAnnotationLayer(); + (state.layers[0] as XYDataLayerConfig).xAccessor = undefined; + const config = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'annotations', + }); + expect(config.groups[0].invalid).toEqual(true); + }); + }); + describe('color assignment', () => { function callConfig(layerConfigOverride: Partial) { const baseState = exampleState(); @@ -1954,4 +2174,87 @@ describe('xy_visualization', () => { `); }); }); + describe('#getUniqueLabels', () => { + it('creates unique labels for single annotations layer with repeating labels', async () => { + const xyState = { + layers: [ + { + layerId: 'layerId', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '1', + }, + { + label: 'Event', + id: '2', + }, + { + label: 'Custom', + id: '3', + }, + ], + }, + ], + } as XYState; + + expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({ + '1': 'Event', + '2': 'Event [1]', + '3': 'Custom', + }); + }); + it('creates unique labels for multiple annotations layers with repeating labels', async () => { + const xyState = { + layers: [ + { + layerId: 'layer1', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '1', + }, + { + label: 'Event', + id: '2', + }, + { + label: 'Custom', + id: '3', + }, + ], + }, + { + layerId: 'layer2', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '4', + }, + { + label: 'Event [1]', + id: '5', + }, + { + label: 'Custom', + id: '6', + }, + ], + }, + ], + } as XYState; + + expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({ + '1': 'Event', + '2': 'Event [1]', + '3': 'Custom', + '4': 'Event [2]', + '5': 'Event [1] [1]', + '6': 'Custom [1]', + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index c9951c24f8a47..78fd50f7cfece 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -13,16 +13,17 @@ import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { ThemeServiceStart } from 'kibana/public'; +import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; -import type { FillStyle } from '../../common/expressions/xy_chart'; +import type { FillStyle, XYLayerConfig } from '../../common/expressions/xy_chart'; 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 type { Visualization, AccessorConfig, FramePublicAPI } from '../types'; import { State, visualizationTypes, XYSuggestion } from './types'; -import { SeriesType, XYDataLayerConfig, XYLayerConfig, YAxisMode } from '../../common/expressions'; +import { SeriesType, XYDataLayerConfig, YAxisMode } from '../../common/expressions'; import { layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; @@ -34,6 +35,12 @@ import { getReferenceSupportedLayer, setReferenceDimension, } from './reference_line_helpers'; +import { + getAnnotationsConfiguration, + getAnnotationsSupportedLayer, + setAnnotationsDimension, + getUniqueLabels, +} from './annotations/helpers'; import { checkXAccessorCompatibility, defaultSeriesType, @@ -42,7 +49,9 @@ import { getDescription, getFirstDataLayer, getLayersByType, + getReferenceLayers, getVisualizationType, + isAnnotationsLayer, isBucketed, isDataLayer, isNumericDynamicMetric, @@ -54,14 +63,18 @@ import { import { groupAxesByType } from './axes_configuration'; import { XYState } from '..'; import { ReferenceLinePanel } from './xy_config_panel/reference_line_panel'; +import { DimensionTrigger } from '../shared_components/dimension_trigger'; +import { AnnotationsPanel, defaultAnnotationLabel } from './annotations/config_panel'; export const getXyVisualization = ({ paletteService, fieldFormats, useLegacyTimeAxis, kibanaTheme, + eventAnnotationService, }: { paletteService: PaletteRegistry; + eventAnnotationService: EventAnnotationServiceType; fieldFormats: FieldFormatsStart; useLegacyTimeAxis: boolean; kibanaTheme: ThemeServiceStart; @@ -155,7 +168,11 @@ export const getXyVisualization = ({ }, getSupportedLayers(state, frame) { - return [supportedDataLayer, getReferenceSupportedLayer(state, frame)]; + return [ + supportedDataLayer, + getAnnotationsSupportedLayer(state, frame), + getReferenceSupportedLayer(state, frame), + ]; }, getConfiguration({ state, frame, layerId }) { @@ -164,10 +181,18 @@ export const getXyVisualization = ({ return { groups: [] }; } + if (isAnnotationsLayer(layer)) { + return getAnnotationsConfiguration({ state, frame, layer }); + } + const sortedAccessors: string[] = getSortedAccessors( frame.datasourceLayers[layer.layerId], layer ); + if (isReferenceLayer(layer)) { + return getReferenceConfiguration({ state, frame, layer, sortedAccessors }); + } + const mappedAccessors = getMappedAccessors({ state, frame, @@ -177,11 +202,7 @@ export const getXyVisualization = ({ accessors: sortedAccessors, }); - if (isReferenceLayer(layer)) { - return getReferenceConfiguration({ state, frame, layer, sortedAccessors, mappedAccessors }); - } const dataLayers = getDataLayers(state.layers); - const isHorizontal = isHorizontalChart(state.layers); const { left, right } = groupAxesByType([layer], frame.activeData); // Check locally if it has one accessor OR one accessor per axis @@ -275,6 +296,9 @@ export const getXyVisualization = ({ if (isReferenceLayer(foundLayer)) { return setReferenceDimension(props); } + if (isAnnotationsLayer(foundLayer)) { + return setAnnotationsDimension(props); + } const newLayer = { ...foundLayer }; if (groupId === 'x') { @@ -295,7 +319,7 @@ export const getXyVisualization = ({ updateLayersConfigurationFromContext({ prevState, layerId, context }) { const { chartType, axisPosition, palette, metrics } = context; const foundLayer = prevState?.layers.find((l) => l.layerId === layerId); - if (!foundLayer) { + if (!foundLayer || !isDataLayer(foundLayer)) { return prevState; } const isReferenceLine = metrics.some((metric) => metric.agg === 'static_value'); @@ -377,7 +401,16 @@ export const getXyVisualization = ({ if (!foundLayer) { return prevState; } - const dataLayers = getDataLayers(prevState.layers); + if (isAnnotationsLayer(foundLayer)) { + const newLayer = { ...foundLayer }; + newLayer.annotations = newLayer.annotations.filter(({ id }) => id !== columnId); + + const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + return { + ...prevState, + layers: newLayers, + }; + } const newLayer = { ...foundLayer }; if (isDataLayer(newLayer)) { if (newLayer.xAccessor === columnId) { @@ -392,15 +425,15 @@ export const getXyVisualization = ({ newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId); } - if (newLayer.yConfig) { - newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); + if ('yConfig' in newLayer) { + newLayer.yConfig = newLayer.yConfig?.filter(({ forAccessor }) => forAccessor !== columnId); } let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); // check if there's any reference layer and pull it off if all data layers have no dimensions set // check for data layers if they all still have xAccessors const groupsAvailable = getGroupsAvailableInData( - dataLayers, + getDataLayers(prevState.layers), frame.datasourceLayers, frame?.activeData ); @@ -410,7 +443,9 @@ export const getXyVisualization = ({ (id) => !groupsAvailable[id] ) ) { - newLayers = newLayers.filter((layer) => isDataLayer(layer) || layer.accessors.length); + newLayers = newLayers.filter( + (layer) => isDataLayer(layer) || ('accessors' in layer && layer.accessors.length) + ); } return { @@ -450,9 +485,12 @@ export const getXyVisualization = ({ const layer = props.state.layers.find((l) => l.layerId === props.layerId)!; const dimensionEditor = isReferenceLayer(layer) ? ( + ) : isAnnotationsLayer(layer) ? ( + ) : ( ); + render( {dimensionEditor} @@ -462,8 +500,9 @@ export const getXyVisualization = ({ }, toExpression: (state, layers, attributes) => - toExpression(state, layers, paletteService, attributes), - toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), + toExpression(state, layers, paletteService, attributes, eventAnnotationService), + toPreviewExpression: (state, layers) => + toPreviewExpression(state, layers, paletteService, eventAnnotationService), getErrorMessages(state, datasourceLayers) { // Data error handling below here @@ -504,7 +543,7 @@ export const getXyVisualization = ({ // temporary fix for #87068 errors.push(...checkXAccessorCompatibility(state, datasourceLayers)); - for (const layer of state.layers) { + for (const layer of getDataLayers(state.layers)) { const datasourceAPI = datasourceLayers[layer.layerId]; if (datasourceAPI) { for (const accessor of layer.accessors) { @@ -540,9 +579,10 @@ export const getXyVisualization = ({ return; } - const layers = state.layers; - - const filteredLayers = layers.filter(({ accessors }: XYLayerConfig) => accessors.length > 0); + const filteredLayers = [ + ...getDataLayers(state.layers), + ...getReferenceLayers(state.layers), + ].filter(({ accessors }) => accessors.length > 0); const accessorsWithArrayValues = []; for (const layer of filteredLayers) { const { layerId, accessors } = layer; @@ -569,6 +609,35 @@ export const getXyVisualization = ({ /> )); }, + getUniqueLabels(state) { + return getUniqueLabels(state.layers); + }, + renderDimensionTrigger({ + columnId, + label, + hideTooltip, + invalid, + invalidMessage, + }: { + columnId: string; + label?: string; + hideTooltip?: boolean; + invalid?: boolean; + invalidMessage?: string; + }) { + if (label) { + return ( + + ); + } + return null; + }, }); const getMappedAccessors = ({ @@ -584,7 +653,7 @@ const getMappedAccessors = ({ paletteService: PaletteRegistry; fieldFormats: FieldFormatsStart; state: XYState; - layer: XYLayerConfig; + layer: XYDataLayerConfig; }) => { let mappedAccessors: AccessorConfig[] = accessors.map((accessor) => ({ columnId: accessor, @@ -592,7 +661,7 @@ const getMappedAccessors = ({ if (frame.activeData) { const colorAssignments = getColorAssignments( - state.layers, + getDataLayers(state.layers), { tables: frame.activeData }, fieldFormats.deserialize ); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx index 7446c2a06119c..23c2446ca2363 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx @@ -11,8 +11,12 @@ import { DatasourcePublicAPI, OperationMetadata, VisualizationType } from '../ty import { State, visualizationTypes, XYState } from './types'; import { isHorizontalChart } from './state_helpers'; import { + AnnotationLayerArgs, + DataLayerArgs, SeriesType, + XYAnnotationLayerConfig, XYDataLayerConfig, + XYLayerArgs, XYLayerConfig, XYReferenceLineLayerConfig, } from '../../common/expressions'; @@ -130,9 +134,12 @@ export function checkScaleOperation( export const isDataLayer = (layer: Pick): layer is XYDataLayerConfig => layer.layerType === layerTypes.DATA || !layer.layerType; -export const getDataLayers = (layers: XYLayerConfig[]) => +export const getDataLayers = (layers: Array>) => (layers || []).filter((layer): layer is XYDataLayerConfig => isDataLayer(layer)); +export const getDataLayersArgs = (layers: XYLayerArgs[]) => + (layers || []).filter((layer): layer is DataLayerArgs => isDataLayer(layer)); + export const getFirstDataLayer = (layers: XYLayerConfig[]) => (layers || []).find((layer): layer is XYDataLayerConfig => isDataLayer(layer)); @@ -140,9 +147,34 @@ export const isReferenceLayer = ( layer: Pick ): layer is XYReferenceLineLayerConfig => layer.layerType === layerTypes.REFERENCELINE; -export const getReferenceLayers = (layers: XYLayerConfig[]) => +export const getReferenceLayers = (layers: Array>) => (layers || []).filter((layer): layer is XYReferenceLineLayerConfig => isReferenceLayer(layer)); +export const isAnnotationsLayer = ( + layer: Pick +): layer is XYAnnotationLayerConfig => layer.layerType === layerTypes.ANNOTATIONS; + +export const getAnnotationsLayers = (layers: Array>) => + (layers || []).filter((layer): layer is XYAnnotationLayerConfig => isAnnotationsLayer(layer)); + +export const getAnnotationsLayersArgs = (layers: XYLayerArgs[]) => + (layers || []).filter((layer): layer is AnnotationLayerArgs => isAnnotationsLayer(layer)); + +export interface LayerTypeToLayer { + [layerTypes.DATA]: (layer: XYDataLayerConfig) => XYDataLayerConfig; + [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => XYReferenceLineLayerConfig; + [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => XYAnnotationLayerConfig; +} + +export const getLayerTypeOptions = (layer: XYLayerConfig, options: LayerTypeToLayer) => { + if (isDataLayer(layer)) { + return options[layerTypes.DATA](layer); + } else if (isReferenceLayer(layer)) { + return options[layerTypes.REFERENCELINE](layer); + } + return options[layerTypes.ANNOTATIONS](layer); +}; + export function getVisualizationType(state: State): VisualizationType | 'mixed' { if (!state.layers.length) { return ( @@ -255,6 +287,11 @@ const newLayerFn = { layerType: layerTypes.REFERENCELINE, accessors: [], }), + [layerTypes.ANNOTATIONS]: ({ layerId }: { layerId: string }): XYAnnotationLayerConfig => ({ + layerId, + layerType: layerTypes.ANNOTATIONS, + annotations: [], + }), }; export function newLayerState({ diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx index 8aa2aaf16ae5f..b448ebfbd455e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; import type { VisualizationDimensionEditorProps } from '../../types'; import { State } from '../types'; import { FormatFactory } from '../../../common'; @@ -20,7 +21,7 @@ import { } from '../color_assignment'; import { getSortedAccessors } from '../to_expression'; import { TooltipWrapper } from '../../shared_components'; -import { isReferenceLayer } from '../visualization_helpers'; +import { isReferenceLayer, isAnnotationsLayer, getDataLayers } from '../visualization_helpers'; const tooltipContent = { auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { @@ -62,15 +63,17 @@ export const ColorPicker = ({ if (overwriteColor || !frame.activeData) return overwriteColor; if (isReferenceLayer(layer)) { return defaultReferenceLineColor; + } else if (isAnnotationsLayer(layer)) { + return defaultAnnotationColor; } const sortedAccessors: string[] = getSortedAccessors( - frame.datasourceLayers[layer.layerId], + frame.datasourceLayers[layer.layerId] ?? layer.accessors, layer ); const colorAssignments = getColorAssignments( - state.layers, + getDataLayers(state.layers), { tables: frame.activeData }, formatFactory ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx index 465a627fa33b2..c4e5268cfb8af 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx @@ -16,8 +16,9 @@ import { trackUiEvent } from '../../lens_ui_telemetry'; import { StaticHeader } from '../../shared_components'; import { ToolbarButton } from '../../../../../../src/plugins/kibana_react/public'; import { LensIconChartBarReferenceLine } from '../../assets/chart_bar_reference_line'; +import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations'; import { updateLayer } from '.'; -import { isReferenceLayer } from '../visualization_helpers'; +import { isAnnotationsLayer, isReferenceLayer } from '../visualization_helpers'; export function LayerHeader(props: VisualizationLayerWidgetProps) { const layer = props.state.layers.find((l) => l.layerId === props.layerId); @@ -26,6 +27,8 @@ export function LayerHeader(props: VisualizationLayerWidgetProps) { } if (isReferenceLayer(layer)) { return ; + } else if (isAnnotationsLayer(layer)) { + return ; } return ; } @@ -41,6 +44,17 @@ function ReferenceLayerHeader() { ); } +function AnnotationsLayerHeader() { + return ( + + ); +} + function DataLayerHeader(props: VisualizationLayerWidgetProps) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); const { state, layerId } = props; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx index f00d60b0dc814..78020034c3d43 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx @@ -70,6 +70,7 @@ export const ReferenceLinePanel = ( return ( <> + {' '} ; + +export const euiIconsSet = [ { value: 'empty', label: i18n.translate('xpack.lens.xyChart.iconSelect.noIconLabel', { @@ -70,29 +72,35 @@ const icons = [ }, ]; -const IconView = (props: { value?: string; label: string }) => { +const IconView = (props: { value?: string; label: string; icon?: IconType }) => { if (!props.value) return null; return ( - - - {` ${props.label}`} - + + + + + {props.label} + ); }; export const IconSelect = ({ value, onChange, + customIconSet = euiIconsSet, }: { value?: string; onChange: (newIcon: string) => void; + customIconSet?: IconSet; }) => { - const selectedIcon = icons.find((option) => value === option.value) || icons[0]; + const selectedIcon = + customIconSet.find((option) => value === option.value) || + customIconSet.find((option) => option.value === 'empty')!; return ( { onChange(selection[0].value!); @@ -100,7 +108,11 @@ export const IconSelect = ({ singleSelection={{ asPlainText: true }} renderOption={IconView} compressed - prepend={hasIcon(selectedIcon.value) ? : undefined} + prepend={ + hasIcon(selectedIcon.value) ? ( + + ) : undefined + } /> ); }; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx index db01a027d8fec..766d5462db787 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx @@ -40,8 +40,8 @@ export const LineStyleSettings = ({ defaultMessage: 'Line', })} > - - + + { @@ -49,9 +49,8 @@ export const LineStyleSettings = ({ }} /> - + void; isHorizontal: boolean; + customIconSet?: IconSet; }) => { return ( <> @@ -133,13 +136,15 @@ export const MarkerDecorationSettings = ({ })} > { setConfig({ icon: newIcon }); }} /> - {hasIcon(currentConfig?.icon) || currentConfig?.textVisibility ? ( + {currentConfig?.iconPosition && + (hasIcon(currentConfig?.icon) || currentConfig?.textVisibility) ? ( { @@ -533,6 +535,60 @@ describe('xy_suggestions', () => { ); }); + test('passes annotation layer without modifying it', () => { + const annotationLayer: XYAnnotationLayerConfig = { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + annotations: [ + { + id: '1', + key: { + type: 'point_in_time', + timestamp: '2020-20-22', + }, + label: 'annotation', + }, + ], + }; + const currentState: XYState = { + legend: { isVisible: true, position: 'bottom' }, + valueLabels: 'hide', + preferredSeriesType: 'bar', + fittingFunction: 'None', + layers: [ + { + accessors: ['price'], + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'bar', + splitAccessor: 'date', + xAccessor: 'product', + }, + annotationLayer, + ], + }; + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, + state: currentState, + keptLayerIds: [], + }); + + suggestions.every((suggestion) => + expect(suggestion.state.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerType: layerTypes.ANNOTATIONS, + }), + ]) + ) + ); + }); + test('includes passed in palette for split charts if specified', () => { const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; const [suggestion] = getSuggestions({ diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 1578442b52815..bd5a37c206c6c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -521,7 +521,10 @@ function buildSuggestion({ const keptLayers = currentState ? currentState.layers // Remove layers that aren't being suggested - .filter((layer) => keptLayerIds.includes(layer.layerId)) + .filter( + (layer) => + keptLayerIds.includes(layer.layerId) || layer.layerType === layerTypes.ANNOTATIONS + ) // Update in place .map((layer) => (layer.layerId === layerId ? newLayer : layer)) // Replace the seriesType on all previous layers diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts index 84e238b3eb15e..c68fed23a7fdb 100644 --- a/x-pack/plugins/lens/server/expressions/expressions.ts +++ b/x-pack/plugins/lens/server/expressions/expressions.ts @@ -12,6 +12,7 @@ import { yAxisConfig, dataLayerConfig, referenceLineLayerConfig, + annotationLayerConfig, formatColumn, legendConfig, renameColumns, @@ -40,6 +41,7 @@ export const setupExpressions = ( yAxisConfig, dataLayerConfig, referenceLineLayerConfig, + annotationLayerConfig, formatColumn, legendConfig, renameColumns, diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index 583e2963a1ca7..76e25f8b08639 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -1,4 +1,3 @@ - { "extends": "../../../tsconfig.base.json", "compilerOptions": { @@ -15,31 +14,86 @@ "../../../typings/**/*" ], "references": [ - { "path": "../spaces/tsconfig.json" }, - { "path": "../../../src/core/tsconfig.json" }, - { "path": "../task_manager/tsconfig.json" }, - { "path": "../global_search/tsconfig.json"}, - { "path": "../saved_objects_tagging/tsconfig.json"}, - { "path": "../../../src/plugins/data/tsconfig.json"}, - { "path": "../../../src/plugins/data_views/tsconfig.json"}, - { "path": "../../../src/plugins/data_view_field_editor/tsconfig.json"}, - { "path": "../../../src/plugins/charts/tsconfig.json"}, - { "path": "../../../src/plugins/expressions/tsconfig.json"}, - { "path": "../../../src/plugins/navigation/tsconfig.json" }, - { "path": "../../../src/plugins/url_forwarding/tsconfig.json" }, - { "path": "../../../src/plugins/visualizations/tsconfig.json" }, - { "path": "../../../src/plugins/dashboard/tsconfig.json" }, - { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, - { "path": "../../../src/plugins/embeddable/tsconfig.json" }, - { "path": "../../../src/plugins/share/tsconfig.json" }, - { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, - { "path": "../../../src/plugins/saved_objects/tsconfig.json"}, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, - { "path": "../../../src/plugins/embeddable/tsconfig.json"}, - { "path": "../../../src/plugins/presentation_util/tsconfig.json"}, - { "path": "../../../src/plugins/field_formats/tsconfig.json"}, - { "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json"}, - { "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json"} + { + "path": "../spaces/tsconfig.json" + }, + { + "path": "../../../src/core/tsconfig.json" + }, + { + "path": "../task_manager/tsconfig.json" + }, + { + "path": "../global_search/tsconfig.json" + }, + { + "path": "../saved_objects_tagging/tsconfig.json" + }, + { + "path": "../../../src/plugins/data/tsconfig.json" + }, + { + "path": "../../../src/plugins/data_views/tsconfig.json" + }, + { + "path": "../../../src/plugins/data_view_field_editor/tsconfig.json" + }, + { + "path": "../../../src/plugins/charts/tsconfig.json" + }, + { + "path": "../../../src/plugins/expressions/tsconfig.json" + }, + { + "path": "../../../src/plugins/navigation/tsconfig.json" + }, + { + "path": "../../../src/plugins/url_forwarding/tsconfig.json" + }, + { + "path": "../../../src/plugins/visualizations/tsconfig.json" + }, + { + "path": "../../../src/plugins/dashboard/tsconfig.json" + }, + { + "path": "../../../src/plugins/ui_actions/tsconfig.json" + }, + { + "path": "../../../src/plugins/embeddable/tsconfig.json" + }, + { + "path": "../../../src/plugins/share/tsconfig.json" + }, + { + "path": "../../../src/plugins/usage_collection/tsconfig.json" + }, + { + "path": "../../../src/plugins/saved_objects/tsconfig.json" + }, + { + "path": "../../../src/plugins/kibana_utils/tsconfig.json" + }, + { + "path": "../../../src/plugins/kibana_react/tsconfig.json" + }, + { + "path": "../../../src/plugins/embeddable/tsconfig.json" + }, + { + "path": "../../../src/plugins/presentation_util/tsconfig.json" + }, + { + "path": "../../../src/plugins/field_formats/tsconfig.json" + }, + { + "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json" + }, + { + "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json" + }, + { + "path": "../../../src/plugins/event_annotation/tsconfig.json" + } ] -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index db10095ce0591..5fbde8959b364 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -530,7 +530,6 @@ "xpack.lens.indexPattern.ranges.lessThanTooltip": "Inférieur à", "xpack.lens.indexPattern.records": "Enregistrements", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "Sous-fonction", - "xpack.lens.indexPattern.removeColumnAriaLabel": "Ajouter ou glisser-déposer un champ dans {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "Retirer la configuration de \"{groupLabel}\"", "xpack.lens.indexPattern.removeFieldLabel": "Retirer le champ du modèle d'indexation", "xpack.lens.indexPattern.sortField.invalid": "Champ non valide. Vérifiez votre modèle d'indexation ou choisissez un autre champ.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2395df6d2d901..9d1ec062fe1b3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -611,7 +611,6 @@ "xpack.lens.indexPattern.rareTermsOf": "{name}の希少な値", "xpack.lens.indexPattern.records": "記録", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "サブ関数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "フィールドを追加するか、{groupLabel}までドラッグアンドドロップします", "xpack.lens.indexPattern.removeColumnLabel": "「{groupLabel}」から構成を削除", "xpack.lens.indexPattern.removeFieldLabel": "データビューフィールドを削除", "xpack.lens.indexPattern.sortField.invalid": "無効なフィールドです。データビューを確認するか、別のフィールドを選択してください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6d4465ae16487..b055d663f9e69 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -617,7 +617,6 @@ "xpack.lens.indexPattern.rareTermsOf": "{name} 的稀有值", "xpack.lens.indexPattern.records": "记录", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "子函数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "将字段添加或拖放到 {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "从“{groupLabel}”中删除配置", "xpack.lens.indexPattern.removeFieldLabel": "移除数据视图字段", "xpack.lens.indexPattern.sortField.invalid": "字段无效。检查数据视图或选取其他字段。",