From 97065efc38e4f8321a4b62ce6719b13e63b086a5 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Tue, 7 Dec 2021 15:13:58 +0100 Subject: [PATCH] [Lens] Add gauges visualization (#118616) * add gauge chart expression * excluding staticValue from suggestion for other visualizations * adding prioritized operation for visualization groups (when dropping, it should be chosen over the default) * changing the group for metric in chart switcher * only access the activeData if exists (avoids error in console) * Adding gauge chart * round nicely the maximum value * fix warnings * fixing rounding * fixing tests * suggestions tests * suggestions tbc * tests for staticValue limitations * added tests to visualization.ts * correct bands * wip * added tests * suggestions * palete fixes & tests * fix magic max * metric should not overflow * address review * [Lens] adding toolbar tests * limit domain to * changes the order of experimental badge and dataLoss indicator * fix i18n * address feedback p1 * don't show required nor optional for max/min dimensions * fix types * tests fixed * fix types * last piece of gauge feedback * change naming from title and subtitle to labelMajor and labelMinor * fix bug with percent color bands * functional tests * metric shouldn't have static value * pass formatter to metric * fake formatter * fix css and replace emptyPlaceholder * fix tests * fix static values * name changes * breaking functional Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/expression_renderers/index.scss | 8 - .../static/components/empty_placeholder.scss | 7 + .../static/components/empty_placeholder.tsx | 17 +- .../expressions/gauge_chart/gauge_chart.ts | 134 ++++ .../common/expressions/gauge_chart/index.ts | 9 + .../common/expressions/gauge_chart/types.ts | 72 +++ .../plugins/lens/common/expressions/index.ts | 1 + .../lens/public/assets/chart_gauge.tsx | 61 ++ x-pack/plugins/lens/public/async_services.ts | 2 + .../components/table_basic.test.tsx | 2 +- .../components/table_basic.tsx | 3 +- .../visualization.test.tsx | 30 + .../datatable_visualization/visualization.tsx | 3 +- .../buttons/empty_dimension_button.tsx | 86 ++- .../editor_frame/config_panel/layer_panel.tsx | 3 +- x-pack/plugins/lens/public/expressions.ts | 3 + .../heatmap_visualization/suggestions.test.ts | 47 ++ .../heatmap_visualization/suggestions.ts | 7 +- .../heatmap_visualization/visualization.tsx | 2 +- .../droppable/get_drop_props.ts | 33 +- .../droppable/on_drop_handler.ts | 11 +- .../indexpattern.test.ts | 2 + .../indexpattern_datasource/indexpattern.tsx | 3 +- .../indexpattern_suggestions.test.tsx | 25 + .../definitions/static_value.test.tsx | 8 + .../operations/definitions/static_value.tsx | 2 + .../operations/operations.test.ts | 18 +- .../metric_visualization/expression.tsx | 3 +- .../metric_suggestions.test.ts | 24 + .../metric_suggestions.ts | 3 +- .../metric_visualization/visualization.tsx | 2 +- .../render_function.test.tsx | 2 +- .../pie_visualization/render_function.tsx | 2 +- .../pie_visualization/suggestions.test.ts | 29 + .../public/pie_visualization/suggestions.ts | 3 +- .../pie_visualization/visualization.tsx | 2 +- x-pack/plugins/lens/public/plugin.ts | 5 + .../shared_components/empty_placeholder.tsx | 30 - .../lens/public/shared_components/index.ts | 2 +- .../public/shared_components/vis_label.tsx | 102 +++ .../public/state_management/lens_slice.ts | 8 +- x-pack/plugins/lens/public/types.ts | 5 + .../chart_component.test.tsx.snap | 36 ++ .../gauge/chart_component.test.tsx | 429 +++++++++++++ .../visualizations/gauge/chart_component.tsx | 244 ++++++++ .../public/visualizations/gauge/constants.ts | 16 + .../gauge/dimension_editor.scss | 3 + .../visualizations/gauge/dimension_editor.tsx | 223 +++++++ .../visualizations/gauge/expression.tsx | 60 ++ .../gauge/gauge_visualization.ts | 9 + .../public/visualizations/gauge/index.scss | 14 + .../lens/public/visualizations/gauge/index.ts | 41 ++ .../visualizations/gauge/palette_config.tsx | 23 + .../visualizations/gauge/suggestions.test.ts | 213 +++++++ .../visualizations/gauge/suggestions.ts | 108 ++++ .../toolbar_component/gauge_config_panel.scss | 3 + .../toolbar_component/gauge_toolbar.test.tsx | 179 ++++++ .../gauge/toolbar_component/index.tsx | 99 +++ .../public/visualizations/gauge/utils.test.ts | 41 ++ .../lens/public/visualizations/gauge/utils.ts | 113 ++++ .../gauge/visualization.test.ts | 590 ++++++++++++++++++ .../visualizations/gauge/visualization.tsx | 454 ++++++++++++++ .../xy_visualization/expression.test.tsx | 2 +- .../public/xy_visualization/expression.tsx | 2 +- .../public/xy_visualization/visualization.tsx | 4 +- .../xy_visualization/xy_suggestions.test.ts | 27 + .../public/xy_visualization/xy_suggestions.ts | 5 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - x-pack/test/functional/apps/lens/gauge.ts | 130 ++++ x-pack/test/functional/apps/lens/index.ts | 1 + .../test/functional/page_objects/lens_page.ts | 10 +- 72 files changed, 3795 insertions(+), 107 deletions(-) create mode 100644 src/plugins/charts/public/static/components/empty_placeholder.scss create mode 100644 x-pack/plugins/lens/common/expressions/gauge_chart/gauge_chart.ts create mode 100644 x-pack/plugins/lens/common/expressions/gauge_chart/index.ts create mode 100644 x-pack/plugins/lens/common/expressions/gauge_chart/types.ts create mode 100644 x-pack/plugins/lens/public/assets/chart_gauge.tsx delete mode 100644 x-pack/plugins/lens/public/shared_components/empty_placeholder.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/vis_label.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/__snapshots__/chart_component.test.tsx.snap create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/chart_component.test.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/chart_component.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/constants.ts create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.scss create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/expression.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/gauge_visualization.ts create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/index.scss create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/index.ts create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/palette_config.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/suggestions.test.ts create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/suggestions.ts create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_config_panel.scss create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_toolbar.test.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/index.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/utils.test.ts create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/utils.ts create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts create mode 100644 x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx create mode 100644 x-pack/test/functional/apps/lens/gauge.ts diff --git a/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.scss b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.scss index 6e1afd91c476d..fb004dfce4ec0 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.scss +++ b/src/plugins/chart_expressions/expression_heatmap/public/expression_renderers/index.scss @@ -9,14 +9,6 @@ padding: $euiSizeS; } -.heatmap-chart__empty { - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - .heatmap-chart-icon__subdued { fill: $euiTextSubduedColor; } diff --git a/src/plugins/charts/public/static/components/empty_placeholder.scss b/src/plugins/charts/public/static/components/empty_placeholder.scss new file mode 100644 index 0000000000000..3f98da9eecb6a --- /dev/null +++ b/src/plugins/charts/public/static/components/empty_placeholder.scss @@ -0,0 +1,7 @@ +.chart__empty-placeholder { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} \ No newline at end of file diff --git a/src/plugins/charts/public/static/components/empty_placeholder.tsx b/src/plugins/charts/public/static/components/empty_placeholder.tsx index db3f3fb6739d5..e376120c9cd9e 100644 --- a/src/plugins/charts/public/static/components/empty_placeholder.tsx +++ b/src/plugins/charts/public/static/components/empty_placeholder.tsx @@ -9,15 +9,20 @@ import React from 'react'; import { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import './empty_placeholder.scss'; -export const EmptyPlaceholder = (props: { icon: IconType }) => ( +export const EmptyPlaceholder = ({ + icon, + message = , +}: { + icon: IconType; + message?: JSX.Element; +}) => ( <> - - + + -

- -

+

{message}

); diff --git a/x-pack/plugins/lens/common/expressions/gauge_chart/gauge_chart.ts b/x-pack/plugins/lens/common/expressions/gauge_chart/gauge_chart.ts new file mode 100644 index 0000000000000..33bd9a802dde2 --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/gauge_chart/gauge_chart.ts @@ -0,0 +1,134 @@ +/* + * 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 type { ExpressionFunctionDefinition } from '../../../../../../src/plugins/expressions/common'; +import type { LensMultiTable } from '../../types'; +import { GaugeArguments, EXPRESSION_GAUGE_NAME, GAUGE_FUNCTION_RENDERER_NAME } from './types'; + +export interface GaugeExpressionProps { + data: LensMultiTable; + args: GaugeArguments; +} +export interface GaugeRender { + type: 'render'; + as: typeof GAUGE_FUNCTION_RENDERER_NAME; + value: GaugeExpressionProps; +} + +export const gauge: ExpressionFunctionDefinition< + typeof EXPRESSION_GAUGE_NAME, + LensMultiTable, + GaugeArguments, + GaugeRender +> = { + name: EXPRESSION_GAUGE_NAME, + type: 'render', + help: i18n.translate('xpack.lens.gauge.expressionHelpLabel', { + defaultMessage: 'Gauge renderer', + }), + args: { + title: { + types: ['string'], + help: i18n.translate('xpack.lens.gauge.title.help', { + defaultMessage: 'Saved gauge title', + }), + }, + shape: { + types: ['string'], + options: ['horizontalBullet', 'verticalBullet'], + help: i18n.translate('xpack.lens.gauge.shape.help', { + defaultMessage: 'Type of gauge chart', + }), + }, + description: { + types: ['string'], + help: i18n.translate('xpack.lens.gauge.description.help', { + defaultMessage: 'Saved gauge description', + }), + }, + metricAccessor: { + types: ['string'], + help: i18n.translate('xpack.lens.gauge.metricAccessor.help', { + defaultMessage: 'Current value', + }), + }, + minAccessor: { + types: ['string'], + help: i18n.translate('xpack.lens.gauge.minAccessor.help', { + defaultMessage: 'Minimum value', + }), + }, + maxAccessor: { + types: ['string'], + help: i18n.translate('xpack.lens.gauge.maxAccessor.help', { + defaultMessage: 'Maximum value', + }), + }, + goalAccessor: { + types: ['string'], + help: i18n.translate('xpack.lens.gauge.goalAccessor.help', { + defaultMessage: 'Goal Value', + }), + }, + colorMode: { + types: ['string'], + default: 'none', + options: ['none', 'palette'], + help: i18n.translate('xpack.lens.gauge.colorMode.help', { + defaultMessage: 'Which part of gauge to color', + }), + }, + palette: { + types: ['palette'], + help: i18n.translate('xpack.lens.metric.palette.help', { + defaultMessage: 'Provides colors for the values', + }), + }, + ticksPosition: { + types: ['string'], + options: ['auto', 'bands'], + help: i18n.translate('xpack.lens.gaugeChart.config.ticksPosition.help', { + defaultMessage: 'Specifies the placement of ticks', + }), + required: true, + }, + labelMajor: { + types: ['string'], + help: i18n.translate('xpack.lens.gaugeChart.config.labelMajor.help', { + defaultMessage: 'Specifies the labelMajor of the gauge chart displayed inside the chart.', + }), + required: false, + }, + labelMajorMode: { + types: ['string'], + options: ['none', 'auto', 'custom'], + help: i18n.translate('xpack.lens.gaugeChart.config.labelMajorMode.help', { + defaultMessage: 'Specifies the mode of labelMajor', + }), + required: true, + }, + labelMinor: { + types: ['string'], + help: i18n.translate('xpack.lens.gaugeChart.config.labelMinor.help', { + defaultMessage: 'Specifies the labelMinor of the gauge chart', + }), + required: false, + }, + }, + inputTypes: ['lens_multitable'], + fn(data: LensMultiTable, args: GaugeArguments) { + return { + type: 'render', + as: GAUGE_FUNCTION_RENDERER_NAME, + value: { + data, + args, + }, + }; + }, +}; diff --git a/x-pack/plugins/lens/common/expressions/gauge_chart/index.ts b/x-pack/plugins/lens/common/expressions/gauge_chart/index.ts new file mode 100644 index 0000000000000..10bb76b079417 --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/gauge_chart/index.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './gauge_chart'; +export * from './types'; diff --git a/x-pack/plugins/lens/common/expressions/gauge_chart/types.ts b/x-pack/plugins/lens/common/expressions/gauge_chart/types.ts new file mode 100644 index 0000000000000..0a58eec4fe0bb --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/gauge_chart/types.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + PaletteOutput, + CustomPaletteState, +} from '../../../../../../src/plugins/charts/common'; +import type { CustomPaletteParams, LayerType } from '../../types'; + +export const EXPRESSION_GAUGE_NAME = 'lens_gauge'; +export const GAUGE_FUNCTION_RENDERER_NAME = 'lens_gauge_renderer'; + +export const GaugeShapes = { + horizontalBullet: 'horizontalBullet', + verticalBullet: 'verticalBullet', +} as const; + +export const GaugeTicksPositions = { + auto: 'auto', + bands: 'bands', +} as const; + +export const GaugeLabelMajorModes = { + auto: 'auto', + custom: 'custom', + none: 'none', +} as const; + +export const GaugeColorModes = { + palette: 'palette', + none: 'none', +} as const; + +export type GaugeColorMode = keyof typeof GaugeColorModes; +export type GaugeShape = keyof typeof GaugeShapes; +export type GaugeLabelMajorMode = keyof typeof GaugeLabelMajorModes; +export type GaugeTicksPosition = keyof typeof GaugeTicksPositions; + +export interface SharedGaugeLayerState { + metricAccessor?: string; + minAccessor?: string; + maxAccessor?: string; + goalAccessor?: string; + ticksPosition: GaugeTicksPosition; + labelMajorMode: GaugeLabelMajorMode; + labelMajor?: string; + labelMinor?: string; + colorMode?: GaugeColorMode; + palette?: PaletteOutput; + shape: GaugeShape; +} + +export type GaugeLayerState = SharedGaugeLayerState & { + layerId: string; + layerType: LayerType; +}; + +export type GaugeVisualizationState = GaugeLayerState & { + shape: GaugeShape; +}; + +export type GaugeArguments = SharedGaugeLayerState & { + title?: string; + description?: string; + shape: GaugeShape; + colorMode: GaugeColorMode; + palette?: PaletteOutput; +}; diff --git a/x-pack/plugins/lens/common/expressions/index.ts b/x-pack/plugins/lens/common/expressions/index.ts index c5ee16ed4bcfd..2addc98610b4a 100644 --- a/x-pack/plugins/lens/common/expressions/index.ts +++ b/x-pack/plugins/lens/common/expressions/index.ts @@ -11,6 +11,7 @@ export * from './rename_columns'; export * from './merge_tables'; export * from './time_scale'; export * from './datatable'; +export * from './gauge_chart'; export * from './metric_chart'; export * from './pie_chart'; export * from './xy_chart'; diff --git a/x-pack/plugins/lens/public/assets/chart_gauge.tsx b/x-pack/plugins/lens/public/assets/chart_gauge.tsx new file mode 100644 index 0000000000000..afece311e5fae --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_gauge.tsx @@ -0,0 +1,61 @@ +/* + * 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 { EuiIconProps } from '@elastic/eui'; +import React from 'react'; + +export const LensIconChartGaugeHorizontal = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? {title} : null} + + + +); + +export const LensIconChartGaugeVertical = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? {title} : null} + + + +); diff --git a/x-pack/plugins/lens/public/async_services.ts b/x-pack/plugins/lens/public/async_services.ts index 9fac9a143c3b3..bbb4faf55e1e9 100644 --- a/x-pack/plugins/lens/public/async_services.ts +++ b/x-pack/plugins/lens/public/async_services.ts @@ -24,6 +24,8 @@ export * from './xy_visualization/xy_visualization'; export * from './xy_visualization'; export * from './heatmap_visualization/heatmap_visualization'; export * from './heatmap_visualization'; +export * from './visualizations/gauge/gauge_visualization'; +export * from './visualizations/gauge'; export * from './indexpattern_datasource/indexpattern'; export * from './indexpattern_datasource'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index 19315b5835d5f..46ca179e7cdb4 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -17,7 +17,7 @@ import { SerializedFieldFormat, } from 'src/plugins/field_formats/common'; import { VisualizationContainer } from '../../visualization_container'; -import { EmptyPlaceholder } from '../../shared_components'; +import { EmptyPlaceholder } from '../../../../../../src/plugins/charts/public'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { DataContext, DatatableComponent } from './table_basic'; import { LensMultiTable } from '../../../common'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index 7ceffcaaff5db..ba33ce77fc72d 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -18,11 +18,12 @@ import { EuiDataGridSorting, EuiDataGridStyle, } from '@elastic/eui'; +import { EmptyPlaceholder } from '../../../../../../src/plugins/charts/public'; import type { LensFilterEvent, LensTableRowContextMenuEvent } from '../../types'; import type { FormatFactory } from '../../../common'; import type { LensGridDirection } from '../../../common/expressions'; import { VisualizationContainer } from '../../visualization_container'; -import { EmptyPlaceholder, findMinMaxByColumnId } from '../../shared_components'; +import { findMinMaxByColumnId } from '../../shared_components'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; import type { DataContextType, diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index b3aabea46b874..3e8a31fa53915 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -138,6 +138,36 @@ describe('Datatable Visualization', () => { expect(suggestions.length).toBeGreaterThan(0); }); + it('should reject suggestion with static value', () => { + function staticValueCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'number', + label: `Static value: ${columnId}`, + isBucketed: false, + isStaticValue: true, + }, + }; + } + const suggestions = datatableVisualization.getSuggestions({ + state: { + layerId: 'first', + layerType: layerTypes.DATA, + columns: [{ columnId: 'col1' }], + }, + table: { + isMultiRow: true, + layerId: 'first', + changeType: 'initial', + columns: [staticValueCol('col1'), strCol('col2')], + }, + keptLayerIds: [], + }); + + expect(suggestions).toHaveLength(0); + }); + it('should retain width and hidden config from existing state', () => { const suggestions = datatableVisualization.getSuggestions({ state: { diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 139d85b51cee7..ca8cbbf067b06 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -105,7 +105,8 @@ export const getDatatableVisualization = ({ if ( keptLayerIds.length > 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || - (state && table.changeType === 'unchanged') + (state && table.changeType === 'unchanged') || + table.columns.some((col) => col.operation.isStaticValue) ) { 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 ac79a78ff7d6d..1ba3ff8f6ac34 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 @@ -20,6 +20,63 @@ const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', { defaultMessage: 'Empty dimension', }); +interface EmptyButtonProps { + columnId: string; + onClick: (id: string) => void; + group: VisualizationDimensionGroupConfig; +} + +const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( + { + onClick(columnId); + }} + > + + +); + +const SuggestedValueButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( + { + onClick(columnId); + }} + > + + +); + export function EmptyDimensionButton({ group, groups, @@ -34,12 +91,12 @@ export function EmptyDimensionButton({ layerId: string; groupIndex: number; layerIndex: number; - onClick: (id: string) => void; onDrop: ( droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier, dropType?: DropType ) => void; + onClick: (id: string) => void; group: VisualizationDimensionGroupConfig; groups: VisualizationDimensionGroupConfig[]; @@ -105,28 +162,11 @@ export function EmptyDimensionButton({ getCustomDropTarget={getCustomDropTarget} >
- { - onClick(value.columnId); - }} - > - - + {typeof group.suggestedValue?.() === 'number' ? ( + + ) : ( + + )}
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 84c7722ca1b88..13f9df1739005 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 @@ -406,7 +406,7 @@ export function LayerPanel( defaultMessage: 'Requires field', }); - const isOptional = !group.required; + const isOptional = !group.required && !group.suggestedValue; return ( - {' '} { ).toHaveLength(0); }); + test('when metric value isStaticValue', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'date-column', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'metric-column', + operation: { + isBucketed: false, + dataType: 'number', + scale: 'ratio', + label: 'Metric', + isStaticValue: true, + }, + }, + { + columnId: 'group-column', + operation: { + isBucketed: true, + dataType: 'string', + scale: 'ratio', + label: 'Group', + }, + }, + ], + changeType: 'initial', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([]); + }); + test('when there are 3 or more buckets', () => { expect( getSuggestions({ diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts index ebe93419edce6..aeddb8473fa98 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts @@ -19,9 +19,10 @@ export const getSuggestions: Visualization['getSugges keptLayerIds, }) => { if ( - state?.shape === CHART_SHAPES.HEATMAP && - (state.xAccessor || state.yAccessor || state.valueAccessor) && - table.changeType !== 'extended' + (state?.shape === CHART_SHAPES.HEATMAP && + (state.xAccessor || state.yAccessor || state.valueAccessor) && + table.changeType !== 'extended') || + table.columns.some((col) => col.operation.isStaticValue) ) { return []; } diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index f4d3e11c30cc3..bf645599cae11 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -58,7 +58,7 @@ function getAxisName(axis: 'x' | 'y') { } export const isBucketed = (op: OperationMetadata) => op.isBucketed && op.scale === 'ordinal'; -const isNumericMetric = (op: OperationMetadata) => op.dataType === 'number'; +const isNumericMetric = (op: OperationMetadata) => op.dataType === 'number' && !op.isStaticValue; export const filterOperationsAxis = (op: OperationMetadata) => isBucketed(op) || op.scale === 'interval'; 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 08361490cdc2c..fcc9a57285ba6 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 @@ -10,6 +10,7 @@ import { isDraggedOperation, DraggedOperation, DropType, + VisualizationDimensionGroupConfig, } from '../../../types'; import { getOperationDisplay } from '../../operations'; import { hasField, isDraggedField } from '../../utils'; @@ -36,7 +37,8 @@ const operationLabels = getOperationDisplay(); export function getNewOperation( field: IndexPatternField | undefined | false, filterOperations: (meta: OperationMetadata) => boolean, - targetColumn: GenericIndexPatternColumn + targetColumn: GenericIndexPatternColumn, + prioritizedOperation?: GenericIndexPatternColumn['operationType'] ) { if (!field) { return; @@ -47,7 +49,12 @@ export function getNewOperation( } // Detects if we can change the field only, otherwise change field + operation const shouldOperationPersist = targetColumn && newOperations.includes(targetColumn.operationType); - return shouldOperationPersist ? targetColumn.operationType : newOperations[0]; + if (shouldOperationPersist) { + return targetColumn.operationType; + } + const existsPrioritizedOperation = + prioritizedOperation && newOperations.includes(prioritizedOperation); + return existsPrioritizedOperation ? prioritizedOperation : newOperations[0]; } export function getField( @@ -85,7 +92,7 @@ export function getDropProps(props: GetDropProps) { } else if (hasTheSameField(sourceColumn, targetColumn)) { return; } else if (filterOperations(sourceColumn)) { - return getDropPropsForCompatibleGroup(targetColumn); + return getDropPropsForCompatibleGroup(props.dimensionGroups, dragging.columnId, targetColumn); } else { return getDropPropsFromIncompatibleGroup({ ...props, dragging }); } @@ -137,12 +144,26 @@ function getDropPropsForSameGroup(targetColumn?: GenericIndexPatternColumn): Dro return targetColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; } -function getDropPropsForCompatibleGroup(targetColumn?: GenericIndexPatternColumn): DropProps { - return { +function getDropPropsForCompatibleGroup( + dimensionGroups: VisualizationDimensionGroupConfig[], + sourceId: string, + targetColumn?: GenericIndexPatternColumn +): DropProps { + const canSwap = + targetColumn && + dimensionGroups + .find((group) => group.accessors.some((accessor) => accessor.columnId === sourceId)) + ?.filterOperations(targetColumn); + + const dropTypes: DropProps = { dropTypes: targetColumn - ? ['replace_compatible', 'replace_duplicate_compatible', 'swap_compatible'] + ? ['replace_compatible', 'replace_duplicate_compatible'] : ['move_compatible', 'duplicate_compatible'], }; + if (canSwap) { + dropTypes.dropTypes.push('swap_compatible'); + } + return dropTypes; } function getDropPropsFromIncompatibleGroup({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts index b518f667a0bfb..0c538d0fc9486 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -70,10 +70,19 @@ function onFieldDrop(props: DropHandlerProps) { dimensionGroups, } = props; + const prioritizedOperation = dimensionGroups.find( + (g) => g.groupId === groupId + )?.prioritizedOperation; + const layer = state.layers[layerId]; const indexPattern = state.indexPatterns[layer.indexPatternId]; const targetColumn = layer.columns[columnId]; - const newOperation = getNewOperation(droppedItem.field, filterOperations, targetColumn); + const newOperation = getNewOperation( + droppedItem.field, + filterOperations, + targetColumn, + prioritizedOperation + ); if (!isDraggedField(droppedItem) || !newOperation) { return false; 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 da5e39c907d07..d7ea174718813 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -1250,6 +1250,7 @@ describe('IndexPattern Data Source', () => { label: 'My Op', dataType: 'string', isBucketed: true, + isStaticValue: false, } as Operation); }); @@ -1723,6 +1724,7 @@ describe('IndexPattern Data Source', () => { ...state.layers.first.columns, newStatic: { dataType: 'number', + isStaticValue: true, isBucketed: false, label: 'Static value: 0', operationType: 'static_value', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 402371930b93e..6179f34226125 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -68,12 +68,13 @@ export function columnToOperation( column: GenericIndexPatternColumn, uniqueLabel?: string ): Operation { - const { dataType, label, isBucketed, scale } = column; + const { dataType, label, isBucketed, scale, operationType } = column; return { dataType: normalizeOperationDataType(dataType), isBucketed, scale, label: uniqueLabel || label, + isStaticValue: operationType === 'static_value', }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index a821dcee29d6d..783314968633f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1188,6 +1188,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, label: '', scale: undefined, + isStaticValue: false, }, }, { @@ -1197,6 +1198,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, label: 'Count of records', scale: 'ratio', + isStaticValue: false, }, }, ], @@ -1273,6 +1275,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, label: '', scale: undefined, + isStaticValue: false, }, }, { @@ -1282,6 +1285,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, label: 'Count of records', scale: 'ratio', + isStaticValue: false, }, }, ], @@ -1546,6 +1550,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'string', isBucketed: true, scale: undefined, + isStaticValue: false, }, }, ], @@ -1568,6 +1573,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'string', isBucketed: true, scale: undefined, + isStaticValue: false, }, }, ], @@ -1614,6 +1620,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'date', isBucketed: true, scale: 'interval', + isStaticValue: false, }, }, { @@ -1623,6 +1630,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, scale: 'ratio', + isStaticValue: false, }, }, ], @@ -1683,6 +1691,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'string', isBucketed: true, scale: 'ordinal', + isStaticValue: false, }, }, { @@ -1692,6 +1701,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'date', isBucketed: true, scale: 'interval', + isStaticValue: false, }, }, { @@ -1701,6 +1711,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, scale: 'ratio', + isStaticValue: false, }, }, ], @@ -1780,6 +1791,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'string', isBucketed: true, scale: 'ordinal', + isStaticValue: false, }, }, { @@ -1789,6 +1801,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'date', isBucketed: true, scale: 'interval', + isStaticValue: false, }, }, { @@ -1798,6 +1811,7 @@ describe('IndexPattern Data Source suggestions', () => { dataType: 'number', isBucketed: false, scale: 'ratio', + isStaticValue: false, }, }, ], @@ -1900,6 +1914,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, label: 'My Custom Range', scale: 'ordinal', + isStaticValue: false, }, }, { @@ -1909,6 +1924,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, label: 'timestampLabel', scale: 'interval', + isStaticValue: false, }, }, { @@ -1918,6 +1934,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, label: 'Unique count of dest', scale: undefined, + isStaticValue: false, }, }, ], @@ -2429,6 +2446,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, label: 'My Op', scale: undefined, + isStaticValue: false, }, }, { @@ -2438,6 +2456,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, label: 'Top 5', scale: undefined, + isStaticValue: false, }, }, ], @@ -2501,6 +2520,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, label: 'timestampLabel', scale: 'interval', + isStaticValue: false, }, }, { @@ -2510,6 +2530,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, label: 'Cumulative sum of Records label', scale: undefined, + isStaticValue: false, }, }, { @@ -2519,6 +2540,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, label: 'Cumulative sum of (incomplete)', scale: undefined, + isStaticValue: false, }, }, ], @@ -2580,6 +2602,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: true, label: '', scale: undefined, + isStaticValue: false, }, }, { @@ -2589,6 +2612,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, label: '', scale: undefined, + isStaticValue: false, }, }, { @@ -2598,6 +2622,7 @@ describe('IndexPattern Data Source suggestions', () => { isBucketed: false, label: '', scale: undefined, + isStaticValue: false, }, }, ], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx index 3e56565b2e13e..6d9a39887b940 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx @@ -72,6 +72,7 @@ describe('static_value', () => { dataType: 'number', isBucketed: false, operationType: 'static_value', + isStaticValue: true, references: [], params: { value: '23', @@ -106,6 +107,7 @@ describe('static_value', () => { dataType: 'number', isBucketed: false, operationType: 'static_value', + isStaticValue: true, references: [], params: { value: '23', @@ -237,6 +239,7 @@ describe('static_value', () => { label: 'Static value', dataType: 'number', operationType: 'static_value', + isStaticValue: true, isBucketed: false, scale: 'ratio', params: { value: '100' }, @@ -253,6 +256,7 @@ describe('static_value', () => { label: 'Static value', dataType: 'number', operationType: 'static_value', + isStaticValue: true, isBucketed: false, scale: 'ratio', params: { value: '23' }, @@ -263,6 +267,7 @@ describe('static_value', () => { label: 'Static value: 23', dataType: 'number', operationType: 'static_value', + isStaticValue: true, isBucketed: false, scale: 'ratio', params: { value: '23' }, @@ -283,6 +288,7 @@ describe('static_value', () => { label: 'Static value: 23', dataType: 'number', operationType: 'static_value', + isStaticValue: true, isBucketed: false, scale: 'ratio', params: { value: '23' }, @@ -300,6 +306,7 @@ describe('static_value', () => { label: 'Static value', dataType: 'number', operationType: 'static_value', + isStaticValue: true, isBucketed: false, scale: 'ratio', params: { value: '23' }, @@ -312,6 +319,7 @@ describe('static_value', () => { label: 'Static value: 53', dataType: 'number', operationType: 'static_value', + isStaticValue: true, isBucketed: false, scale: 'ratio', params: { value: '53' }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx index 45a35d18873fc..0adaf8ea00640 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx @@ -81,6 +81,7 @@ export const staticValueOperation: OperationDefinition< dataType: 'number', isBucketed: false, scale: 'ratio', + isStaticValue: true, }; }, toExpression: (layer, columnId) => { @@ -122,6 +123,7 @@ export const staticValueOperation: OperationDefinition< label: ofName(previousParams.value), dataType: 'number', operationType: 'static_value', + isStaticValue: true, isBucketed: false, scale: 'ratio', params: { ...previousParams, value: String(previousParams.value ?? defaultValue) }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 08136ed501cfc..b2cae54b0f8ba 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -378,10 +378,6 @@ describe('getOperationTypesForField', () => { "operationType": "formula", "type": "managedReference", }, - Object { - "operationType": "static_value", - "type": "managedReference", - }, ], }, Object { @@ -398,6 +394,20 @@ describe('getOperationTypesForField', () => { }, ], }, + Object { + "operationMetaData": Object { + "dataType": "number", + "isBucketed": false, + "isStaticValue": true, + "scale": "ratio", + }, + "operations": Array [ + Object { + "operationType": "static_value", + "type": "managedReference", + }, + ], + }, ] `); }); diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.tsx index 4c92864776045..b9a79963510ed 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.tsx @@ -22,7 +22,8 @@ import { } from '../../../../../src/plugins/charts/public'; import { AutoScale } from './auto_scale'; import { VisualizationContainer } from '../visualization_container'; -import { EmptyPlaceholder, getContrastColor } from '../shared_components'; +import { getContrastColor } from '../shared_components'; +import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { LensIconChartMetric } from '../assets/chart_metric'; import type { FormatFactory } from '../../common'; import type { MetricChartProps } from '../../common/expressions'; diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts index 8fceffa0db1fe..7c0f8dd073674 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts @@ -31,6 +31,18 @@ describe('metric_suggestions', () => { }; } + function staticValueCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'number', + label: `Static value: ${columnId}`, + isBucketed: false, + isStaticValue: true, + }, + }; + } + function dateCol(columnId: string): TableSuggestionColumn { return { columnId, @@ -86,7 +98,19 @@ describe('metric_suggestions', () => { ).map((table) => expect(getSuggestions({ table, keptLayerIds: ['l1'] })).toEqual([])) ); }); + test('does not suggest for a static value', () => { + const suggestion = getSuggestions({ + table: { + columns: [staticValueCol('id')], + isMultiRow: false, + layerId: 'l1', + changeType: 'unchanged', + }, + keptLayerIds: [], + }); + expect(suggestion).toHaveLength(0); + }); test('suggests a basic metric chart', () => { const [suggestion, ...rest] = getSuggestions({ table: { diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts index 3d6b2683b4ad2..e8a377169bb97 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts @@ -26,7 +26,8 @@ export function getSuggestions({ keptLayerIds.length > 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || table.columns.length !== 1 || - table.columns[0].operation.dataType !== 'number' + table.columns[0].operation.dataType !== 'number' || + table.columns[0].operation.isStaticValue ) { return []; } diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index 857bfa676faf4..87e51378377aa 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -90,7 +90,7 @@ export const getMetricVisualization = ({ defaultMessage: 'Metric', }), groupLabel: i18n.translate('xpack.lens.metric.groupLabel', { - defaultMessage: 'Single value', + defaultMessage: 'Goal and single value', }), sortPriority: 3, }, diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index 55b621498bb10..ef160b1dd682b 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -20,7 +20,7 @@ import type { LensMultiTable } from '../../common'; import type { PieExpressionArgs } from '../../common/expressions'; import { PieComponent } from './render_function'; import { VisualizationContainer } from '../visualization_container'; -import { EmptyPlaceholder } from '../shared_components'; +import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { LensIconChartDonut } from '../assets/chart_donut'; diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 559a3cfc48164..4bf7fcf9f8925 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -36,7 +36,7 @@ import { byDataColorPaletteMap, extractUniqTermsMap, } from './render_helpers'; -import { EmptyPlaceholder } from '../shared_components'; +import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import './visualization.scss'; import { ChartsPluginSetup, diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index a2e3f6d3ca865..229ef9b387ac0 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -288,6 +288,35 @@ describe('suggestions', () => { ).toHaveLength(0); }); + it('should reject when metric value isStaticValue', () => { + const results = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { + label: 'Count', + dataType: 'number' as DataType, + isBucketed: false, + isStaticValue: true, + }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + + expect(results.length).toEqual(0); + }); + it('should hide suggestions when there are no buckets', () => { const currentSuggestions = suggestions({ table: { diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 248f4a82b1694..dd42dd6474e0b 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -27,7 +27,8 @@ function shouldReject({ table, keptLayerIds, state }: SuggestionRequest 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || table.changeType === 'reorder' || - shouldRejectIntervals + shouldRejectIntervals || + table.columns.some((col) => col.operation.isStaticValue) ); } diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 49a80b73da1c4..e7c5e2f78920b 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -41,7 +41,7 @@ function newLayerState(layerId: string): PieLayerState { const bucketedOperations = (op: OperationMetadata) => op.isBucketed; const numberMetricOperations = (op: OperationMetadata) => - !op.isBucketed && op.dataType === 'number'; + !op.isBucketed && op.dataType === 'number' && !op.isStaticValue; const applyPaletteToColumnConfig = ( columns: AccessorConfig[], diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index b89492d7e7588..ecf237ac1327d 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -57,6 +57,7 @@ import type { PieVisualizationPluginSetupPlugins, } from './pie_visualization'; import type { HeatmapVisualization as HeatmapVisualizationType } from './heatmap_visualization'; +import type { GaugeVisualization as GaugeVisualizationType } from './visualizations/gauge'; import type { SavedObjectTaggingPluginStart } from '../../saved_objects_tagging/public'; import { AppNavLinkStatus } from '../../../../src/core/public'; @@ -169,6 +170,7 @@ export class LensPlugin { private metricVisualization: MetricVisualizationType | undefined; private pieVisualization: PieVisualizationType | undefined; private heatmapVisualization: HeatmapVisualizationType | undefined; + private gaugeVisualization: GaugeVisualizationType | undefined; private stopReportManager?: () => void; @@ -308,6 +310,7 @@ export class LensPlugin { MetricVisualization, PieVisualization, HeatmapVisualization, + GaugeVisualization, } = await import('./async_services'); this.datatableVisualization = new DatatableVisualization(); this.editorFrameService = new EditorFrameService(); @@ -316,6 +319,7 @@ export class LensPlugin { this.metricVisualization = new MetricVisualization(); this.pieVisualization = new PieVisualization(); this.heatmapVisualization = new HeatmapVisualization(); + this.gaugeVisualization = new GaugeVisualization(); const editorFrameSetupInterface = this.editorFrameService.setup(); @@ -337,6 +341,7 @@ export class LensPlugin { this.metricVisualization.setup(core, dependencies); this.pieVisualization.setup(core, dependencies); this.heatmapVisualization.setup(core, dependencies); + this.gaugeVisualization.setup(core, dependencies); } start(core: CoreStart, startDependencies: LensPluginStartDependencies): LensPublicStart { diff --git a/x-pack/plugins/lens/public/shared_components/empty_placeholder.tsx b/x-pack/plugins/lens/public/shared_components/empty_placeholder.tsx deleted file mode 100644 index ab69c6cc3139d..0000000000000 --- a/x-pack/plugins/lens/public/shared_components/empty_placeholder.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -const noResultsMessage = ( - -); - -export const EmptyPlaceholder = ({ - icon, - message = noResultsMessage, -}: { - icon: IconType; - message?: JSX.Element; -}) => ( - <> - - - -

{message}

-
- -); diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index 9ffddaa1a135b..dd2d5aa7c8558 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -export * from './empty_placeholder'; export type { ToolbarPopoverProps } from './toolbar_popover'; export { ToolbarPopover } from './toolbar_popover'; export { LegendSettingsPopover } from './legend_settings_popover'; @@ -17,3 +16,4 @@ export * from './helpers'; export { LegendActionPopover } from './legend_action_popover'; export { ValueLabelsSettings } from './value_labels_settings'; export * from './static_header'; +export * from './vis_label'; diff --git a/x-pack/plugins/lens/public/shared_components/vis_label.tsx b/x-pack/plugins/lens/public/shared_components/vis_label.tsx new file mode 100644 index 0000000000000..2fec7f56561a9 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/vis_label.tsx @@ -0,0 +1,102 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiFieldText, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +type LabelMode = 'auto' | 'custom' | 'none'; + +interface Label { + mode: LabelMode; + label: string; +} + +export interface VisLabelProps { + label: string; + mode: LabelMode; + handleChange: (label: Label) => void; + placeholder?: string; + hasAutoOption?: boolean; + header?: string; + dataTestSubj?: string; +} + +const defaultHeader = i18n.translate('xpack.lens.label.header', { + defaultMessage: 'Label', +}); + +const MODE_NONE = { + id: `lns_title_none`, + value: 'none', + text: i18n.translate('xpack.lens.chart.labelVisibility.none', { + defaultMessage: 'None', + }), +}; + +const MODE_CUSTOM = { + id: `lns_title_custom`, + value: 'custom', + text: i18n.translate('xpack.lens.chart.labelVisibility.custom', { + defaultMessage: 'Custom', + }), +}; + +const MODE_AUTO = { + id: `lns_title_auto`, + value: 'auto', + text: i18n.translate('xpack.lens.chart.labelVisibility.auto', { + defaultMessage: 'Auto', + }), +}; + +const modeDefaultOptions = [MODE_NONE, MODE_CUSTOM]; + +const modeEnhancedOptions = [MODE_NONE, MODE_AUTO, MODE_CUSTOM]; + +export function VisLabel({ + label, + mode, + handleChange, + hasAutoOption = false, + placeholder = '', + header = defaultHeader, + dataTestSubj, +}: VisLabelProps) { + return ( + + + { + if (target.value === 'custom') { + handleChange({ label: '', mode: target.value as LabelMode }); + return; + } + handleChange({ label: '', mode: target.value as LabelMode }); + }} + options={hasAutoOption ? modeEnhancedOptions : modeDefaultOptions} + value={mode} + /> + + + handleChange({ mode: 'custom', label: target.value })} + aria-label={header} + /> + + + ); +} 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 af9897581fcf4..67b7ccac97478 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -616,7 +616,9 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { visualizationState, framePublicAPI: { // any better idea to avoid `as`? - activeData: state.activeData as TableInspectorAdapter, + activeData: state.activeData + ? (current(state.activeData) as TableInspectorAdapter) + : undefined, datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), }, activeVisualization, @@ -653,7 +655,9 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { visualizationState: state.visualization.state, framePublicAPI: { // any better idea to avoid `as`? - activeData: state.activeData as TableInspectorAdapter, + activeData: state.activeData + ? (current(state.activeData) as TableInspectorAdapter) + : undefined, datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), }, activeVisualization, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index da1db7727aff7..8c5331100e903 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -217,6 +217,7 @@ export interface Datasource { props: DatasourceDimensionDropProps & { groupId: string; dragging: DragContextState['dragging']; + prioritizedOperation?: string; } ) => { dropTypes: DropType[]; nextLabel?: string } | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; @@ -431,6 +432,8 @@ export interface OperationMetadata { // TODO currently it's not possible to differentiate between a field from a raw // document and an aggregated metric which might be handy in some cases. Once we // introduce a raw document datasource, this should be considered here. + + isStaticValue?: boolean; } export interface VisualizationConfigProps { @@ -475,6 +478,8 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { required?: boolean; requiredMinDimensionCount?: number; dataTestSubj?: string; + prioritizedOperation?: string; + suggestedValue?: () => number | undefined; /** * When the dimension editor is enabled for this group, all dimensions in the group diff --git a/x-pack/plugins/lens/public/visualizations/gauge/__snapshots__/chart_component.test.tsx.snap b/x-pack/plugins/lens/public/visualizations/gauge/__snapshots__/chart_component.test.tsx.snap new file mode 100644 index 0000000000000..b588c1d341a75 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/__snapshots__/chart_component.test.tsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GaugeComponent renders the chart 1`] = ` + + + + +`; diff --git a/x-pack/plugins/lens/public/visualizations/gauge/chart_component.test.tsx b/x-pack/plugins/lens/public/visualizations/gauge/chart_component.test.tsx new file mode 100644 index 0000000000000..517c5718f613f --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/chart_component.test.tsx @@ -0,0 +1,429 @@ +/* + * 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 { Chart, Goal } from '@elastic/charts'; +import { shallowWithIntl } from '@kbn/test/jest'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import type { ColorStop, LensMultiTable } from '../../../common'; +import { fieldFormatsServiceMock } from '../../../../../../src/plugins/field_formats/public/mocks'; +import { GaugeArguments, GaugeLabelMajorMode } from '../../../common/expressions/gauge_chart'; +import { GaugeComponent, GaugeRenderProps } from './chart_component'; +import { DatatableColumn, DatatableRow } from 'src/plugins/expressions/common'; +import { VisualizationContainer } from '../../visualization_container'; + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + + return { + ...original, + getSpecId: jest.fn(() => {}), + }; +}); + +const numberColumn = (id = 'metric-accessor'): DatatableColumn => ({ + id, + name: 'Count of records', + meta: { + type: 'number', + index: 'kibana_sample_data_ecommerce', + params: { + id: 'number', + }, + }, +}); + +const createData = ( + row: DatatableRow = { 'metric-accessor': 3, 'min-accessor': 0, 'max-accessor': 10 } +): LensMultiTable => { + return { + type: 'lens_multitable', + tables: { + layerId: { + type: 'datatable', + rows: [row], + columns: Object.keys(row).map((key) => numberColumn(key)), + }, + }, + }; +}; + +const chartsThemeService = chartPluginMock.createSetupContract().theme; +const palettesRegistry = chartPluginMock.createPaletteRegistry(); +const formatService = fieldFormatsServiceMock.createStartContract(); +const args: GaugeArguments = { + labelMajor: 'Gauge', + description: 'vis description', + metricAccessor: 'metric-accessor', + minAccessor: '', + maxAccessor: '', + goalAccessor: '', + shape: 'verticalBullet', + colorMode: 'none', + ticksPosition: 'auto', + labelMajorMode: 'auto', +}; + +describe('GaugeComponent', function () { + let wrapperProps: GaugeRenderProps; + + beforeAll(() => { + wrapperProps = { + data: createData(), + chartsThemeService, + args, + paletteService: palettesRegistry, + formatFactory: formatService.deserialize, + }; + }); + + it('renders the chart', () => { + const component = shallowWithIntl(); + expect(component.find(Chart)).toMatchSnapshot(); + }); + + it('shows empty placeholder when metricAccessor is not provided', async () => { + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: undefined, + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + }, + data: createData({ 'min-accessor': 0, 'max-accessor': 10 }), + }; + const component = shallowWithIntl(); + expect(component.find(VisualizationContainer)).toHaveLength(1); + }); + + it('shows empty placeholder when minimum accessor equals maximum accessor', async () => { + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + }, + data: createData({ 'metric-accessor': 0, 'min-accessor': 0, 'max-accessor': 0 }), + }; + const component = shallowWithIntl(); + expect(component.find('EmptyPlaceholder')).toHaveLength(1); + }); + it('shows empty placeholder when minimum accessor value is greater maximum accessor value', async () => { + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + }, + data: createData({ 'metric-accessor': 0, 'min-accessor': 0, 'max-accessor': -10 }), + }; + const component = shallowWithIntl(); + expect(component.find('EmptyPlaceholder')).toHaveLength(1); + }); + it('when metric value is bigger than max, it takes maximum value', () => { + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + ticksPosition: 'bands', + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + }, + data: createData({ 'metric-accessor': 12, 'min-accessor': 0, 'max-accessor': 10 }), + } as GaugeRenderProps; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('actual')).toEqual(10); + }); + + describe('labelMajor and labelMinor settings', () => { + it('displays no labelMajor and no labelMinor when no passed', () => { + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + labelMajorMode: 'none' as GaugeLabelMajorMode, + labelMinor: '', + }, + }; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('labelMajor')).toEqual(''); + expect(goal.prop('labelMinor')).toEqual(''); + }); + it('displays custom labelMajor and labelMinor when passed', () => { + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + labelMajorMode: 'custom' as GaugeLabelMajorMode, + labelMajor: 'custom labelMajor', + labelMinor: 'custom labelMinor', + }, + }; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('labelMajor')).toEqual('custom labelMajor '); + expect(goal.prop('labelMinor')).toEqual('custom labelMinor '); + }); + it('displays auto labelMajor', () => { + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + labelMajorMode: 'auto' as GaugeLabelMajorMode, + labelMajor: '', + }, + }; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('labelMajor')).toEqual('Count of records '); + }); + }); + + describe('ticks and color bands', () => { + it('displays auto ticks', () => { + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + }, + }; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 3.33, 6.67, 10]); + }); + it('spreads auto ticks only over the [min, max] domain if color bands defined bigger domain', () => { + const palette = { + type: 'palette' as const, + name: 'custom', + params: { + colors: ['#aaa', '#bbb', '#ccc'], + gradient: false, + stops: [10, 20, 30] as unknown as ColorStop[], + range: 'number', + rangeMin: 0, + rangeMax: 20, + }, + }; + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + palette, + }, + } as GaugeRenderProps; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 3.33, 6.67, 10]); + }); + it('sets proper color bands and ticks on color bands for values smaller than maximum', () => { + const palette = { + type: 'palette' as const, + name: 'custom', + params: { + colors: ['#aaa', '#bbb', '#ccc'], + gradient: false, + stops: [1, 2, 3] as unknown as ColorStop[], + range: 'number', + rangeMin: 0, + rangeMax: 4, + }, + }; + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + palette, + ticksPosition: 'bands', + }, + } as GaugeRenderProps; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 1, 2, 3, 10]); + expect(goal.prop('bands')).toEqual([0, 1, 2, 3, 10]); + }); + it('sets proper color bands and ticks on color bands if palette steps are smaller than minimum', () => { + const palette = { + type: 'palette' as const, + name: 'custom', + params: { + colors: ['#aaa', '#bbb', '#ccc'], + gradient: false, + stops: [-10, -5, 0] as unknown as ColorStop[], + range: 'number', + rangeMin: 0, + rangeMax: 4, + }, + }; + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + palette, + ticksPosition: 'bands', + }, + } as GaugeRenderProps; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 10]); + expect(goal.prop('bands')).toEqual([0, 10]); + }); + it('sets proper color bands and ticks on color bands if percent palette steps are smaller than 0', () => { + const palette = { + type: 'palette' as const, + name: 'custom', + params: { + colors: ['#aaa', '#bbb', '#ccc'], + gradient: false, + stops: [-20, -60, 80], + range: 'percent', + rangeMin: 0, + rangeMax: 4, + }, + }; + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + palette, + ticksPosition: 'bands', + }, + } as GaugeRenderProps; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 8, 10]); + expect(goal.prop('bands')).toEqual([0, 8, 10]); + }); + it('doesnt set ticks for values differing <10%', () => { + const palette = { + type: 'palette' as const, + name: 'custom', + params: { + colors: ['#aaa', '#bbb', '#ccc'], + gradient: false, + stops: [1, 1.5, 3] as unknown as ColorStop[], + range: 'number', + rangeMin: 0, + rangeMax: 10, + }, + }; + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + palette, + ticksPosition: 'bands', + }, + } as GaugeRenderProps; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 1, 3, 10]); + expect(goal.prop('bands')).toEqual([0, 1, 1.5, 3, 10]); + }); + it('sets proper color bands and ticks on color bands for values greater than maximum', () => { + const palette = { + type: 'palette' as const, + name: 'custom', + params: { + colors: ['#aaa', '#bbb', '#ccc'], + gradient: false, + stops: [10, 20, 30, 31] as unknown as ColorStop[], + range: 'number', + rangeMin: 0, + rangeMax: 30, + }, + }; + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + palette, + ticksPosition: 'bands', + }, + } as GaugeRenderProps; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 10]); + expect(goal.prop('bands')).toEqual([0, 10]); + }); + it('passes number bands from color palette with no stops defined', () => { + const palette = { + type: 'palette' as const, + name: 'gray', + params: { + colors: ['#aaa', '#bbb'], + gradient: false, + stops: [], + range: 'number', + rangeMin: 0, + rangeMax: 10, + }, + }; + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + colorMode: 'palette', + palette, + ticksPosition: 'bands', + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + }, + } as GaugeRenderProps; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 5, 10]); + expect(goal.prop('bands')).toEqual([0, 5, 10]); + }); + it('passes percent bands from color palette', () => { + const palette = { + type: 'palette' as const, + name: 'custom', + params: { + colors: ['#aaa', '#bbb', '#ccc'], + gradient: false, + stops: [20, 60, 80], + range: 'percent', + rangeMin: 0, + rangeMax: 10, + }, + }; + const customProps = { + ...wrapperProps, + args: { + ...wrapperProps.args, + colorMode: 'palette', + palette, + ticksPosition: 'bands', + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + }, + } as GaugeRenderProps; + const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 2, 6, 8, 10]); + expect(goal.prop('bands')).toEqual([0, 2, 6, 8, 10]); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/gauge/chart_component.tsx b/x-pack/plugins/lens/public/visualizations/gauge/chart_component.tsx new file mode 100644 index 0000000000000..a8f2b0e1c204c --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/chart_component.tsx @@ -0,0 +1,244 @@ +/* + * 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, { FC } from 'react'; +import { Chart, Goal, Settings } from '@elastic/charts'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { + CustomPaletteState, + ChartsPluginSetup, + PaletteRegistry, +} from 'src/plugins/charts/public'; +import { VisualizationContainer } from '../../visualization_container'; +import './index.scss'; +import { LensIconChartGaugeHorizontal, LensIconChartGaugeVertical } from '../../assets/chart_gauge'; +import { EmptyPlaceholder } from '../../../../../../src/plugins/charts/public'; +import { getMaxValue, getMinValue, getValueFromAccessor } from './utils'; +import { + GaugeExpressionProps, + GaugeShapes, + GaugeTicksPosition, + GaugeTicksPositions, + GaugeLabelMajorMode, +} from '../../../common/expressions/gauge_chart'; +import type { FormatFactory } from '../../../common'; + +export type GaugeRenderProps = GaugeExpressionProps & { + formatFactory: FormatFactory; + chartsThemeService: ChartsPluginSetup['theme']; + paletteService: PaletteRegistry; +}; + +declare global { + interface Window { + /** + * Flag used to enable debugState on elastic charts + */ + _echDebugStateFlag?: boolean; + } +} + +function normalizeColors({ colors, stops, range }: CustomPaletteState, min: number) { + if (!colors) { + return; + } + const colorsOutOfRangeSmaller = Math.max( + stops.filter((stop, i) => (range === 'percent' ? stop < 0 : stop < min)).length, + 0 + ); + return colors.slice(colorsOutOfRangeSmaller); +} + +function normalizeBands( + { colors, stops, range }: CustomPaletteState, + { min, max }: { min: number; max: number } +) { + if (!stops.length) { + const step = (max - min) / colors.length; + return [min, ...colors.map((_, i) => min + (i + 1) * step)]; + } + if (range === 'percent') { + const filteredStops = stops.filter((stop) => stop >= 0 && stop <= 100); + return [min, ...filteredStops.map((step) => min + step * ((max - min) / 100)), max]; + } + const orderedStops = stops.filter((stop, i) => stop < max && stop > min); + return [min, ...orderedStops, max]; +} + +function getTitle( + labelMajorMode: GaugeLabelMajorMode, + labelMajor?: string, + fallbackTitle?: string +) { + if (labelMajorMode === 'none') { + return ''; + } else if (labelMajorMode === 'auto') { + return `${fallbackTitle || ''} `; + } + return `${labelMajor || fallbackTitle || ''} `; +} + +// TODO: once charts handle not displaying labels when there's no space for them, it's safe to remove this +function getTicksLabels(baseStops: number[]) { + const tenPercentRange = (Math.max(...baseStops) - Math.min(...baseStops)) * 0.1; + const lastIndex = baseStops.length - 1; + return baseStops.filter((stop, i) => { + if (i === 0 || i === lastIndex) { + return true; + } + + return !( + stop - baseStops[i - 1] < tenPercentRange || baseStops[lastIndex] - stop < tenPercentRange + ); + }); +} + +function getTicks( + ticksPosition: GaugeTicksPosition, + range: [number, number], + colorBands?: number[] +) { + if (ticksPosition === GaugeTicksPositions.bands && colorBands) { + return colorBands && getTicksLabels(colorBands); + } + const TICKS_NO = 3; + const min = Math.min(...(colorBands || []), ...range); + const max = Math.max(...(colorBands || []), ...range); + const step = (max - min) / TICKS_NO; + return [ + ...Array(TICKS_NO) + .fill(null) + .map((_, i) => Number((min + step * i).toFixed(2))), + max, + ]; +} + +export const GaugeComponent: FC = ({ + data, + args, + formatFactory, + chartsThemeService, +}) => { + const { + shape: subtype, + metricAccessor, + palette, + colorMode, + labelMinor, + labelMajor, + labelMajorMode, + ticksPosition, + } = args; + if (!metricAccessor) { + return ; + } + + const chartTheme = chartsThemeService.useChartsTheme(); + + const table = Object.values(data.tables)[0]; + const metricColumn = table.columns.find((col) => col.id === metricAccessor); + + const chartData = table.rows.filter( + (v) => typeof v[metricAccessor!] === 'number' || Array.isArray(v[metricAccessor!]) + ); + const row = chartData?.[0]; + + const metricValue = getValueFromAccessor('metricAccessor', row, args); + + const icon = + subtype === GaugeShapes.horizontalBullet + ? LensIconChartGaugeHorizontal + : LensIconChartGaugeVertical; + + if (typeof metricValue !== 'number') { + return ; + } + + const goal = getValueFromAccessor('goalAccessor', row, args); + const min = getMinValue(row, args); + const max = getMaxValue(row, args); + + if (min === max) { + return ( + + } + /> + ); + } else if (min > max) { + return ( + + } + /> + ); + } + + const tickFormatter = formatFactory( + metricColumn?.meta?.params?.params + ? metricColumn?.meta?.params + : { + id: 'number', + params: { + pattern: max - min > 5 ? `0,0` : `0,0.0`, + }, + } + ); + const colors = palette?.params?.colors ? normalizeColors(palette.params, min) : undefined; + const bands: number[] = (palette?.params as CustomPaletteState) + ? normalizeBands(args.palette?.params as CustomPaletteState, { min, max }) + : [min, max]; + + // TODO: format in charts + const formattedActual = Math.round(Math.min(Math.max(metricValue, min), max) * 1000) / 1000; + + return ( + + + = min && goal <= max ? goal : undefined} + actual={formattedActual} + tickValueFormatter={({ value: tickValue }) => tickFormatter.convert(tickValue)} + bands={bands} + ticks={getTicks(ticksPosition, [min, max], bands)} + bandFillColor={ + colorMode === 'palette' && colors + ? (val) => { + const index = bands && bands.indexOf(val.value) - 1; + return colors && index >= 0 && colors[index] + ? colors[index] + : colors[colors.length - 1]; + } + : () => `rgba(255,255,255,0)` + } + labelMajor={getTitle(labelMajorMode, labelMajor, metricColumn?.name)} + labelMinor={labelMinor ? labelMinor + ' ' : ''} + /> + + ); +}; + +export function GaugeChartReportable(props: GaugeRenderProps) { + return ( + + + + ); +} diff --git a/x-pack/plugins/lens/public/visualizations/gauge/constants.ts b/x-pack/plugins/lens/public/visualizations/gauge/constants.ts new file mode 100644 index 0000000000000..1a801dc942652 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/constants.ts @@ -0,0 +1,16 @@ +/* + * 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 const LENS_GAUGE_RENDERER = 'lens_gauge_renderer'; +export const LENS_GAUGE_ID = 'lnsGauge'; + +export const GROUP_ID = { + METRIC: 'metric', + MIN: 'min', + MAX: 'max', + GOAL: 'goal', +} as const; diff --git a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.scss b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.scss new file mode 100644 index 0000000000000..d7664b9d2da16 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.scss @@ -0,0 +1,3 @@ +.lnsDynamicColoringRow { + align-items: center; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx new file mode 100644 index 0000000000000..89a4be3300e2e --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/dimension_editor.tsx @@ -0,0 +1,223 @@ +/* + * 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 { + EuiButtonEmpty, + EuiFlexGroup, + EuiColorPaletteDisplay, + EuiFormRow, + EuiFlexItem, + EuiSwitchEvent, + EuiSwitch, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { PaletteRegistry } from 'src/plugins/charts/public'; +import { + isNumericFieldForDatatable, + GaugeVisualizationState, + GaugeTicksPositions, + GaugeColorModes, +} from '../../../common/expressions'; +import { + applyPaletteParams, + CustomizablePalette, + CUSTOM_PALETTE, + FIXED_PROGRESSION, + getStopsForFixedMode, + PalettePanelContainer, +} from '../../shared_components/'; +import type { VisualizationDimensionEditorProps } from '../../types'; +import { defaultPaletteParams } from './palette_config'; + +import './dimension_editor.scss'; +import { getMaxValue, getMinValue } from './utils'; + +export function GaugeDimensionEditor( + props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + } +) { + const { state, setState, frame, accessor } = props; + const [isPaletteOpen, setIsPaletteOpen] = useState(false); + + if (state?.metricAccessor !== accessor) return null; + + const currentData = frame.activeData?.[state.layerId]; + const [firstRow] = currentData?.rows || []; + + if (accessor == null || firstRow == null || !isNumericFieldForDatatable(currentData, accessor)) { + return null; + } + + const hasDynamicColoring = state?.colorMode === 'palette'; + + const currentMinMax = { + min: getMinValue(firstRow, state), + max: getMaxValue(firstRow, state), + }; + + const activePalette = state?.palette || { + type: 'palette', + name: defaultPaletteParams.name, + params: { + ...defaultPaletteParams, + colorStops: undefined, + stops: undefined, + rangeMin: currentMinMax.min, + rangeMax: (currentMinMax.max * 3) / 4, + }, + }; + + const displayStops = applyPaletteParams(props.paletteService, activePalette, currentMinMax); + + const togglePalette = () => setIsPaletteOpen(!isPaletteOpen); + return ( + <> + + { + const { checked } = e.target; + const params = checked + ? { + palette: { + ...activePalette, + params: { + ...activePalette.params, + stops: displayStops, + }, + }, + ticksPosition: GaugeTicksPositions.bands, + colorMode: GaugeColorModes.palette, + } + : { + ticksPosition: GaugeTicksPositions.auto, + colorMode: GaugeColorModes.none, + }; + + setState({ + ...state, + ...params, + }); + }} + /> + + {hasDynamicColoring && ( + <> + + + + color) + } + type={FIXED_PROGRESSION} + onClick={togglePalette} + /> + + + + {i18n.translate('xpack.lens.paletteTableGradient.customize', { + defaultMessage: 'Edit', + })} + + + { + // if the new palette is not custom, replace the rangeMin with the artificial one + if ( + newPalette.name !== CUSTOM_PALETTE && + newPalette.params && + newPalette.params.rangeMin !== currentMinMax.min + ) { + newPalette.params.rangeMin = currentMinMax.min; + } + setState({ + ...state, + palette: newPalette, + }); + }} + /> + + + + + + { + setState({ + ...state, + ticksPosition: + state.ticksPosition === GaugeTicksPositions.bands + ? GaugeTicksPositions.auto + : GaugeTicksPositions.bands, + }); + }} + /> + + + )} + + ); +} diff --git a/x-pack/plugins/lens/public/visualizations/gauge/expression.tsx b/x-pack/plugins/lens/public/visualizations/gauge/expression.tsx new file mode 100644 index 0000000000000..b8852f22691ed --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/expression.tsx @@ -0,0 +1,60 @@ +/* + * 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 { I18nProvider } from '@kbn/i18n-react'; +import ReactDOM from 'react-dom'; +import React from 'react'; +import type { IInterpreterRenderHandlers } from '../../../../../../src/plugins/expressions'; +import type { FormatFactory } from '../../../common'; +import { LENS_GAUGE_RENDERER } from './constants'; +import type { + ChartsPluginSetup, + PaletteRegistry, +} from '../../../../../../src/plugins/charts/public'; +import { GaugeChartReportable } from './chart_component'; +import type { GaugeExpressionProps } from '../../../common/expressions'; + +export const getGaugeRenderer = (dependencies: { + formatFactory: FormatFactory; + chartsThemeService: ChartsPluginSetup['theme']; + paletteService: PaletteRegistry; +}) => ({ + name: LENS_GAUGE_RENDERER, + displayName: i18n.translate('xpack.lens.gauge.visualizationName', { + defaultMessage: 'Gauge', + }), + help: '', + validate: () => undefined, + reuseDomNode: true, + render: async ( + domNode: Element, + config: GaugeExpressionProps, + handlers: IInterpreterRenderHandlers + ) => { + ReactDOM.render( + + { + + } + , + domNode, + () => { + handlers.done(); + } + ); + + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); + +const MemoizedChart = React.memo(GaugeChartReportable); diff --git a/x-pack/plugins/lens/public/visualizations/gauge/gauge_visualization.ts b/x-pack/plugins/lens/public/visualizations/gauge/gauge_visualization.ts new file mode 100644 index 0000000000000..231b6bacbbe20 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/gauge_visualization.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './expression'; +export * from './visualization'; diff --git a/x-pack/plugins/lens/public/visualizations/gauge/index.scss b/x-pack/plugins/lens/public/visualizations/gauge/index.scss new file mode 100644 index 0000000000000..c999fe7e218a2 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/index.scss @@ -0,0 +1,14 @@ +.lnsGaugeExpression__container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + text-align: center; + overflow-x: hidden; + + .echChart { + width: 100%; + } +} diff --git a/x-pack/plugins/lens/public/visualizations/gauge/index.ts b/x-pack/plugins/lens/public/visualizations/gauge/index.ts new file mode 100644 index 0000000000000..b0a4f26f2d675 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/index.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreSetup } from 'kibana/public'; +import type { ExpressionsSetup } from '../../../../../../src/plugins/expressions/public'; +import type { EditorFrameSetup } from '../../types'; +import type { ChartsPluginSetup } from '../../../../../../src/plugins/charts/public'; +import type { FormatFactory } from '../../../common'; +import { transparentizePalettes } from './utils'; + +export interface GaugeVisualizationPluginSetupPlugins { + expressions: ExpressionsSetup; + formatFactory: FormatFactory; + editorFrame: EditorFrameSetup; + charts: ChartsPluginSetup; +} + +export class GaugeVisualization { + setup( + core: CoreSetup, + { expressions, formatFactory, editorFrame, charts }: GaugeVisualizationPluginSetupPlugins + ) { + editorFrame.registerVisualization(async () => { + const { getGaugeVisualization, getGaugeRenderer } = await import('../../async_services'); + const palettes = transparentizePalettes(await charts.palettes.getPalettes()); + + expressions.registerRenderer( + getGaugeRenderer({ + formatFactory, + chartsThemeService: charts.theme, + paletteService: palettes, + }) + ); + return getGaugeVisualization({ paletteService: palettes }); + }); + } +} diff --git a/x-pack/plugins/lens/public/visualizations/gauge/palette_config.tsx b/x-pack/plugins/lens/public/visualizations/gauge/palette_config.tsx new file mode 100644 index 0000000000000..20e026a3f5719 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/palette_config.tsx @@ -0,0 +1,23 @@ +/* + * 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 { RequiredPaletteParamTypes } from '../../../common'; +import { defaultPaletteParams as sharedDefaultParams } from '../../shared_components/'; + +export const DEFAULT_PALETTE_NAME = 'gray'; +export const DEFAULT_COLOR_STEPS = 3; +export const DEFAULT_MIN_STOP = 0; +export const DEFAULT_MAX_STOP = 100; + +export const defaultPaletteParams: RequiredPaletteParamTypes = { + ...sharedDefaultParams, + rangeMin: DEFAULT_MIN_STOP, + rangeMax: DEFAULT_MAX_STOP, + name: DEFAULT_PALETTE_NAME, + steps: DEFAULT_COLOR_STEPS, + maxSteps: 5, +}; diff --git a/x-pack/plugins/lens/public/visualizations/gauge/suggestions.test.ts b/x-pack/plugins/lens/public/visualizations/gauge/suggestions.test.ts new file mode 100644 index 0000000000000..cced4bb2c309b --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/suggestions.test.ts @@ -0,0 +1,213 @@ +/* + * 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 { getSuggestions } from './suggestions'; +import { GaugeShapes, GaugeVisualizationState } from '../../../common/expressions'; +import { layerTypes } from '../../../common'; + +const metricColumn = { + columnId: 'metric-column', + operation: { + isBucketed: false, + dataType: 'number' as const, + scale: 'ratio' as const, + label: 'Metric', + }, +}; + +const bucketColumn = { + columnId: 'date-column-01', + operation: { + isBucketed: true, + dataType: 'date' as const, + scale: 'interval' as const, + label: 'Date', + }, +}; + +describe('gauge suggestions', () => { + describe('rejects suggestions', () => { + test('when currently active and unchanged data', () => { + const unchangedSuggestion = { + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'unchanged' as const, + }, + state: { + shape: GaugeShapes.horizontalBullet, + layerId: 'first', + layerType: layerTypes.DATA, + } as GaugeVisualizationState, + keptLayerIds: ['first'], + }; + expect(getSuggestions(unchangedSuggestion)).toHaveLength(0); + }); + test('when there are buckets', () => { + const bucketAndMetricSuggestion = { + table: { + layerId: 'first', + isMultiRow: true, + columns: [bucketColumn, metricColumn], + changeType: 'initial' as const, + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + } as GaugeVisualizationState, + keptLayerIds: ['first'], + }; + expect(getSuggestions(bucketAndMetricSuggestion)).toEqual([]); + }); + test('when currently active with partial configuration', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn], + changeType: 'initial', + }, + state: { + shape: GaugeShapes.horizontalBullet, + layerId: 'first', + layerType: layerTypes.DATA, + minAccessor: 'some-field', + labelMajorMode: 'auto', + ticksPosition: 'auto', + } as GaugeVisualizationState, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + test('for tables with a single bucket dimension', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [bucketColumn], + changeType: 'reduced', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + } as GaugeVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([]); + }); + test('when two metric accessor are available', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + metricColumn, + { + ...metricColumn, + columnId: 'metric-column2', + }, + ], + changeType: 'initial', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + } as GaugeVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([]); + }); + }); +}); + +describe('shows suggestions', () => { + test('when complete configuration has been resolved', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn], + changeType: 'initial', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + } as GaugeVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + layerType: layerTypes.DATA, + shape: GaugeShapes.horizontalBullet, + metricAccessor: 'metric-column', + labelMajorMode: 'auto', + ticksPosition: 'auto', + }, + title: 'Gauge', + hide: true, + previewIcon: 'empty', + score: 0.5, + }, + { + hide: true, + previewIcon: 'empty', + title: 'Gauge', + score: 0.5, + state: { + layerId: 'first', + layerType: 'data', + metricAccessor: 'metric-column', + shape: GaugeShapes.verticalBullet, + ticksPosition: 'auto', + labelMajorMode: 'auto', + }, + }, + ]); + }); + test('passes the state when change is shape change', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn], + changeType: 'extended', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + shape: GaugeShapes.horizontalBullet, + metricAccessor: 'metric-column', + } as GaugeVisualizationState, + keptLayerIds: ['first'], + subVisualizationId: GaugeShapes.verticalBullet, + }) + ).toEqual([ + { + state: { + layerType: layerTypes.DATA, + shape: GaugeShapes.verticalBullet, + metricAccessor: 'metric-column', + labelMajorMode: 'auto', + ticksPosition: 'auto', + layerId: 'first', + }, + previewIcon: 'empty', + title: 'Gauge', + hide: false, // shows suggestion when current is gauge + score: 0.5, + }, + ]); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/gauge/suggestions.ts b/x-pack/plugins/lens/public/visualizations/gauge/suggestions.ts new file mode 100644 index 0000000000000..03198411581c1 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/suggestions.ts @@ -0,0 +1,108 @@ +/* + * 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 type { TableSuggestion, Visualization } from '../../types'; +import { layerTypes } from '../../../common'; +import { + GaugeShape, + GaugeShapes, + GaugeTicksPositions, + GaugeLabelMajorModes, + GaugeVisualizationState, +} from '../../../common/expressions/gauge_chart'; + +const isNotNumericMetric = (table: TableSuggestion) => + table.columns?.[0]?.operation.dataType !== 'number' || + table.columns.some((col) => col.operation.isBucketed); + +const hasLayerMismatch = (keptLayerIds: string[], table: TableSuggestion) => + keptLayerIds.length > 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]); + +export const getSuggestions: Visualization['getSuggestions'] = ({ + table, + state, + keptLayerIds, + subVisualizationId, +}) => { + const isGauge = Boolean( + state && (state.minAccessor || state.maxAccessor || state.goalAccessor || state.metricAccessor) + ); + + const numberOfAccessors = + state && + [state.minAccessor, state.maxAccessor, state.goalAccessor, state.metricAccessor].filter(Boolean) + .length; + + if ( + hasLayerMismatch(keptLayerIds, table) || + isNotNumericMetric(table) || + (!isGauge && table.columns.length > 1) || + (isGauge && (numberOfAccessors !== table.columns.length || table.changeType === 'initial')) + ) { + return []; + } + + const shape: GaugeShape = + state?.shape === GaugeShapes.verticalBullet + ? GaugeShapes.verticalBullet + : GaugeShapes.horizontalBullet; + + const baseSuggestion = { + state: { + ...state, + shape, + layerId: table.layerId, + layerType: layerTypes.DATA, + ticksPosition: GaugeTicksPositions.auto, + labelMajorMode: GaugeLabelMajorModes.auto, + }, + title: i18n.translate('xpack.lens.gauge.gaugeLabel', { + defaultMessage: 'Gauge', + }), + previewIcon: 'empty', + score: 0.5, + hide: !isGauge || state?.metricAccessor === undefined, // only display for gauges for beta + }; + + const suggestions = isGauge + ? [ + { + ...baseSuggestion, + state: { + ...baseSuggestion.state, + ...state, + shape: + state?.shape === GaugeShapes.verticalBullet + ? GaugeShapes.horizontalBullet + : GaugeShapes.verticalBullet, + }, + }, + ] + : [ + { + ...baseSuggestion, + state: { + ...baseSuggestion.state, + metricAccessor: table.columns[0].columnId, + }, + }, + { + ...baseSuggestion, + state: { + ...baseSuggestion.state, + metricAccessor: table.columns[0].columnId, + shape: + state?.shape === GaugeShapes.verticalBullet + ? GaugeShapes.horizontalBullet + : GaugeShapes.verticalBullet, + }, + }, + ]; + + return suggestions; +}; diff --git a/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_config_panel.scss b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_config_panel.scss new file mode 100644 index 0000000000000..893ed71235881 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_config_panel.scss @@ -0,0 +1,3 @@ +.lnsGaugeToolbar__popover { + width: 500px; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_toolbar.test.tsx b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_toolbar.test.tsx new file mode 100644 index 0000000000000..2d3d54be97453 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/gauge_toolbar.test.tsx @@ -0,0 +1,179 @@ +/* + * 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, { FormEvent } from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { GaugeToolbar } from '.'; +import { FramePublicAPI, VisualizationToolbarProps } from '../../../types'; +import { ToolbarButton } from 'src/plugins/kibana_react/public'; +import { ReactWrapper } from 'enzyme'; +import { GaugeVisualizationState } from '../../../../common/expressions'; +import { act } from 'react-dom/test-utils'; + +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (fn: unknown) => fn, + }; +}); + +class Harness { + wrapper: ReactWrapper; + + constructor(wrapper: ReactWrapper) { + this.wrapper = wrapper; + } + + togglePopover() { + this.wrapper.find(ToolbarButton).simulate('click'); + } + + public get titleLabel() { + return this.wrapper.find('EuiFieldText[data-test-subj="lnsToolbarGaugeLabelMajor"]'); + } + public get titleSelect() { + return this.wrapper.find('EuiSelect[data-test-subj="lnsToolbarGaugeLabelMajor-select"]'); + } + + modifyTitle(e: FormEvent) { + act(() => { + this.titleLabel.prop('onChange')!(e); + }); + } + + public get subtitleSelect() { + return this.wrapper.find('EuiSelect[data-test-subj="lnsToolbarGaugeLabelMinor-select"]'); + } + + public get subtitleLabel() { + return this.wrapper.find('EuiFieldText[data-test-subj="lnsToolbarGaugeLabelMinor"]'); + } + + modifySubtitle(e: FormEvent) { + act(() => { + this.subtitleLabel.prop('onChange')!(e); + }); + } +} + +describe('gauge toolbar', () => { + let harness: Harness; + let defaultProps: VisualizationToolbarProps; + + beforeEach(() => { + defaultProps = { + setState: jest.fn(), + frame: {} as FramePublicAPI, + state: { + layerId: 'layerId', + layerType: 'data', + metricAccessor: 'metric-accessor', + minAccessor: '', + maxAccessor: '', + goalAccessor: '', + shape: 'verticalBullet', + colorMode: 'none', + ticksPosition: 'auto', + labelMajorMode: 'auto', + }, + }; + }); + + it('should reflect state in the UI for default props', async () => { + harness = new Harness(mountWithIntl()); + harness.togglePopover(); + + expect(harness.titleLabel.prop('value')).toBe(''); + expect(harness.titleSelect.prop('value')).toBe('auto'); + expect(harness.subtitleLabel.prop('value')).toBe(''); + expect(harness.subtitleSelect.prop('value')).toBe('none'); + }); + it('should reflect state in the UI for non-default props', async () => { + const props = { + ...defaultProps, + state: { + ...defaultProps.state, + ticksPosition: 'bands' as const, + labelMajorMode: 'custom' as const, + labelMajor: 'new labelMajor', + labelMinor: 'new labelMinor', + }, + }; + + harness = new Harness(mountWithIntl()); + harness.togglePopover(); + + expect(harness.titleLabel.prop('value')).toBe('new labelMajor'); + expect(harness.titleSelect.prop('value')).toBe('custom'); + expect(harness.subtitleLabel.prop('value')).toBe('new labelMinor'); + expect(harness.subtitleSelect.prop('value')).toBe('custom'); + }); + + describe('labelMajor', () => { + it('labelMajor label is disabled if labelMajor is selected to be none', () => { + defaultProps.state.labelMajorMode = 'none' as const; + + harness = new Harness(mountWithIntl()); + harness.togglePopover(); + + expect(harness.titleSelect.prop('value')).toBe('none'); + expect(harness.titleLabel.prop('disabled')).toBe(true); + expect(harness.titleLabel.prop('value')).toBe(''); + }); + it('labelMajor mode switches to custom when user starts typing', () => { + defaultProps.state.labelMajorMode = 'auto' as const; + + harness = new Harness(mountWithIntl()); + harness.togglePopover(); + + expect(harness.titleSelect.prop('value')).toBe('auto'); + expect(harness.titleLabel.prop('disabled')).toBe(false); + expect(harness.titleLabel.prop('value')).toBe(''); + harness.modifyTitle({ target: { value: 'labelMajor' } } as unknown as FormEvent); + expect(defaultProps.setState).toHaveBeenCalledTimes(1); + expect(defaultProps.setState).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + labelMajorMode: 'custom', + labelMajor: 'labelMajor', + }) + ); + }); + }); + describe('labelMinor', () => { + it('labelMinor label is enabled if labelMinor is string', () => { + defaultProps.state.labelMinor = 'labelMinor label'; + + harness = new Harness(mountWithIntl()); + harness.togglePopover(); + + expect(harness.subtitleSelect.prop('value')).toBe('custom'); + expect(harness.subtitleLabel.prop('disabled')).toBe(false); + expect(harness.subtitleLabel.prop('value')).toBe('labelMinor label'); + }); + it('labelMajor mode can switch to custom', () => { + defaultProps.state.labelMinor = ''; + + harness = new Harness(mountWithIntl()); + harness.togglePopover(); + + expect(harness.subtitleSelect.prop('value')).toBe('none'); + expect(harness.subtitleLabel.prop('disabled')).toBe(true); + expect(harness.subtitleLabel.prop('value')).toBe(''); + harness.modifySubtitle({ target: { value: 'labelMinor label' } } as unknown as FormEvent); + expect(defaultProps.setState).toHaveBeenCalledTimes(1); + expect(defaultProps.setState).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + labelMinor: 'labelMinor label', + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/index.tsx b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/index.tsx new file mode 100644 index 0000000000000..e907dc0529019 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/toolbar_component/index.tsx @@ -0,0 +1,99 @@ +/* + * 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, { memo, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { VisualizationToolbarProps } from '../../../types'; +import { ToolbarPopover, useDebouncedValue, VisLabel } from '../../../shared_components'; +import './gauge_config_panel.scss'; +import { GaugeLabelMajorMode, GaugeVisualizationState } from '../../../../common/expressions'; + +export const GaugeToolbar = memo((props: VisualizationToolbarProps) => { + const { state, setState, frame } = props; + const metricDimensionTitle = + state.layerId && + frame.activeData?.[state.layerId]?.columns.find((col) => col.id === state.metricAccessor)?.name; + + const [subtitleMode, setSubtitleMode] = useState(() => + state.labelMinor ? 'custom' : 'none' + ); + + const { inputValue, handleInputChange } = useDebouncedValue({ + onChange: setState, + value: state, + }); + + return ( + + + + { + setSubtitleMode(inputValue.labelMinor ? 'custom' : 'none'); + }} + title={i18n.translate('xpack.lens.gauge.appearanceLabel', { + defaultMessage: 'Appearance', + })} + type="visualOptions" + buttonDataTestSubj="lnsVisualOptionsButton" + panelClassName="lnsGaugeToolbar__popover" + > + + { + handleInputChange({ + ...inputValue, + labelMajor: value.label, + labelMajorMode: value.mode, + }); + }} + /> + + + { + handleInputChange({ + ...inputValue, + labelMinor: value.label, + }); + setSubtitleMode(value.mode); + }} + /> + + + + + + ); +}); diff --git a/x-pack/plugins/lens/public/visualizations/gauge/utils.test.ts b/x-pack/plugins/lens/public/visualizations/gauge/utils.test.ts new file mode 100644 index 0000000000000..8989c5fa46934 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/utils.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { transparentizePalettes } from './utils'; +import { chartPluginMock } from '../../../../../../src/plugins/charts/public/mocks'; + +const paletteServiceMock = chartPluginMock.createPaletteRegistry(); + +describe('transparentizePalettes', () => { + it('converts all colors to half-transparent', () => { + const newPalettes = transparentizePalettes(paletteServiceMock); + + const singlePalette = newPalettes.get('mocked'); + expect(singlePalette.getCategoricalColors(2)).toEqual(['#0000FF80', '#FFFF0080']); + expect( + singlePalette.getCategoricalColor([ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + ]) + ).toEqual('#0000FF80'); + + const firstPalette = newPalettes.getAll()[0]; + expect(firstPalette.getCategoricalColors(2)).toEqual(['#FF000080', '#00000080']); + expect( + firstPalette.getCategoricalColor([ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + ]) + ).toEqual('#00000080'); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/gauge/utils.ts b/x-pack/plugins/lens/public/visualizations/gauge/utils.ts new file mode 100644 index 0000000000000..ec6e52b01864b --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/utils.ts @@ -0,0 +1,113 @@ +/* + * 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 { scaleLinear } from 'd3-scale'; +import { + ChartColorConfiguration, + PaletteDefinition, + PaletteRegistry, + SeriesLayer, +} from 'src/plugins/charts/public'; +import { DatatableRow } from 'src/plugins/expressions'; +import Color from 'color'; +import type { GaugeVisualizationState } from '../../../common/expressions/gauge_chart'; + +type GaugeAccessors = 'maxAccessor' | 'minAccessor' | 'goalAccessor' | 'metricAccessor'; + +type GaugeAccessorsType = Pick; + +export const getValueFromAccessor = ( + accessorName: GaugeAccessors, + row?: DatatableRow, + state?: GaugeAccessorsType +) => { + if (row && state) { + const accessor = state[accessorName]; + const value = accessor && row[accessor]; + if (typeof value === 'number') { + return value; + } + if (value?.length) { + if (typeof value[value.length - 1] === 'number') { + return value[value.length - 1]; + } + } + } +}; + +export const getMaxValue = (row?: DatatableRow, state?: GaugeAccessorsType): number => { + const FALLBACK_VALUE = 100; + const currentValue = getValueFromAccessor('maxAccessor', row, state); + if (currentValue != null) { + return currentValue; + } + if (row && state) { + const { metricAccessor, goalAccessor } = state; + const metricValue = metricAccessor && row[metricAccessor]; + const goalValue = goalAccessor && row[goalAccessor]; + const minValue = getMinValue(row, state); + if (metricValue != null) { + const numberValues = [minValue, goalValue, metricValue].filter((v) => typeof v === 'number'); + const biggerValue = Math.max(...numberValues); + const nicelyRounded = scaleLinear().domain([minValue, biggerValue]).nice().ticks(4); + if (nicelyRounded.length > 2) { + const ticksDifference = Math.abs(nicelyRounded[0] - nicelyRounded[1]); + return nicelyRounded[nicelyRounded.length - 1] + ticksDifference; + } + return minValue === biggerValue ? biggerValue + 1 : biggerValue; + } + } + return FALLBACK_VALUE; +}; + +export const getMinValue = (row?: DatatableRow, state?: GaugeAccessorsType) => { + const currentValue = getValueFromAccessor('minAccessor', row, state); + if (currentValue != null) { + return currentValue; + } + const FALLBACK_VALUE = 0; + if (row && state) { + const { metricAccessor, maxAccessor } = state; + const metricValue = metricAccessor && row[metricAccessor]; + const maxValue = maxAccessor && row[maxAccessor]; + const numberValues = [metricValue, maxValue].filter((v) => typeof v === 'number'); + if (Math.min(...numberValues) <= 0) { + return Math.min(...numberValues) - 10; // TODO: TO THINK THROUGH + } + } + return FALLBACK_VALUE; +}; + +export const getGoalValue = (row?: DatatableRow, state?: GaugeVisualizationState) => { + const currentValue = getValueFromAccessor('goalAccessor', row, state); + if (currentValue != null) { + return currentValue; + } + const minValue = getMinValue(row, state); + const maxValue = getMaxValue(row, state); + return Math.round((maxValue - minValue) * 0.75 + minValue); +}; + +export const transparentizePalettes = (palettes: PaletteRegistry) => { + const addAlpha = (c: string | null) => (c ? new Color(c).hex() + `80` : `000000`); + const transparentizePalette = (palette: PaletteDefinition) => ({ + ...palette, + getCategoricalColor: ( + series: SeriesLayer[], + chartConfiguration?: ChartColorConfiguration, + state?: unknown + ) => addAlpha(palette.getCategoricalColor(series, chartConfiguration, state)), + getCategoricalColors: (size: number, state?: unknown): string[] => + palette.getCategoricalColors(size, state).map(addAlpha), + }); + + return { + ...palettes, + get: (name: string) => transparentizePalette(palettes.get(name)), + getAll: () => palettes.getAll().map((singlePalette) => transparentizePalette(singlePalette)), + }; +}; diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts new file mode 100644 index 0000000000000..e5e7f092db9ce --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.test.ts @@ -0,0 +1,590 @@ +/* + * 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 { getGaugeVisualization, isNumericDynamicMetric, isNumericMetric } from './visualization'; +import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; +import { GROUP_ID } from './constants'; +import type { DatasourcePublicAPI, Operation } from '../../types'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { CustomPaletteParams, layerTypes } from '../../../common'; +import { + EXPRESSION_GAUGE_NAME, + GaugeVisualizationState, +} from '../../../common/expressions/gauge_chart'; +import { PaletteOutput } from 'src/plugins/charts/common'; + +function exampleState(): GaugeVisualizationState { + return { + layerId: 'test-layer', + layerType: layerTypes.DATA, + labelMajorMode: 'auto', + ticksPosition: 'auto', + shape: 'horizontalBullet', + }; +} + +const paletteService = chartPluginMock.createPaletteRegistry(); + +describe('gauge', () => { + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + }); + + describe('#intialize', () => { + test('returns a default state', () => { + expect(getGaugeVisualization({ paletteService }).initialize(() => 'l1')).toEqual({ + layerId: 'l1', + layerType: layerTypes.DATA, + title: 'Empty Gauge chart', + shape: 'horizontalBullet', + labelMajorMode: 'auto', + ticksPosition: 'auto', + }); + }); + + test('returns persisted state', () => { + expect( + getGaugeVisualization({ paletteService }).initialize(() => 'test-layer', exampleState()) + ).toEqual(exampleState()); + }); + }); + + describe('#getConfiguration', () => { + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + afterEach(() => { + // some tests manipulate it, so restore a pristine version + frame = createMockFramePublicAPI(); + }); + + test('resolves configuration from complete state and available data', () => { + const state: GaugeVisualizationState = { + ...exampleState(), + layerId: 'first', + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + goalAccessor: 'goal-accessor', + }; + frame.activeData = { + first: { type: 'datatable', columns: [], rows: [{ 'metric-accessor': 200 }] }, + }; + + expect( + getGaugeVisualization({ + paletteService, + }).getConfiguration({ state, frame, layerId: 'first' }) + ).toEqual({ + groups: [ + { + layerId: 'first', + groupId: GROUP_ID.METRIC, + groupLabel: 'Metric', + accessors: [{ columnId: 'metric-accessor', triggerIcon: 'none' }], + filterOperations: isNumericDynamicMetric, + supportsMoreColumns: false, + required: true, + dataTestSubj: 'lnsGauge_metricDimensionPanel', + enableDimensionEditor: true, + supportFieldFormat: true, + }, + { + layerId: 'first', + groupId: GROUP_ID.MIN, + groupLabel: 'Minimum value', + accessors: [{ columnId: 'min-accessor' }], + filterOperations: isNumericMetric, + supportsMoreColumns: false, + dataTestSubj: 'lnsGauge_minDimensionPanel', + prioritizedOperation: 'min', + suggestedValue: expect.any(Function), + supportFieldFormat: false, + supportStaticValue: true, + }, + { + layerId: 'first', + groupId: GROUP_ID.MAX, + groupLabel: 'Maximum value', + accessors: [{ columnId: 'max-accessor' }], + filterOperations: isNumericMetric, + supportsMoreColumns: false, + dataTestSubj: 'lnsGauge_maxDimensionPanel', + prioritizedOperation: 'max', + suggestedValue: expect.any(Function), + supportFieldFormat: false, + supportStaticValue: true, + }, + { + layerId: 'first', + groupId: GROUP_ID.GOAL, + groupLabel: 'Goal value', + accessors: [{ columnId: 'goal-accessor' }], + filterOperations: isNumericMetric, + supportsMoreColumns: false, + required: false, + dataTestSubj: 'lnsGauge_goalDimensionPanel', + supportFieldFormat: false, + supportStaticValue: true, + }, + ], + }); + }); + + test('resolves configuration from partial state', () => { + const state: GaugeVisualizationState = { + ...exampleState(), + layerId: 'first', + minAccessor: 'min-accessor', + }; + expect( + getGaugeVisualization({ + paletteService, + }).getConfiguration({ state, frame, layerId: 'first' }) + ).toEqual({ + groups: [ + { + layerId: 'first', + groupId: GROUP_ID.METRIC, + groupLabel: 'Metric', + accessors: [], + filterOperations: isNumericDynamicMetric, + supportsMoreColumns: true, + required: true, + dataTestSubj: 'lnsGauge_metricDimensionPanel', + enableDimensionEditor: true, + supportFieldFormat: true, + }, + { + layerId: 'first', + groupId: GROUP_ID.MIN, + groupLabel: 'Minimum value', + accessors: [{ columnId: 'min-accessor' }], + filterOperations: isNumericMetric, + supportsMoreColumns: false, + dataTestSubj: 'lnsGauge_minDimensionPanel', + prioritizedOperation: 'min', + suggestedValue: expect.any(Function), + supportFieldFormat: false, + supportStaticValue: true, + }, + { + layerId: 'first', + groupId: GROUP_ID.MAX, + groupLabel: 'Maximum value', + accessors: [], + filterOperations: isNumericMetric, + supportsMoreColumns: true, + dataTestSubj: 'lnsGauge_maxDimensionPanel', + prioritizedOperation: 'max', + suggestedValue: expect.any(Function), + supportFieldFormat: false, + supportStaticValue: true, + }, + { + layerId: 'first', + groupId: GROUP_ID.GOAL, + groupLabel: 'Goal value', + accessors: [], + filterOperations: isNumericMetric, + supportsMoreColumns: true, + required: false, + dataTestSubj: 'lnsGauge_goalDimensionPanel', + supportFieldFormat: false, + supportStaticValue: true, + }, + ], + }); + }); + + test("resolves configuration when there's no access to active data in frame", () => { + const state: GaugeVisualizationState = { + ...exampleState(), + layerId: 'first', + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + goalAccessor: 'goal-accessor', + }; + + frame.activeData = undefined; + + expect( + getGaugeVisualization({ + paletteService, + }).getConfiguration({ state, frame, layerId: 'first' }) + ).toEqual({ + groups: [ + { + layerId: 'first', + groupId: GROUP_ID.METRIC, + groupLabel: 'Metric', + accessors: [{ columnId: 'metric-accessor', triggerIcon: 'none' }], + filterOperations: isNumericDynamicMetric, + supportsMoreColumns: false, + required: true, + dataTestSubj: 'lnsGauge_metricDimensionPanel', + enableDimensionEditor: true, + supportFieldFormat: true, + }, + { + layerId: 'first', + groupId: GROUP_ID.MIN, + groupLabel: 'Minimum value', + accessors: [{ columnId: 'min-accessor' }], + filterOperations: isNumericMetric, + supportsMoreColumns: false, + dataTestSubj: 'lnsGauge_minDimensionPanel', + prioritizedOperation: 'min', + suggestedValue: expect.any(Function), + supportFieldFormat: false, + supportStaticValue: true, + }, + { + layerId: 'first', + groupId: GROUP_ID.MAX, + groupLabel: 'Maximum value', + accessors: [{ columnId: 'max-accessor' }], + filterOperations: isNumericMetric, + supportsMoreColumns: false, + dataTestSubj: 'lnsGauge_maxDimensionPanel', + prioritizedOperation: 'max', + suggestedValue: expect.any(Function), + supportFieldFormat: false, + supportStaticValue: true, + }, + { + layerId: 'first', + groupId: GROUP_ID.GOAL, + groupLabel: 'Goal value', + accessors: [{ columnId: 'goal-accessor' }], + filterOperations: isNumericMetric, + supportsMoreColumns: false, + required: false, + dataTestSubj: 'lnsGauge_goalDimensionPanel', + supportFieldFormat: false, + supportStaticValue: true, + }, + ], + }); + }); + }); + + describe('#setDimension', () => { + test('set dimension correctly', () => { + const prevState: GaugeVisualizationState = { + ...exampleState(), + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + }; + expect( + getGaugeVisualization({ + paletteService, + }).setDimension({ + prevState, + layerId: 'first', + columnId: 'new-min-accessor', + groupId: 'min', + frame, + }) + ).toEqual({ + ...prevState, + minAccessor: 'new-min-accessor', + }); + }); + }); + + describe('#removeDimension', () => { + const prevState: GaugeVisualizationState = { + ...exampleState(), + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + palette: [] as unknown as PaletteOutput, + colorMode: 'palette', + ticksPosition: 'bands', + }; + test('removes metricAccessor correctly', () => { + expect( + getGaugeVisualization({ + paletteService, + }).removeDimension({ + prevState, + layerId: 'first', + columnId: 'metric-accessor', + frame, + }) + ).toEqual({ + ...exampleState(), + minAccessor: 'min-accessor', + }); + }); + test('removes minAccessor correctly', () => { + expect( + getGaugeVisualization({ + paletteService, + }).removeDimension({ + prevState, + layerId: 'first', + columnId: 'min-accessor', + frame, + }) + ).toEqual({ + ...exampleState(), + metricAccessor: 'metric-accessor', + palette: [] as unknown as PaletteOutput, + colorMode: 'palette', + ticksPosition: 'bands', + }); + }); + }); + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect( + getGaugeVisualization({ + paletteService, + }).getSupportedLayers() + ).toHaveLength(1); + }); + }); + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + const state: GaugeVisualizationState = { + ...exampleState(), + minAccessor: 'minAccessor', + goalAccessor: 'value-accessor', + }; + const instance = getGaugeVisualization({ + paletteService, + }); + expect(instance.getLayerType('test-layer', state)).toEqual(layerTypes.DATA); + expect(instance.getLayerType('foo', state)).toBeUndefined(); + }); + }); + + describe('#toExpression', () => { + let datasourceLayers: Record; + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + test('creates an expression based on state and attributes', () => { + const state: GaugeVisualizationState = { + ...exampleState(), + layerId: 'first', + minAccessor: 'min-accessor', + goalAccessor: 'goal-accessor', + metricAccessor: 'metric-accessor', + maxAccessor: 'max-accessor', + labelMinor: 'Subtitle', + }; + const attributes = { + title: 'Test', + }; + expect( + getGaugeVisualization({ + paletteService, + }).toExpression(state, datasourceLayers, attributes) + ).toEqual({ + type: 'expression', + chain: [ + { + type: 'function', + function: EXPRESSION_GAUGE_NAME, + arguments: { + title: ['Test'], + description: [''], + metricAccessor: ['metric-accessor'], + minAccessor: ['min-accessor'], + maxAccessor: ['max-accessor'], + goalAccessor: ['goal-accessor'], + colorMode: ['none'], + ticksPosition: ['auto'], + labelMajorMode: ['auto'], + labelMinor: ['Subtitle'], + labelMajor: [], + palette: [], + shape: ['horizontalBullet'], + }, + }, + ], + }); + }); + test('returns null with a missing metric accessor', () => { + const state: GaugeVisualizationState = { + ...exampleState(), + layerId: 'first', + minAccessor: 'minAccessor', + }; + const attributes = { + title: 'Test', + }; + expect( + getGaugeVisualization({ + paletteService, + }).toExpression(state, datasourceLayers, attributes) + ).toEqual(null); + }); + }); + + describe('#getErrorMessages', () => { + it('returns undefined if no error is raised', () => { + const error = getGaugeVisualization({ + paletteService, + }).getErrorMessages(exampleState()); + expect(error).not.toBeDefined(); + }); + }); + + describe('#getWarningMessages', () => { + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + label: 'MyOperation', + } as Operation); + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + const state: GaugeVisualizationState = { + ...exampleState(), + layerId: 'first', + metricAccessor: 'metric-accessor', + minAccessor: 'min-accessor', + maxAccessor: 'max-accessor', + goalAccessor: 'goal-accessor', + }; + it('should not warn for data in bounds', () => { + frame.activeData = { + first: { + type: 'datatable', + columns: [], + rows: [ + { + 'min-accessor': 0, + 'metric-accessor': 5, + 'max-accessor': 10, + 'goal-accessor': 8, + }, + ], + }, + }; + + expect( + getGaugeVisualization({ + paletteService, + }).getWarningMessages!(state, frame) + ).toHaveLength(0); + }); + it('should warn when minimum value is greater than metric value', () => { + frame.activeData = { + first: { + type: 'datatable', + columns: [], + rows: [ + { + 'metric-accessor': -1, + 'min-accessor': 1, + 'max-accessor': 3, + 'goal-accessor': 2, + }, + ], + }, + }; + + expect( + getGaugeVisualization({ + paletteService, + }).getWarningMessages!(state, frame) + ).toHaveLength(1); + }); + + it('should warn when metric value is greater than maximum value', () => { + frame.activeData = { + first: { + type: 'datatable', + columns: [], + rows: [ + { + 'metric-accessor': 10, + 'min-accessor': -10, + 'max-accessor': 0, + }, + ], + }, + }; + + expect( + getGaugeVisualization({ + paletteService, + }).getWarningMessages!(state, frame) + ).toHaveLength(1); + }); + it('should warn when goal value is greater than maximum value', () => { + frame.activeData = { + first: { + type: 'datatable', + columns: [], + rows: [ + { + 'metric-accessor': 5, + 'min-accessor': 0, + 'max-accessor': 10, + 'goal-accessor': 15, + }, + ], + }, + }; + + expect( + getGaugeVisualization({ + paletteService, + }).getWarningMessages!(state, frame) + ).toHaveLength(1); + }); + it('should warn when minimum value is greater than goal value', () => { + frame.activeData = { + first: { + type: 'datatable', + columns: [], + rows: [ + { + 'metric-accessor': 5, + 'min-accessor': 0, + 'max-accessor': 10, + 'goal-accessor': -5, + }, + ], + }, + }; + + expect( + getGaugeVisualization({ + paletteService, + }).getWarningMessages!(state, frame) + ).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx new file mode 100644 index 0000000000000..59dd2ac161a8a --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -0,0 +1,454 @@ +/* + * 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 { render } from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; +import { Ast } from '@kbn/interpreter/common'; +import { PaletteRegistry } from '../../../../../../src/plugins/charts/public'; +import type { DatasourcePublicAPI, OperationMetadata, Visualization } from '../../types'; +import { getSuggestions } from './suggestions'; +import { GROUP_ID, LENS_GAUGE_ID } from './constants'; +import { GaugeToolbar } from './toolbar_component'; +import { LensIconChartGaugeHorizontal, LensIconChartGaugeVertical } from '../../assets/chart_gauge'; +import { applyPaletteParams, CUSTOM_PALETTE, getStopsForFixedMode } from '../../shared_components'; +import { GaugeDimensionEditor } from './dimension_editor'; +import { CustomPaletteParams, layerTypes } from '../../../common'; +import { generateId } from '../../id_generator'; +import { getGoalValue, getMaxValue, getMinValue } from './utils'; + +import { + GaugeShapes, + GaugeArguments, + EXPRESSION_GAUGE_NAME, + GaugeVisualizationState, +} from '../../../common/expressions/gauge_chart'; + +const groupLabelForGauge = i18n.translate('xpack.lens.metric.groupLabel', { + defaultMessage: 'Goal and single value', +}); + +interface GaugeVisualizationDeps { + paletteService: PaletteRegistry; +} + +export const isNumericMetric = (op: OperationMetadata) => + !op.isBucketed && op.dataType === 'number'; + +export const isNumericDynamicMetric = (op: OperationMetadata) => + isNumericMetric(op) && !op.isStaticValue; + +export const CHART_NAMES = { + horizontalBullet: { + icon: LensIconChartGaugeHorizontal, + label: i18n.translate('xpack.lens.gaugeHorizontal.gaugeLabel', { + defaultMessage: 'Gauge horizontal', + }), + groupLabel: groupLabelForGauge, + }, + verticalBullet: { + icon: LensIconChartGaugeVertical, + label: i18n.translate('xpack.lens.gaugeVertical.gaugeLabel', { + defaultMessage: 'Gauge vertical', + }), + groupLabel: groupLabelForGauge, + }, +}; + +function computePaletteParams(params: CustomPaletteParams) { + return { + ...params, + // rewrite colors and stops as two distinct arguments + colors: (params?.stops || []).map(({ color }) => color), + stops: params?.name === 'custom' ? (params?.stops || []).map(({ stop }) => stop) : [], + reverse: false, // managed at UI level + }; +} + +const toExpression = ( + paletteService: PaletteRegistry, + state: GaugeVisualizationState, + datasourceLayers: Record, + attributes?: Partial> +): Ast | null => { + const datasource = datasourceLayers[state.layerId]; + + const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); + if (!originalOrder || !state.metricAccessor) { + return null; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: EXPRESSION_GAUGE_NAME, + arguments: { + title: [attributes?.title ?? ''], + description: [attributes?.description ?? ''], + metricAccessor: [state.metricAccessor ?? ''], + minAccessor: [state.minAccessor ?? ''], + maxAccessor: [state.maxAccessor ?? ''], + goalAccessor: [state.goalAccessor ?? ''], + shape: [state.shape ?? GaugeShapes.horizontalBullet], + colorMode: [state?.colorMode ?? 'none'], + palette: state.palette?.params + ? [ + paletteService + .get(CUSTOM_PALETTE) + .toExpression( + computePaletteParams((state.palette?.params || {}) as CustomPaletteParams) + ), + ] + : [], + ticksPosition: state.ticksPosition ? [state.ticksPosition] : ['auto'], + labelMinor: state.labelMinor ? [state.labelMinor] : [], + labelMajor: state.labelMajor ? [state.labelMajor] : [], + labelMajorMode: state.labelMajorMode ? [state.labelMajorMode] : ['auto'], + }, + }, + ], + }; +}; + +export const getGaugeVisualization = ({ + paletteService, +}: GaugeVisualizationDeps): Visualization => ({ + id: LENS_GAUGE_ID, + + visualizationTypes: [ + { + ...CHART_NAMES.horizontalBullet, + id: GaugeShapes.horizontalBullet, + showExperimentalBadge: true, + }, + { + ...CHART_NAMES.verticalBullet, + id: GaugeShapes.verticalBullet, + showExperimentalBadge: true, + }, + ], + getVisualizationTypeId(state) { + return state.shape; + }, + getLayerIds(state) { + return [state.layerId]; + }, + clearLayer(state) { + const newState = { ...state }; + delete newState.metricAccessor; + delete newState.minAccessor; + delete newState.maxAccessor; + delete newState.goalAccessor; + delete newState.palette; + delete newState.colorMode; + return newState; + }, + + getDescription(state) { + if (state.shape === GaugeShapes.horizontalBullet) { + return CHART_NAMES.horizontalBullet; + } + return CHART_NAMES.verticalBullet; + }, + + switchVisualizationType: (visualizationTypeId, state) => { + return { + ...state, + shape: + visualizationTypeId === GaugeShapes.horizontalBullet + ? GaugeShapes.horizontalBullet + : GaugeShapes.verticalBullet, + }; + }, + + initialize(addNewLayer, state, mainPalette) { + return ( + state || { + layerId: addNewLayer(), + layerType: layerTypes.DATA, + title: 'Empty Gauge chart', + shape: GaugeShapes.horizontalBullet, + palette: mainPalette, + ticksPosition: 'auto', + labelMajorMode: 'auto', + } + ); + }, + getSuggestions, + + getConfiguration({ state, frame }) { + const hasColoring = Boolean(state.colorMode !== 'none' && state.palette?.params?.stops); + + const row = state?.layerId ? frame?.activeData?.[state?.layerId]?.rows?.[0] : undefined; + let palette; + if (!(row == null || state?.metricAccessor == null || state?.palette == null || !hasColoring)) { + const currentMinMax = { min: getMinValue(row, state), max: getMaxValue(row, state) }; + + const displayStops = applyPaletteParams(paletteService, state?.palette, currentMinMax); + palette = getStopsForFixedMode(displayStops, state?.palette?.params?.colorStops); + } + + return { + groups: [ + { + supportFieldFormat: true, + layerId: state.layerId, + groupId: GROUP_ID.METRIC, + groupLabel: i18n.translate('xpack.lens.gauge.metricLabel', { + defaultMessage: 'Metric', + }), + accessors: state.metricAccessor + ? [ + palette + ? { + columnId: state.metricAccessor, + triggerIcon: 'colorBy', + palette, + } + : { + columnId: state.metricAccessor, + triggerIcon: 'none', + }, + ] + : [], + filterOperations: isNumericDynamicMetric, + supportsMoreColumns: !state.metricAccessor, + required: true, + dataTestSubj: 'lnsGauge_metricDimensionPanel', + enableDimensionEditor: true, + }, + { + supportStaticValue: true, + supportFieldFormat: false, + layerId: state.layerId, + groupId: GROUP_ID.MIN, + groupLabel: i18n.translate('xpack.lens.gauge.minValueLabel', { + defaultMessage: 'Minimum value', + }), + accessors: state.minAccessor ? [{ columnId: state.minAccessor }] : [], + filterOperations: isNumericMetric, + supportsMoreColumns: !state.minAccessor, + dataTestSubj: 'lnsGauge_minDimensionPanel', + prioritizedOperation: 'min', + suggestedValue: () => (state.metricAccessor ? getMinValue(row, state) : undefined), + }, + { + supportStaticValue: true, + supportFieldFormat: false, + layerId: state.layerId, + groupId: GROUP_ID.MAX, + groupLabel: i18n.translate('xpack.lens.gauge.maxValueLabel', { + defaultMessage: 'Maximum value', + }), + accessors: state.maxAccessor ? [{ columnId: state.maxAccessor }] : [], + filterOperations: isNumericMetric, + supportsMoreColumns: !state.maxAccessor, + dataTestSubj: 'lnsGauge_maxDimensionPanel', + prioritizedOperation: 'max', + suggestedValue: () => (state.metricAccessor ? getMaxValue(row, state) : undefined), + }, + { + supportStaticValue: true, + supportFieldFormat: false, + layerId: state.layerId, + groupId: GROUP_ID.GOAL, + groupLabel: i18n.translate('xpack.lens.gauge.goalValueLabel', { + defaultMessage: 'Goal value', + }), + accessors: state.goalAccessor ? [{ columnId: state.goalAccessor }] : [], + filterOperations: isNumericMetric, + supportsMoreColumns: !state.goalAccessor, + required: false, + dataTestSubj: 'lnsGauge_goalDimensionPanel', + }, + ], + }; + }, + + setDimension({ prevState, layerId, columnId, groupId, previousColumn }) { + const update: Partial = {}; + if (groupId === GROUP_ID.MIN) { + update.minAccessor = columnId; + } + if (groupId === GROUP_ID.MAX) { + update.maxAccessor = columnId; + } + if (groupId === GROUP_ID.GOAL) { + update.goalAccessor = columnId; + } + if (groupId === GROUP_ID.METRIC) { + update.metricAccessor = columnId; + } + return { + ...prevState, + ...update, + }; + }, + + removeDimension({ prevState, layerId, columnId }) { + const update = { ...prevState }; + + if (prevState.goalAccessor === columnId) { + delete update.goalAccessor; + } + if (prevState.minAccessor === columnId) { + delete update.minAccessor; + } + if (prevState.maxAccessor === columnId) { + delete update.maxAccessor; + } + if (prevState.metricAccessor === columnId) { + delete update.metricAccessor; + delete update.palette; + delete update.colorMode; + update.ticksPosition = 'auto'; + } + + return update; + }, + + renderDimensionEditor(domElement, props) { + render( + + + , + domElement + ); + }, + + renderToolbar(domElement, props) { + render( + + + , + domElement + ); + }, + + getSupportedLayers(state, frame) { + const row = state?.layerId ? frame?.activeData?.[state?.layerId]?.rows?.[0] : undefined; + + const minAccessorValue = getMinValue(row, state); + const maxAccessorValue = getMaxValue(row, state); + const goalAccessorValue = getGoalValue(row, state); + + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.gauge.addLayer', { + defaultMessage: 'Add visualization layer', + }), + initialDimensions: state + ? [ + { + groupId: 'min', + columnId: generateId(), + dataType: 'number', + label: 'minAccessor', + staticValue: minAccessorValue, + }, + { + groupId: 'max', + columnId: generateId(), + dataType: 'number', + label: 'maxAccessor', + staticValue: maxAccessorValue, + }, + { + groupId: 'goal', + columnId: generateId(), + dataType: 'number', + label: 'goalAccessor', + staticValue: goalAccessorValue, + }, + ] + : undefined, + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return state.layerType; + } + }, + + toExpression: (state, datasourceLayers, attributes) => + toExpression(paletteService, state, datasourceLayers, { ...attributes }), + toPreviewExpression: (state, datasourceLayers) => + toExpression(paletteService, state, datasourceLayers), + + getErrorMessages(state) { + // not possible to break it? + return undefined; + }, + + getWarningMessages(state, frame) { + const { maxAccessor, minAccessor, goalAccessor, metricAccessor } = state; + if (!maxAccessor && !minAccessor && !goalAccessor && !metricAccessor) { + // nothing configured yet + return; + } + if (!metricAccessor) { + return []; + } + + const row = frame?.activeData?.[state.layerId]?.rows?.[0]; + if (!row) { + return []; + } + const metricValue = row[metricAccessor]; + const maxValue = maxAccessor && row[maxAccessor]; + const minValue = minAccessor && row[minAccessor]; + const goalValue = goalAccessor && row[goalAccessor]; + + const warnings = []; + if (typeof minValue === 'number') { + if (minValue > metricValue) { + warnings.push([ + , + ]); + } + if (minValue > goalValue) { + warnings.push([ + , + ]); + } + } + + if (typeof maxValue === 'number') { + if (metricValue > maxValue) { + warnings.push([ + , + ]); + } + + if (typeof goalValue === 'number' && goalValue > maxValue) { + warnings.push([ + , + ]); + } + } + + return warnings; + }, +}); 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 bb3b5bfcbfec6..65425b04129d3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -45,7 +45,7 @@ import { shallow } from 'enzyme'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { mountWithIntl } from '@kbn/test/jest'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; -import { EmptyPlaceholder } from '../shared_components/empty_placeholder'; +import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { XyEndzones } from './x_domain'; const onClickValue = jest.fn(); diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index d22a8034cdf2b..01359c68c6da3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -44,6 +44,7 @@ import { IconType } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RenderMode } from 'src/plugins/expressions'; import { ThemeServiceStart } from 'kibana/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'; @@ -61,7 +62,6 @@ import { useActiveCursor, } from '../../../../../src/plugins/charts/public'; import { MULTILAYER_TIME_AXIS_STYLE } from '../../../../../src/plugins/charts/common'; -import { EmptyPlaceholder } from '../shared_components'; import { getFitOptions } from './fitting_functions'; import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axes_configuration'; import { getColorAssignments } from './color_assignment'; diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index d536a18b6ab79..8330acf28264c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -46,7 +46,9 @@ import { groupAxesByType } from './axes_configuration'; const defaultIcon = LensIconChartBarStacked; const defaultSeriesType = 'bar_stacked'; + const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; +const isNumericDynamicMetric = (op: OperationMetadata) => isNumericMetric(op) && !op.isStaticValue; const isBucketed = (op: OperationMetadata) => op.isBucketed; function getVisualizationType(state: State): VisualizationType | 'mixed' { @@ -438,7 +440,7 @@ export const getXyVisualization = ({ groupId: 'y', groupLabel: getAxisName('y', { isHorizontal }), accessors: mappedAccessors, - filterOperations: isNumericMetric, + filterOperations: isNumericDynamicMetric, supportsMoreColumns: true, required: true, dataTestSubj: 'lnsXY_yDimensionPanel', diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index d7b48553ce73a..89714dff04a62 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -38,6 +38,18 @@ describe('xy_suggestions', () => { }; } + function staticValueCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'number', + label: `Static value: ${columnId}`, + isBucketed: false, + isStaticValue: true, + }, + }; + } + function strCol(columnId: string): TableSuggestionColumn { return { columnId, @@ -120,6 +132,21 @@ describe('xy_suggestions', () => { ); }); + test('rejects the configuration when metric isStaticValue', () => { + (generateId as jest.Mock).mockReturnValueOnce('aaa'); + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [staticValueCol('value'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: [], + }); + + expect(suggestions).toHaveLength(0); + }); + test('rejects incomplete configurations if there is a state already but no sub visualization id', () => { expect( ( 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 2e275c455a4d0..0fa07f4f9ebb6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -69,7 +69,10 @@ export function getSuggestions({ }); } - if (incompleteTable && state && !subVisualizationId) { + if ( + (incompleteTable && state && !subVisualizationId) || + table.columns.some((col) => col.operation.isStaticValue) + ) { // reject incomplete configurations if the sub visualization isn't specifically requested // this allows to switch chart types via switcher with incomplete configurations, but won't // cause incomplete suggestions getting auto applied on dropped fields diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e1cbbe99a2e8b..fb16a36997f0b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -14720,7 +14720,6 @@ "xpack.lens.xyVisualization.lineLabel": "折れ線", "xpack.lens.xyVisualization.mixedBarHorizontalLabel": "混在した横棒", "xpack.lens.xyVisualization.mixedLabel": "ミックスされた XY", - "xpack.lens.xyVisualization.noDataLabel": "結果が見つかりませんでした", "xpack.lens.xyVisualization.stackedAreaLabel": "面積み上げ", "xpack.lens.xyVisualization.stackedBarHorizontalFullLabel": "積み上げ横棒", "xpack.lens.xyVisualization.stackedBarHorizontalLabel": "H.積み上げ棒", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6f49e98d1991d..0473373536c2c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -14914,7 +14914,6 @@ "xpack.lens.xyVisualization.lineLabel": "折线图", "xpack.lens.xyVisualization.mixedBarHorizontalLabel": "水平混合条形图", "xpack.lens.xyVisualization.mixedLabel": "混合 XY", - "xpack.lens.xyVisualization.noDataLabel": "找不到结果", "xpack.lens.xyVisualization.stackedAreaLabel": "堆积面积图", "xpack.lens.xyVisualization.stackedBarHorizontalFullLabel": "水平堆叠条形图", "xpack.lens.xyVisualization.stackedBarHorizontalLabel": "水平堆叠条形图", diff --git a/x-pack/test/functional/apps/lens/gauge.ts b/x-pack/test/functional/apps/lens/gauge.ts new file mode 100644 index 0000000000000..fd81bad258280 --- /dev/null +++ b/x-pack/test/functional/apps/lens/gauge.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const elasticChart = getService('elasticChart'); + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const retry = getService('retry'); + + describe('lens gauge', () => { + before(async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await elasticChart.setNewChartUiDebugFlag(true); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'ip', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.waitForVisualization(); + }); + + it('should switch to gauge and render a gauge with default values', async () => { + await PageObjects.lens.switchToVisualization('horizontalBullet', 'gauge'); + await PageObjects.lens.waitForVisualization(); + const elementWithInfo = await find.byCssSelector('.echScreenReaderOnly'); + const textContent = await elementWithInfo.getAttribute('textContent'); + expect(textContent).to.contain('Average of bytes'); // it gets default title + expect(textContent).to.contain('horizontalBullet chart'); + expect(textContent).to.contain('Minimum:0'); // it gets default minimum static value + expect(textContent).to.contain('Maximum:8000'); // it gets default maximum static value + expect(textContent).to.contain('Value:5727.32'); + }); + + it('should reflect edits for gauge', async () => { + await PageObjects.lens.openVisualOptions(); + await retry.try(async () => { + await testSubjects.setValue('lnsToolbarGaugeLabelMajor', 'custom title'); + }); + await retry.try(async () => { + await testSubjects.setValue('lnsToolbarGaugeLabelMinor-select', 'custom'); + }); + await retry.try(async () => { + await testSubjects.setValue('lnsToolbarGaugeLabelMinor', 'custom subtitle'); + }); + + await PageObjects.lens.waitForVisualization(); + await PageObjects.lens.configureDimension({ + dimension: 'lnsGauge_metricDimensionPanel > lns-dimensionTrigger', + operation: 'count', + isPreviousIncompatible: true, + keepOpen: true, + }); + + await testSubjects.setEuiSwitch('lnsDynamicColoringGaugeSwitch', 'check'); + await PageObjects.lens.closeDimensionEditor(); + + await PageObjects.lens.openDimensionEditor( + 'lnsGauge_goalDimensionPanel > lns-empty-dimension' + ); + + await PageObjects.lens.waitForVisualization(); + await PageObjects.lens.closeDimensionEditor(); + await PageObjects.lens.openDimensionEditor( + 'lnsGauge_minDimensionPanel > lns-empty-dimension-suggested-value' + ); + + await testSubjects.setValue('lns-indexPattern-static_value-input', '1000', { + clearWithKeyboard: true, + }); + await PageObjects.lens.waitForVisualization(); + await PageObjects.lens.closeDimensionEditor(); + + await PageObjects.lens.openDimensionEditor( + 'lnsGauge_maxDimensionPanel > lns-empty-dimension-suggested-value' + ); + + await testSubjects.setValue('lns-indexPattern-static_value-input', '25000', { + clearWithKeyboard: true, + }); + await PageObjects.lens.waitForVisualization(); + await PageObjects.lens.closeDimensionEditor(); + + const elementWithInfo = await find.byCssSelector('.echScreenReaderOnly'); + const textContent = await elementWithInfo.getAttribute('textContent'); + expect(textContent).to.contain('custom title'); + expect(textContent).to.contain('custom subtitle'); + expect(textContent).to.contain('horizontalBullet chart'); + expect(textContent).to.contain('Minimum:1000'); + expect(textContent).to.contain('Maximum:25000'); + expect(textContent).to.contain('Target:15000'); + expect(textContent).to.contain('Value:14005'); + }); + it('should seamlessly switch to vertical chart without losing configuration', async () => { + await PageObjects.lens.switchToVisualization('verticalBullet', 'gauge'); + const elementWithInfo = await find.byCssSelector('.echScreenReaderOnly'); + const textContent = await elementWithInfo.getAttribute('textContent'); + expect(textContent).to.contain('custom title'); + expect(textContent).to.contain('custom subtitle'); + expect(textContent).to.contain('verticalBullet chart'); + expect(textContent).to.contain('Minimum:1000'); + expect(textContent).to.contain('Maximum:25000'); + expect(textContent).to.contain('Target:15000'); + expect(textContent).to.contain('Value:14005'); + }); + it('should switch to table chart and filter not supported static values', async () => { + await PageObjects.lens.switchToVisualization('lnsDatatable'); + const columnsCount = await PageObjects.lens.getCountOfDatatableColumns(); + expect(columnsCount).to.eql(1); + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal('Count of records'); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 24bb1440af622..8d1363b30c43f 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -67,6 +67,7 @@ export default function ({ getService, loadTestFile, getPageObjects }: FtrProvid loadTestFile(require.resolve('./geo_field')); loadTestFile(require.resolve('./formula')); loadTestFile(require.resolve('./heatmap')); + loadTestFile(require.resolve('./gauge')); loadTestFile(require.resolve('./metrics')); loadTestFile(require.resolve('./reference_lines')); loadTestFile(require.resolve('./inspector')); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 78b9762e3889a..77b420804737e 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -807,6 +807,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, {}); }, + async getCountOfDatatableColumns() { + const table = await find.byCssSelector('.euiDataGrid'); + const $ = await table.parseDomContent(); + return (await $('.euiDataGridHeaderCell__content')).length; + }, + async getDatatableHeader(index = 0) { log.debug(`All headers ${await testSubjects.getVisibleText('dataGridHeader')}`); return find.byCssSelector( @@ -817,9 +823,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, async getDatatableCell(rowIndex = 0, colIndex = 0) { - const table = await find.byCssSelector('.euiDataGrid'); - const $ = await table.parseDomContent(); - const columnNumber = $('.euiDataGridHeaderCell__content').length; + const columnNumber = await this.getCountOfDatatableColumns(); return await find.byCssSelector( `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRowCell"]:nth-child(${ rowIndex * columnNumber + colIndex + 2