From 42d396627e0eae74fada9483aafc44207b0b6a52 Mon Sep 17 00:00:00 2001 From: Andrew Tate Date: Tue, 26 Jul 2022 14:18:20 -0500 Subject: [PATCH] [Lens] add new metric visualization (#136567) --- packages/kbn-optimizer/limits.yml | 2 +- .../metric_vis_function.ts | 36 +- .../common/types/expression_functions.ts | 5 +- .../common/types/expression_renderers.ts | 5 +- .../__snapshots__/metric_vis.test.tsx.snap | 81 +++ .../public/components/metric_vis.test.tsx | 561 ++++++++++++---- .../public/components/metric_vis.tsx | 206 ++++-- .../metric_vis_renderer.tsx | 56 +- .../expression_metric/public/index.ts | 2 + .../expression_metric/public/utils/index.ts | 4 +- .../expression_metric/public/utils/palette.ts | 41 -- .../public/utils/palette_data_bounds.ts | 37 + src/plugins/embeddable/public/index.ts | 1 + src/plugins/embeddable/public/lib/index.ts | 1 + .../lib/panel/embeddable_panel.test.tsx | 46 ++ .../public/lib/panel/embeddable_panel.tsx | 8 +- .../lib/self_styled_embeddable/index.ts} | 15 +- .../lib/self_styled_embeddable/types.ts | 27 + src/plugins/embeddable/public/mocks.tsx | 12 + .../expressions/collapse/collapse_fn.test.ts | 55 ++ .../expressions/collapse/collapse_fn.ts | 27 +- .../lens/common/expressions/collapse/index.ts | 4 +- x-pack/plugins/lens/kibana.json | 10 +- .../app_plugin/show_underlying_data.test.ts | 3 + x-pack/plugins/lens/public/async_services.ts | 2 + .../editor_frame/editor_frame.test.tsx | 1 + .../editor_frame/suggestion_helpers.test.ts | 3 + .../lens/public/embeddable/embeddable.tsx | 24 +- .../public/embeddable/expression_wrapper.tsx | 4 +- .../indexpattern.test.ts | 19 + .../indexpattern_datasource/indexpattern.tsx | 10 + .../definitions/filters/filters.test.tsx | 8 + .../definitions/filters/filters.tsx | 2 + .../operations/definitions/index.ts | 7 + .../operations/definitions/terms/index.tsx | 1 + .../definitions/terms/terms.test.tsx | 17 + .../metric_visualization/dimension_editor.tsx | 10 +- .../lens/public/metric_visualization/index.ts | 10 +- .../metric_config_panel/align_options.tsx | 8 +- .../metric_config_panel/size_options.tsx | 12 +- .../text_formatting_options.tsx | 2 +- .../title_position_option.tsx | 8 +- .../metric_suggestions.ts | 4 +- .../visualization.test.ts | 4 +- .../metric_visualization/visualization.tsx | 27 +- .../lens/public/mocks/datasource_mock.ts | 1 + x-pack/plugins/lens/public/plugin.ts | 11 +- x-pack/plugins/lens/public/types.ts | 17 + .../lens/public/visualization_container.scss | 2 +- .../__snapshots__/visualization.test.ts.snap | 113 ++++ .../public/visualizations/metric/constants.ts | 15 + .../metric/dimension_editor.test.tsx | 397 +++++++++++ .../metric/dimension_editor.tsx | 405 +++++++++++ .../public/visualizations/metric/index.ts | 26 + .../metric/metric_visualization.ts | 8 + .../visualizations/metric/palette_config.tsx | 35 + .../visualizations/metric/suggestions.test.ts | 359 ++++++++++ .../visualizations/metric/suggestions.ts | 87 +++ .../visualizations/metric/toolbar.test.tsx | 119 ++++ .../public/visualizations/metric/toolbar.tsx | 61 ++ .../metric/visualization.test.ts | 635 ++++++++++++++++++ .../visualizations/metric/visualization.tsx | 414 ++++++++++++ x-pack/plugins/lens/tsconfig.json | 1 + .../translations/translations/fr-FR.json | 17 - .../translations/translations/ja-JP.json | 17 - .../translations/translations/zh-CN.json | 18 - x-pack/test/examples/screenshotting/index.ts | 2 +- 67 files changed, 3818 insertions(+), 370 deletions(-) create mode 100644 src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_vis.test.tsx.snap delete mode 100644 src/plugins/chart_expressions/expression_metric/public/utils/palette.ts create mode 100644 src/plugins/chart_expressions/expression_metric/public/utils/palette_data_bounds.ts rename src/plugins/{chart_expressions/expression_metric/public/utils/format.ts => embeddable/public/lib/self_styled_embeddable/index.ts} (50%) create mode 100644 src/plugins/embeddable/public/lib/self_styled_embeddable/types.ts create mode 100644 x-pack/plugins/lens/public/visualizations/metric/__snapshots__/visualization.test.ts.snap create mode 100644 x-pack/plugins/lens/public/visualizations/metric/constants.ts create mode 100644 x-pack/plugins/lens/public/visualizations/metric/dimension_editor.test.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/metric/dimension_editor.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/metric/index.ts create mode 100644 x-pack/plugins/lens/public/visualizations/metric/metric_visualization.ts create mode 100644 x-pack/plugins/lens/public/visualizations/metric/palette_config.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/metric/suggestions.test.ts create mode 100644 x-pack/plugins/lens/public/visualizations/metric/suggestions.ts create mode 100644 x-pack/plugins/lens/public/visualizations/metric/toolbar.test.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/metric/toolbar.tsx create mode 100644 x-pack/plugins/lens/public/visualizations/metric/visualization.test.ts create mode 100644 x-pack/plugins/lens/public/visualizations/metric/visualization.tsx diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 1044cae862fe0..955ec123c1626 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -32,7 +32,7 @@ pageLoadAssetSize: inputControlVis: 172675 inspector: 148711 kibanaOverview: 56279 - lens: 35000 + lens: 36000 licenseManagement: 41817 licensing: 29004 lists: 22900 diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index b7b3426f5e132..d01a2400e038a 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts @@ -31,6 +31,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ help: i18n.translate('expressionMetricVis.function.metric.help', { defaultMessage: 'The primary metric.', }), + required: true, }, secondaryMetric: { types: ['vis_dimension', 'string'], @@ -38,6 +39,12 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ defaultMessage: 'The secondary metric (shown above the primary).', }), }, + max: { + types: ['vis_dimension', 'string'], + help: i18n.translate('expressionMetricVis.function.max.help.', { + defaultMessage: 'The dimension containing the maximum value.', + }), + }, breakdownBy: { types: ['vis_dimension', 'string'], help: i18n.translate('expressionMetricVis.function.breakdownBy.help', { @@ -50,16 +57,10 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ defaultMessage: 'The subtitle for a single metric. Overridden if breakdownBy is supplied.', }), }, - extraText: { + secondaryPrefix: { types: ['string'], - help: i18n.translate('expressionMetricVis.function.extra.help', { - defaultMessage: 'Text to be shown above metric value. Overridden by secondaryMetric.', - }), - }, - progressMax: { - types: ['vis_dimension', 'string'], - help: i18n.translate('expressionMetricVis.function.progressMax.help.', { - defaultMessage: 'The dimension containing the maximum value.', + help: i18n.translate('expressionMetricVis.function.secondaryPrefix.help', { + defaultMessage: 'Optional text to be show before secondaryMetric.', }), }, progressDirection: { @@ -71,6 +72,12 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ }), strict: true, }, + color: { + types: ['string'], + help: i18n.translate('expressionMetricVis.function.color.help', { + defaultMessage: 'Provides a static visualization color. Overridden by palette.', + }), + }, palette: { types: ['palette'], help: i18n.translate('expressionMetricVis.function.palette.help', { @@ -79,7 +86,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ }, maxCols: { types: ['number'], - help: i18n.translate('expressionMetricVis.function.maxCols.help', { + help: i18n.translate('expressionMetricVis.function.numCols.help', { defaultMessage: 'Specifies the max number of columns in the metric grid.', }), default: 5, @@ -128,9 +135,9 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ ]); } - if (args.progressMax) { + if (args.max) { argsTable.push([ - [args.progressMax], + [args.max], i18n.translate('expressionMetricVis.function.dimension.maximum', { defaultMessage: 'Maximum', }), @@ -150,7 +157,8 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ visConfig: { metric: { subtitle: args.subtitle, - extraText: args.extraText, + secondaryPrefix: args.secondaryPrefix, + color: args.color, palette: args.palette?.params, progressDirection: args.progressDirection, maxCols: args.maxCols, @@ -159,8 +167,8 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ dimensions: { metric: args.metric, secondaryMetric: args.secondaryMetric, + max: args.max, breakdownBy: args.breakdownBy, - progressMax: args.progressMax, }, }, }, diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts index bc383569a1c01..d44b34fa736d1 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts @@ -21,11 +21,12 @@ import { EXPRESSION_METRIC_NAME } from '../constants'; export interface MetricArguments { metric: ExpressionValueVisDimension | string; secondaryMetric?: ExpressionValueVisDimension | string; + max?: ExpressionValueVisDimension | string; breakdownBy?: ExpressionValueVisDimension | string; subtitle?: string; - extraText?: string; - progressMax?: ExpressionValueVisDimension | string; + secondaryPrefix?: string; progressDirection: LayoutDirection; + color?: string; palette?: PaletteOutput; maxCols: number; minTiles?: number; diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts index 20be978b55684..75144d1cf5525 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts @@ -15,13 +15,14 @@ export const visType = 'metric'; export interface DimensionsVisParam { metric: ExpressionValueVisDimension | string; secondaryMetric?: ExpressionValueVisDimension | string; + max?: ExpressionValueVisDimension | string; breakdownBy?: ExpressionValueVisDimension | string; - progressMax?: ExpressionValueVisDimension | string; } export interface MetricVisParam { subtitle?: string; - extraText?: string; + secondaryPrefix?: string; + color?: string; palette?: CustomPaletteState; progressDirection: LayoutDirection; maxCols: number; diff --git a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_vis.test.tsx.snap b/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_vis.test.tsx.snap new file mode 100644 index 0000000000000..0f4130c238b74 --- /dev/null +++ b/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_vis.test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MetricVisComponent coloring by palette percent-based should set correct data bounds with breakdown-by and max dimension 1`] = ` +Array [ + Object { + "max": 28.984375, + "min": 0, + "value": 13.6328125, + }, + Object { + "max": 28.984375, + "min": 0, + "value": 13.639539930555555, + }, + Object { + "max": 25.984375, + "min": 0, + "value": 13.34375, + }, + Object { + "max": 25.784375, + "min": 0, + "value": 13.4921875, + }, + Object { + "max": 25.348011363636363, + "min": 0, + "value": 13.34375, + }, + Object { + "max": 24.984375, + "min": 0, + "value": 13.242513020833334, + }, +] +`; + +exports[`MetricVisComponent coloring by palette percent-based should set correct data bounds with just breakdown-by dimension 1`] = ` +Array [ + Object { + "max": 13.639539930555555, + "min": 13.242513020833334, + "value": 13.6328125, + }, + Object { + "max": 13.639539930555555, + "min": 13.242513020833334, + "value": 13.639539930555555, + }, + Object { + "max": 13.639539930555555, + "min": 13.242513020833334, + "value": 13.34375, + }, + Object { + "max": 13.639539930555555, + "min": 13.242513020833334, + "value": 13.4921875, + }, + Object { + "max": 13.639539930555555, + "min": 13.242513020833334, + "value": 13.34375, + }, + Object { + "max": 13.639539930555555, + "min": 13.242513020833334, + "value": 13.242513020833334, + }, +] +`; + +exports[`MetricVisComponent coloring by palette percent-based should set correct data bounds with just max dimension 1`] = ` +Array [ + Object { + "max": 28.984375, + "min": 0, + "value": 13.6328125, + }, +] +`; diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx index 4e70f7d75146a..7ecc379b2abc6 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx @@ -9,15 +9,29 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Datatable } from '@kbn/expressions-plugin/common'; -import MetricVis, { MetricVisComponentProps } from './metric_vis'; -import { LayoutDirection, Metric, MetricWProgress, Settings } from '@elastic/charts'; +import { MetricVis, MetricVisComponentProps } from './metric_vis'; +import { + LayoutDirection, + Metric, + MetricElementEvent, + MetricWProgress, + Settings, +} from '@elastic/charts'; import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; import { SerializableRecord } from '@kbn/utility-types'; import numeral from '@elastic/numeral'; - -const mockDeserialize = jest.fn(() => ({ - getConverterFor: jest.fn(() => () => 'formatted duration'), -})); +import { HtmlAttributes } from 'csstype'; +import { CustomPaletteState } from '@kbn/charts-plugin/common/expressions/palette/types'; +import { DimensionsVisParam } from '../../common'; +import { euiThemeVars } from '@kbn/ui-theme'; + +const mockDeserialize = jest.fn((params) => { + const converter = + params.id === 'terms' + ? (val: string) => (val === '__other__' ? 'Other' : val) + : () => 'formatted duration'; + return { getConverterFor: jest.fn(() => converter) }; +}); const mockGetColorForValue = jest.fn(() => undefined); @@ -186,14 +200,25 @@ const table: Datatable = { [minPriceColumnId]: 13.34375, }, { - [dayOfWeekColumnId]: 'Monday', + [dayOfWeekColumnId]: '__other__', [basePriceColumnId]: 24.984375, [minPriceColumnId]: 13.242513020833334, }, ], }; +const defaultProps = { + renderComplete: () => {}, + fireEvent: () => {}, + filterable: true, + renderMode: 'view', +} as Pick; + describe('MetricVisComponent', function () { + afterEach(() => { + mockDeserialize.mockClear(); + }); + describe('single metric', () => { const config: Props['config'] = { metric: { @@ -206,9 +231,7 @@ describe('MetricVisComponent', function () { }; it('should render a single metric value', () => { - const component = shallow( - {}} /> - ); + const component = shallow(); const { data } = component.find(Metric).props(); @@ -219,7 +242,7 @@ describe('MetricVisComponent', function () { expect(visConfig).toMatchInlineSnapshot(` Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": undefined, "title": "Median products.base_price", @@ -228,29 +251,26 @@ describe('MetricVisComponent', function () { } `); }); - it('should display subtitle and extra text', () => { + it('should display subtitle and secondary prefix', () => { const component = shallow( {}} + {...defaultProps} /> ); const [[visConfig]] = component.find(Metric).props().data!; expect(visConfig!.subtitle).toBe('subtitle'); - expect(visConfig!.extra).toEqual(extra text); expect(visConfig).toMatchInlineSnapshot(` Object { - "color": "#343741", - "extra": - extra text - , + "color": "#f5f7fa", + "extra": , "subtitle": "subtitle", "title": "Median products.base_price", "value": 28.984375, @@ -263,27 +283,31 @@ describe('MetricVisComponent', function () { {}} + {...defaultProps} /> ); const [[visConfig]] = component.find(Metric).props().data!; - // overrides subtitle and extra text - expect(visConfig!.subtitle).toBe(table.columns[2].name); - expect(visConfig!.extra).toEqual(13.63); + expect(visConfig!.extra).toEqual( + + {'secondary prefix'} + {' ' + 13.63} + + ); expect(visConfig).toMatchInlineSnapshot(` Object { - "color": "#343741", + "color": "#f5f7fa", "extra": - 13.63 + secondary prefix + 13.63 , - "subtitle": "Median products.min_price", + "subtitle": "subtitle", "title": "Median products.base_price", "value": 28.984375, "valueFormatter": [Function], @@ -303,11 +327,11 @@ describe('MetricVisComponent', function () { }, dimensions: { ...config.dimensions, - progressMax: max, + max, }, }} data={table} - renderComplete={() => {}} + {...defaultProps} /> ) .find(Metric) @@ -326,7 +350,7 @@ describe('MetricVisComponent', function () { expect(configWithProgress).toMatchInlineSnapshot(` Object { - "color": "#343741", + "color": "#f5f7fa", "domainMax": 28.984375, "extra": , "progressBarDirection": "vertical", @@ -341,56 +365,6 @@ describe('MetricVisComponent', function () { (getConfig(basePriceColumnId, 'horizontal') as MetricWProgress).progressBarDirection ).toBe('horizontal'); }); - - it('should fetch color from palette if provided', () => { - const colorFromPalette = 'color-from-palette'; - - mockGetColorForValue.mockReturnValue(colorFromPalette); - - const component = shallow( - {}} - /> - ); - - const [[datum]] = component.find(Metric).props().data!; - - expect(datum!.color).toBe(colorFromPalette); - expect(mockGetColorForValue.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - 28.984375, - Object { - "colors": Array [], - "gradient": true, - "range": "number", - "rangeMax": 10, - "rangeMin": 2, - "stops": Array [], - }, - Object { - "max": 10, - "min": 2, - }, - ], - ] - `); - }); }); describe('metric grid', () => { @@ -406,9 +380,7 @@ describe('MetricVisComponent', function () { }; it('should render a grid if breakdownBy dimension supplied', () => { - const component = shallow( - {}} /> - ); + const component = shallow(); const { data } = component.find(Metric).props(); @@ -420,7 +392,7 @@ describe('MetricVisComponent', function () { expect(visConfig).toMatchInlineSnapshot(` Array [ Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Friday", @@ -428,7 +400,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Wednesday", @@ -436,7 +408,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Saturday", @@ -444,7 +416,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Sunday", @@ -452,7 +424,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Thursday", @@ -463,17 +435,17 @@ describe('MetricVisComponent', function () { `); }); - it('should display extra text or secondary metric', () => { + it('should display secondary prefix or secondary metric', () => { const componentWithSecondaryDimension = shallow( {}} + {...defaultProps} /> ); @@ -485,19 +457,24 @@ describe('MetricVisComponent', function () { ).toMatchInlineSnapshot(` Array [ - 13.63 + howdy + 13.63 , - 13.64 + howdy + 13.64 , - 13.34 + howdy + 13.34 , - 13.49 + howdy + 13.49 , - 13.34 + howdy + 13.34 , ] `); @@ -506,10 +483,10 @@ describe('MetricVisComponent', function () { {}} + {...defaultProps} /> ); @@ -552,7 +529,7 @@ describe('MetricVisComponent', function () { }, }} data={table} - renderComplete={() => {}} + {...defaultProps} /> ) .find(Metric) @@ -575,7 +552,7 @@ describe('MetricVisComponent', function () { Array [ Array [ Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Friday", @@ -583,7 +560,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Wednesday", @@ -591,7 +568,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Saturday", @@ -599,7 +576,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Sunday", @@ -607,7 +584,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", "title": "Thursday", @@ -617,10 +594,10 @@ describe('MetricVisComponent', function () { ], Array [ Object { - "color": "#343741", + "color": "#f5f7fa", "extra": , "subtitle": "Median products.base_price", - "title": "Monday", + "title": "Other", "value": 24.984375, "valueFormatter": [Function], }, @@ -644,11 +621,11 @@ describe('MetricVisComponent', function () { }, dimensions: { ...config.dimensions, - progressMax: basePriceColumnId, + max: basePriceColumnId, }, }} data={table} - renderComplete={() => {}} + {...defaultProps} /> ) .find(Metric) @@ -657,7 +634,7 @@ describe('MetricVisComponent', function () { Array [ Array [ Object { - "color": "#343741", + "color": "#f5f7fa", "domainMax": 28.984375, "extra": , "progressBarDirection": "vertical", @@ -667,7 +644,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "domainMax": 28.984375, "extra": , "progressBarDirection": "vertical", @@ -677,7 +654,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "domainMax": 25.984375, "extra": , "progressBarDirection": "vertical", @@ -687,7 +664,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "domainMax": 25.784375, "extra": , "progressBarDirection": "vertical", @@ -697,7 +674,7 @@ describe('MetricVisComponent', function () { "valueFormatter": [Function], }, Object { - "color": "#343741", + "color": "#f5f7fa", "domainMax": 25.348011363636363, "extra": , "progressBarDirection": "vertical", @@ -709,12 +686,12 @@ describe('MetricVisComponent', function () { ], Array [ Object { - "color": "#343741", + "color": "#f5f7fa", "domainMax": 24.984375, "extra": , "progressBarDirection": "vertical", "subtitle": "Median products.base_price", - "title": "Monday", + "title": "Other", "value": 24.984375, "valueFormatter": [Function], }, @@ -722,6 +699,88 @@ describe('MetricVisComponent', function () { ] `); }); + + it('renders with no data', () => { + const component = shallow( + + ); + + const { data } = component.find(Metric).props(); + + expect(data).toBeDefined(); + expect(data).toMatchInlineSnapshot(` + Array [ + Array [ + undefined, + undefined, + undefined, + undefined, + undefined, + ], + Array [ + undefined, + ], + ] + `); + }); + }); + + describe('rendering with no data', () => {}); + + it('should constrain dimensions in edit mode', () => { + const getContainerStyles = (editMode: boolean, multipleTiles: boolean) => + ( + shallow( + + ) + .find('div') + .props() as HtmlAttributes & { css: { styles: string } } + ).css.styles; + + expect(getContainerStyles(false, false)).toMatchInlineSnapshot(` + " + height: 100%; + width: 100%; + max-height: 100%; + max-width: 100%; + " + `); + + expect(getContainerStyles(true, false)).toMatchInlineSnapshot(` + " + height: 300px; + width: 300px; + max-height: 100%; + max-width: 100%; + " + `); + + expect(getContainerStyles(true, true)).toMatchInlineSnapshot(` + " + height: 400px; + width: 1000px; + max-height: 100%; + max-width: 100%; + " + `); }); it('should report render complete', () => { @@ -738,6 +797,7 @@ describe('MetricVisComponent', function () { }, }} data={table} + {...defaultProps} renderComplete={renderCompleteSpy} /> ); @@ -750,6 +810,271 @@ describe('MetricVisComponent', function () { expect(renderCompleteSpy).toHaveBeenCalledTimes(1); }); + describe('filter events', () => { + const fireEventSpy = jest.fn(); + + afterEach(() => fireEventSpy.mockClear()); + + const fireFilter = (event: MetricElementEvent, filterable: boolean, breakdown?: boolean) => { + const component = shallow( + + ); + + component.find(Settings).props().onElementClick!([event]); + }; + + test('without breakdown', () => { + const event: MetricElementEvent = { + type: 'metricElementEvent', + rowIndex: 0, + columnIndex: 0, + }; + + fireFilter(event, true, false); + + expect(fireEventSpy).toHaveBeenCalledTimes(1); + expect(fireEventSpy).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + table, + column: 1, + row: 0, + }, + ], + }, + }); + }); + + test('with breakdown', () => { + const event: MetricElementEvent = { + type: 'metricElementEvent', + rowIndex: 1, + columnIndex: 0, + }; + + fireFilter(event, true, true); + + expect(fireEventSpy).toHaveBeenCalledTimes(1); + expect(fireEventSpy).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + table, + column: 0, + row: 5, + }, + ], + }, + }); + }); + + it('should do nothing if primary metric is not filterable', () => { + const event: MetricElementEvent = { + type: 'metricElementEvent', + rowIndex: 1, + columnIndex: 0, + }; + + fireFilter(event, false, true); + + expect(fireEventSpy).not.toHaveBeenCalled(); + }); + }); + + describe('coloring', () => { + afterEach(() => mockGetColorForValue.mockClear()); + + describe('by palette', () => { + const colorFromPalette = 'color-from-palette'; + mockGetColorForValue.mockReturnValue(colorFromPalette); + + it('should fetch color from palette if provided', () => { + const component = shallow( + + ); + + const [[datum]] = component.find(Metric).props().data!; + + expect(datum!.color).toBe(colorFromPalette); + expect(mockGetColorForValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 28.984375, + Object { + "colors": Array [], + "gradient": true, + "range": "number", + "rangeMax": 10, + "rangeMin": 2, + "stops": Array [], + }, + Object { + "max": 28.984375, + "min": 0, + }, + ], + ] + `); + }); + + describe('percent-based', () => { + const renderWithPalette = ( + palette: CustomPaletteState, + dimensions: MetricVisComponentProps['config']['dimensions'] + ) => + shallow( + + ); + + const dimensionsAndExpectedBounds = [ + [ + 'breakdown-by and max', + { + metric: minPriceColumnId, + max: basePriceColumnId, + breakdownBy: dayOfWeekColumnId, + }, + ], + ['just breakdown-by', { metric: minPriceColumnId, breakdownBy: dayOfWeekColumnId }], + ['just max', { metric: minPriceColumnId, max: basePriceColumnId }], + ]; + + it.each(dimensionsAndExpectedBounds)( + 'should set correct data bounds with %s dimension', + // @ts-expect-error + (label, dimensions) => { + mockGetColorForValue.mockClear(); + + renderWithPalette( + { + range: 'percent', + // the rest of these params don't matter + colors: [], + gradient: false, + stops: [], + rangeMin: 2, + rangeMax: 10, + }, + dimensions as DimensionsVisParam + ); + + expect( + mockGetColorForValue.mock.calls.map(([value, _palette, bounds]) => ({ + value, + ...bounds, + })) + ).toMatchSnapshot(); + } + ); + }); + }); + + describe('by static color', () => { + it('uses static color if no palette', () => { + const staticColor = 'static-color'; + + const component = shallow( + + ); + + const [[datum]] = component.find(Metric).props().data!; + + expect(datum!.color).toBe(staticColor); + expect(mockGetColorForValue).not.toHaveBeenCalled(); + }); + + it('defaults if no static color', () => { + const component = shallow( + + ); + + const [[datum]] = component.find(Metric).props().data!; + + expect(datum!.color).toBe(euiThemeVars.euiColorLightestShade); + expect(mockGetColorForValue).not.toHaveBeenCalled(); + }); + }); + }); + describe('metric value formatting', () => { const getFormattedMetrics = ( value: number, @@ -786,7 +1111,7 @@ describe('MetricVisComponent', function () { ], rows: [{ '1': value, '2': secondaryValue }], }} - renderComplete={() => {}} + {...defaultProps} /> ); @@ -796,7 +1121,7 @@ describe('MetricVisComponent', function () { extra, } = component.find(Metric).props().data?.[0][0]!; - return { primary: valueFormatter(primaryMetric), secondary: extra?.props.children }; + return { primary: valueFormatter(primaryMetric), secondary: extra?.props.children[1] }; }; it('correctly formats plain numbers', () => { diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx index 3f91061cba6c3..5473d98d85c18 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; @@ -14,21 +14,26 @@ import { Chart, Metric, MetricSpec, + MetricWProgress, + isMetricElementEvent, RenderChangeListener, Settings, - MetricWProgress, } from '@elastic/charts'; import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; -import { +import type { Datatable, DatatableColumn, DatatableRow, IInterpreterRenderHandlers, + RenderMode, } from '@kbn/expressions-plugin/common'; import { CustomPaletteState } from '@kbn/charts-plugin/public'; -import { euiLightVars } from '@kbn/ui-theme'; import { FORMATS_UI_SETTINGS } from '@kbn/field-formats-plugin/common'; +import type { FieldFormatConvertFunction } from '@kbn/field-formats-plugin/common'; +import { CUSTOM_PALETTE } from '@kbn/coloring'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; import { VisParams } from '../../common'; import { getPaletteService, @@ -37,9 +42,9 @@ import { getUiSettingsService, } from '../services'; import { getCurrencyCode } from './currency_codes'; +import { getDataBoundsForPalette } from '../utils'; -const defaultColor = euiLightVars.euiColorDarkestShade; - +export const defaultColor = euiThemeVars.euiColorLightestShade; const getBytesUnit = (value: number) => { const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte']; const abs = Math.abs(value); @@ -69,7 +74,7 @@ const getBytesUnit = (value: number) => { return { value, unit }; }; -const getFormatter = ( +const getMetricFormatter = ( accessor: ExpressionValueVisDimension | string, columns: Datatable['columns'] ) => { @@ -79,7 +84,7 @@ const getFormatter = ( if (!['number', 'currency', 'percent', 'bytes', 'duration'].includes(formatId)) { throw new Error( i18n.translate('expressionMetricVis.errors.unsupportedColumnFormat', { - defaultMessage: 'Metric Visualization - Unsupported column format: "{id}"', + defaultMessage: 'Metric visualization expression - Unsupported column format: "{id}"', values: { id: formatId, }, @@ -140,55 +145,97 @@ const getFormatter = ( : new Intl.NumberFormat(locale, intlOptions).format; }; -const getColor = (value: number, paletteParams: CustomPaletteState | undefined) => - paletteParams - ? getPaletteService().get('custom')?.getColorForValue?.(value, paletteParams, { - min: paletteParams.rangeMin, - max: paletteParams.rangeMax, - }) || defaultColor - : defaultColor; +const getColor = ( + value: number, + paletteParams: CustomPaletteState, + accessors: { metric: string; max?: string; breakdownBy?: string }, + data: Datatable, + rowNumber: number +) => { + let minBound = paletteParams.rangeMin; + let maxBound = paletteParams.rangeMax; + + const { min, max } = getDataBoundsForPalette(accessors, data, rowNumber); + minBound = min; + maxBound = max; + + return getPaletteService().get(CUSTOM_PALETTE)?.getColorForValue?.(value, paletteParams, { + min: minBound, + max: maxBound, + }); +}; + +const buildFilterEvent = (rowIdx: number, columnIdx: number, table: Datatable) => { + return { + name: 'filter', + data: { + data: [ + { + table, + column: columnIdx, + row: rowIdx, + }, + ], + }, + }; +}; export interface MetricVisComponentProps { data: Datatable; config: Pick; renderComplete: IInterpreterRenderHandlers['done']; + fireEvent: IInterpreterRenderHandlers['event']; + renderMode: RenderMode; + filterable: boolean; } -const MetricVisComponent = ({ data, config, renderComplete }: MetricVisComponentProps) => { +export const MetricVis = ({ + data, + config, + renderComplete, + fireEvent, + renderMode, + filterable, +}: MetricVisComponentProps) => { const primaryMetricColumn = getColumnByAccessor(config.dimensions.metric, data.columns)!; - const formatPrimaryMetric = getFormatter(config.dimensions.metric, data.columns); + const formatPrimaryMetric = getMetricFormatter(config.dimensions.metric, data.columns); let secondaryMetricColumn: DatatableColumn | undefined; - let formatSecondaryMetric: ReturnType; + let formatSecondaryMetric: ReturnType; if (config.dimensions.secondaryMetric) { secondaryMetricColumn = getColumnByAccessor(config.dimensions.secondaryMetric, data.columns); - formatSecondaryMetric = getFormatter(config.dimensions.secondaryMetric, data.columns); + formatSecondaryMetric = getMetricFormatter(config.dimensions.secondaryMetric, data.columns); } - const breakdownByColumn = config.dimensions.breakdownBy - ? getColumnByAccessor(config.dimensions.breakdownBy, data.columns) - : undefined; + let breakdownByColumn: DatatableColumn | undefined; + let formatBreakdownValue: FieldFormatConvertFunction; + if (config.dimensions.breakdownBy) { + breakdownByColumn = getColumnByAccessor(config.dimensions.breakdownBy, data.columns); + formatBreakdownValue = getFormatService() + .deserialize(getFormatByAccessor(config.dimensions.breakdownBy, data.columns)) + .getConverterFor('text'); + } let getProgressBarConfig = (_row: DatatableRow): Partial => ({}); - if (config.dimensions.progressMax) { - const maxColId = getColumnByAccessor(config.dimensions.progressMax, data.columns)?.id; - if (maxColId) { - getProgressBarConfig = (_row: DatatableRow): Partial => ({ - domainMax: _row[maxColId], - progressBarDirection: config.metric.progressDirection, - }); - } + const maxColId = config.dimensions.max + ? getColumnByAccessor(config.dimensions.max, data.columns)?.id + : undefined; + if (maxColId) { + getProgressBarConfig = (_row: DatatableRow): Partial => ({ + domainMax: _row[maxColId], + progressBarDirection: config.metric.progressDirection, + }); } const metricConfigs: MetricSpec['data'][number] = ( breakdownByColumn ? data.rows : data.rows.slice(0, 1) - ).map((row) => { + ).map((row, rowIdx) => { const value = row[primaryMetricColumn.id]; - const title = breakdownByColumn ? row[breakdownByColumn.id] : primaryMetricColumn.name; - const subtitle = breakdownByColumn - ? primaryMetricColumn.name - : secondaryMetricColumn?.name ?? config.metric.subtitle; + const title = breakdownByColumn + ? formatBreakdownValue(row[breakdownByColumn.id]) + : primaryMetricColumn.name; + const subtitle = breakdownByColumn ? primaryMetricColumn.name : config.metric.subtitle; return { value, valueFormatter: formatPrimaryMetric, @@ -196,12 +243,28 @@ const MetricVisComponent = ({ data, config, renderComplete }: MetricVisComponent subtitle, extra: ( + {config.metric.secondaryPrefix} {secondaryMetricColumn - ? formatSecondaryMetric!(row[secondaryMetricColumn.id]) - : config.metric.extraText} + ? `${config.metric.secondaryPrefix ? ' ' : ''}${formatSecondaryMetric!( + row[secondaryMetricColumn.id] + )}` + : undefined} ), - color: getColor(value, config.metric.palette), + color: + config.metric.palette && value != null + ? getColor( + value, + config.metric.palette, + { + metric: primaryMetricColumn.id, + max: maxColId, + breakdownBy: breakdownByColumn?.id, + }, + data, + rowIdx + ) ?? defaultColor + : config.metric.color ?? defaultColor, ...getProgressBarConfig(row), }; }); @@ -228,17 +291,62 @@ const MetricVisComponent = ({ data, config, renderComplete }: MetricVisComponent [renderComplete] ); + let pixelHeight; + let pixelWidth; + if (renderMode === 'edit') { + // In the editor, we constrain the maximum size of the tiles for aesthetic reasons + const maxTileSideLength = metricConfigs.flat().length > 1 ? 200 : 300; + pixelHeight = grid.length * maxTileSideLength; + pixelWidth = grid[0].length * maxTileSideLength; + } + + // force chart to re-render to circumvent a charts bug + const magicKey = useRef(0); + useEffect(() => { + magicKey.current++; + }, [data]); + return ( - - - - +
+ + { + if (!filterable) { + return; + } + events.forEach((event) => { + if (isMetricElementEvent(event)) { + const colIdx = breakdownByColumn + ? data.columns.findIndex((col) => col === breakdownByColumn) + : data.columns.findIndex((col) => col === primaryMetricColumn); + const rowLength = grid[0].length; + fireEvent( + buildFilterEvent(event.rowIndex * rowLength + event.columnIndex, colIdx, data) + ); + } + }); + }} + /> + + +
); }; - -// default export required for React.Lazy -// eslint-disable-next-line import/no-default-export -export { MetricVisComponent as default }; diff --git a/src/plugins/chart_expressions/expression_metric/public/expression_renderers/metric_vis_renderer.tsx b/src/plugins/chart_expressions/expression_metric/public/expression_renderers/metric_vis_renderer.tsx index f398029de38b8..19b47d57a06a4 100644 --- a/src/plugins/chart_expressions/expression_metric/public/expression_renderers/metric_vis_renderer.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/expression_renderers/metric_vis_renderer.tsx @@ -6,21 +6,42 @@ * Side Public License, v 1. */ -import React, { lazy } from 'react'; +import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { ExpressionRenderDefinition } from '@kbn/expressions-plugin/common/expression_renderers'; -import { VisualizationContainer } from '@kbn/visualizations-plugin/public'; import { css } from '@emotion/react'; import { StartServicesGetter } from '@kbn/kibana-utils-plugin/public'; import { METRIC_TYPE } from '@kbn/analytics'; +import type { IInterpreterRenderHandlers, Datatable } from '@kbn/expressions-plugin/common'; +import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExpressionMetricPluginStart } from '../plugin'; -import { EXPRESSION_METRIC_NAME, MetricVisRenderConfig } from '../../common'; +import { EXPRESSION_METRIC_NAME, MetricVisRenderConfig, VisParams } from '../../common'; import { extractContainerType, extractVisualizationType } from '../../../common'; -const MetricVis = lazy(() => import('../components/metric_vis')); - +async function metricFilterable( + dimensions: VisParams['dimensions'], + table: Datatable, + hasCompatibleActions?: IInterpreterRenderHandlers['hasCompatibleActions'] +) { + const column = getColumnByAccessor(dimensions.breakdownBy ?? dimensions.metric, table.columns); + const colIndex = table.columns.indexOf(column!); + return Boolean( + await hasCompatibleActions?.({ + name: 'filter', + data: { + data: [ + { + table, + column: colIndex, + row: 0, + }, + ], + }, + }) + ); +} interface ExpressionMetricVisRendererDependencies { getStartDeps: StartServicesGetter; } @@ -39,6 +60,11 @@ export const getMetricVisRenderer = ( unmountComponentAtNode(domNode); }); + const filterable = await metricFilterable( + visConfig.dimensions, + visData, + handlers.hasCompatibleActions?.bind(handlers) + ); const renderComplete = () => { const executionContext = handlers.getExecutionContext(); const containerType = extractContainerType(executionContext); @@ -53,20 +79,28 @@ export const getMetricVisRenderer = ( handlers.done(); }; + const { MetricVis } = await import('../components/metric_vis'); render( - - - + + , domNode ); diff --git a/src/plugins/chart_expressions/expression_metric/public/index.ts b/src/plugins/chart_expressions/expression_metric/public/index.ts index dfb442514d5f0..c8d5d080bd4e6 100644 --- a/src/plugins/chart_expressions/expression_metric/public/index.ts +++ b/src/plugins/chart_expressions/expression_metric/public/index.ts @@ -11,3 +11,5 @@ import { ExpressionMetricPlugin } from './plugin'; export function plugin() { return new ExpressionMetricPlugin(); } + +export { getDataBoundsForPalette } from './utils'; diff --git a/src/plugins/chart_expressions/expression_metric/public/utils/index.ts b/src/plugins/chart_expressions/expression_metric/public/utils/index.ts index 66c305a14c460..6927ea10009de 100644 --- a/src/plugins/chart_expressions/expression_metric/public/utils/index.ts +++ b/src/plugins/chart_expressions/expression_metric/public/utils/index.ts @@ -5,5 +5,5 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -export { parseRgbString, shouldApplyColor, needsLightText } from './palette'; -export { formatValue } from './format'; + +export { getDataBoundsForPalette } from './palette_data_bounds'; diff --git a/src/plugins/chart_expressions/expression_metric/public/utils/palette.ts b/src/plugins/chart_expressions/expression_metric/public/utils/palette.ts deleted file mode 100644 index 7f588aa552385..0000000000000 --- a/src/plugins/chart_expressions/expression_metric/public/utils/palette.ts +++ /dev/null @@ -1,41 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { isColorDark } from '@elastic/eui'; - -export const parseRgbString = (rgb: string) => { - const groups = rgb.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*?(,\s*(\d+)\s*)?\)/) ?? []; - if (!groups) { - return null; - } - - const red = parseFloat(groups[1]); - const green = parseFloat(groups[2]); - const blue = parseFloat(groups[3]); - const opacity = groups[5] ? parseFloat(groups[5]) : undefined; - - return { red, green, blue, opacity }; -}; - -export const shouldApplyColor = (color: string) => { - const rgb = parseRgbString(color); - const { opacity } = rgb ?? {}; - - // if opacity === 0, it means there is no color to apply to the metric - return !rgb || (rgb && opacity !== 0); -}; - -export const needsLightText = (bgColor: string = '') => { - const rgb = parseRgbString(bgColor); - if (!rgb) { - return false; - } - - const { red, green, blue, opacity } = rgb; - return isColorDark(red, green, blue) && opacity !== 0; -}; diff --git a/src/plugins/chart_expressions/expression_metric/public/utils/palette_data_bounds.ts b/src/plugins/chart_expressions/expression_metric/public/utils/palette_data_bounds.ts new file mode 100644 index 0000000000000..0776def5ab9ab --- /dev/null +++ b/src/plugins/chart_expressions/expression_metric/public/utils/palette_data_bounds.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '@kbn/expressions-plugin/common'; + +export const getDataBoundsForPalette = ( + accessors: { metric: string; max?: string; breakdownBy?: string }, + data?: Datatable, + rowNumber?: number +) => { + if (!data) { + return { min: -Infinity, max: Infinity }; + } + + const smallestMetric = Math.min(...data.rows.map((row) => row[accessors.metric])); + const greatestMetric = Math.max(...data.rows.map((row) => row[accessors.metric])); + const greatestMaximum = accessors.max + ? rowNumber + ? data.rows[rowNumber][accessors.max] + : Math.max(...data.rows.map((row) => row[accessors.max!])) + : greatestMetric; + + const dataMin = accessors.breakdownBy && !accessors.max ? smallestMetric : 0; + + const dataMax = accessors.breakdownBy + ? accessors.max + ? greatestMaximum + : greatestMetric + : greatestMaximum; + + return { min: dataMin, max: dataMax }; +}; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 035947877550b..29409f63548af 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -14,6 +14,7 @@ import { EmbeddablePublicPlugin } from './plugin'; export type { Adapters, ReferenceOrValueEmbeddable, + SelfStyledEmbeddable, ChartActionContext, ContainerInput, ContainerOutput, diff --git a/src/plugins/embeddable/public/lib/index.ts b/src/plugins/embeddable/public/lib/index.ts index bf4732702b993..6f57003403aca 100644 --- a/src/plugins/embeddable/public/lib/index.ts +++ b/src/plugins/embeddable/public/lib/index.ts @@ -15,3 +15,4 @@ export * from './containers'; export * from './panel'; export * from './state_transfer'; export * from './reference_or_value_embeddable'; +export * from './self_styled_embeddable'; diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 4b2ef26aeeb7c..dfbd5f80999d6 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -573,6 +573,52 @@ test('Updates when hidePanelTitles is toggled', async () => { expect(title.length).toBe(1); }); +test('Respects options from SelfStyledEmbeddable', async () => { + const inspector = inspectorPluginMock.createStartContract(); + + const container = new HelloWorldContainer( + { id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false }, + { getEmbeddableFactory } as any + ); + + const contactCardEmbeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Rob', + lastName: 'Stark', + }); + + const selfStyledEmbeddable = embeddablePluginMock.mockSelfStyledEmbeddable( + contactCardEmbeddable, + { hideTitle: true } + ); + + // make sure the title is being hidden because of the self styling, not the container + container.updateInput({ hidePanelTitles: false }); + + const component = mount( + + Promise.resolve([])} + getAllEmbeddableFactories={start.getEmbeddableFactories} + getEmbeddableFactory={start.getEmbeddableFactory} + notifications={{} as any} + overlays={{} as any} + application={applicationMock} + inspector={inspector} + SavedObjectFinder={() => null} + theme={theme} + /> + + ); + + const title = findTestSubject(component, `embeddablePanelHeading-HelloRobStark`); + expect(title.length).toBe(0); +}); + test('Check when hide header option is false', async () => { const inspector = inspectorPluginMock.createStartContract(); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 6456ba1999566..29bb4281bec01 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -40,7 +40,7 @@ import { InspectPanelAction } from './panel_header/panel_actions/inspect_panel_a import { EditPanelAction } from '../actions'; import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal'; import { EmbeddableStart } from '../../plugin'; -import { EmbeddableStateTransfer, ErrorEmbeddable } from '..'; +import { EmbeddableStateTransfer, ErrorEmbeddable, isSelfStyledEmbeddable } from '..'; const sortByOrderField = ( { order: orderA }: { order?: number }, @@ -296,6 +296,10 @@ export class EmbeddablePanel extends React.Component { const title = this.props.embeddable.getTitle(); const headerId = this.generateId(); + const selfStyledOptions = isSelfStyledEmbeddable(this.props.embeddable) + ? this.props.embeddable.getSelfStyledOptions() + : undefined; + return ( { {!this.props.hideHeader && ( { - if (typeof value === 'number' && isNaN(value)) { - return '-'; - } - - return fieldFormatter.convert(value, format); -}; +export type { SelfStyledEmbeddable } from './types'; +export { isSelfStyledEmbeddable } from './types'; diff --git a/src/plugins/embeddable/public/lib/self_styled_embeddable/types.ts b/src/plugins/embeddable/public/lib/self_styled_embeddable/types.ts new file mode 100644 index 0000000000000..516c452ef3abf --- /dev/null +++ b/src/plugins/embeddable/public/lib/self_styled_embeddable/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface SelfStyledOptions { + hideTitle?: boolean; +} + +/** + * All embeddables that implement this interface will be able to configure + * the style of their containing panels + * @public + */ +export interface SelfStyledEmbeddable { + /** + * Gets the embeddable's style configuration + */ + getSelfStyledOptions: () => SelfStyledOptions; +} + +export function isSelfStyledEmbeddable(incoming: unknown): incoming is SelfStyledEmbeddable { + return !!(incoming as SelfStyledEmbeddable).getSelfStyledOptions; +} diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index a626dc5a4b1d9..9f05b3eeaa001 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -26,7 +26,9 @@ import { EmbeddableInput, SavedObjectEmbeddableInput, ReferenceOrValueEmbeddable, + SelfStyledEmbeddable, } from '.'; +import { SelfStyledOptions } from './lib/self_styled_embeddable/types'; export { mockAttributeService } from './lib/attribute_service/attribute_service.mock'; export type Setup = jest.Mocked; @@ -101,6 +103,15 @@ export const mockRefOrValEmbeddable = < return newEmbeddable as OriginalEmbeddableType & ReferenceOrValueEmbeddable; }; +export function mockSelfStyledEmbeddable( + embeddable: OriginalEmbeddableType, + selfStyledOptions: SelfStyledOptions +): OriginalEmbeddableType & SelfStyledEmbeddable { + const newEmbeddable: SelfStyledEmbeddable = embeddable as unknown as SelfStyledEmbeddable; + newEmbeddable.getSelfStyledOptions = () => selfStyledOptions; + return newEmbeddable as OriginalEmbeddableType & SelfStyledEmbeddable; +} + const createSetupContract = (): Setup => { const setupContract: Setup = { registerEmbeddableFactory: jest.fn(), @@ -147,4 +158,5 @@ export const embeddablePluginMock = { createStartContract, createInstance, mockRefOrValEmbeddable, + mockSelfStyledEmbeddable, }; diff --git a/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.test.ts b/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.test.ts index 97c7c2cfaac90..f878653430954 100644 --- a/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.test.ts +++ b/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.test.ts @@ -39,6 +39,61 @@ describe('collapse_fn', () => { expect(result.rows).toEqual([{ val: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 }]); }); + it('can use different functions for each different metric', async () => { + const result = await runFn( + { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'val2', name: 'val2', meta: { type: 'number' } }, + { id: 'val3', name: 'val3', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + ], + rows: [ + { val: 1, val2: 1, val3: 1, split: 'A' }, + { val: 2, val2: 2, val3: 2, split: 'B' }, + { val: 3, val2: 3, val3: 3, split: 'B' }, + { val: 4, val2: 4, val3: 4, split: 'A' }, + { val: 5, val2: 5, val3: 5, split: 'A' }, + { val: 6, val2: 6, val3: 6, split: 'A' }, + { val: 7, val2: 7, val3: 7, split: 'B' }, + { val: 8, val2: 8, val3: 8, split: 'B' }, + ], + }, + { metric: ['val', 'val2', 'val3'], fn: ['sum', 'min', 'avg'] } + ); + + expect(result.rows).toEqual([ + { + val: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8, + val2: Math.min(1, 2, 3, 4, 5, 6, 7, 8), + val3: (1 + 2 + 3 + 4 + 5 + 6 + 7 + 8) / 8, + }, + ]); + }); + + it('throws error if number of functions and metrics do not match', async () => { + expect(() => + runFn( + { + type: 'datatable', + columns: [ + { id: 'val', name: 'val', meta: { type: 'number' } }, + { id: 'val2', name: 'val2', meta: { type: 'number' } }, + { id: 'val3', name: 'val3', meta: { type: 'number' } }, + { id: 'split', name: 'split', meta: { type: 'string' } }, + ], + rows: [{ val: 1, val2: 1, val3: 1, split: 'A' }], + }, + { metric: ['val', 'val2', 'val3'], fn: ['sum', 'min'] } + ) + ).rejects.toMatchInlineSnapshot(` + [Error: lens_collapse - Called with 3 metrics and 2 collapse functions. + Must be called with either a single collapse function for all metrics, + or a number of collapse functions matching the number of metrics.] + `); + }); + const twoSplitTable: Datatable = { type: 'datatable', columns: [ diff --git a/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.ts b/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.ts index b05942ed667a9..5ca2248ed1ef7 100644 --- a/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.ts +++ b/x-pack/plugins/lens/common/expressions/collapse/collapse_fn.ts @@ -17,22 +17,36 @@ function getValueAsNumberArray(value: unknown) { } export const collapseFn: CollapseExpressionFunction['fn'] = (input, { by, metric, fn }) => { + const collapseFunctionsByMetricIndex = Array.isArray(fn) + ? fn + : metric + ? new Array(metric.length).fill(fn) + : []; + + if (metric && metric.length !== collapseFunctionsByMetricIndex.length) { + throw Error(`lens_collapse - Called with ${metric.length} metrics and ${fn.length} collapse functions. +Must be called with either a single collapse function for all metrics, +or a number of collapse functions matching the number of metrics.`); + } + const accumulators: Record>> = {}; const valueCounter: Record>> = {}; metric?.forEach((m) => { accumulators[m] = {}; valueCounter[m] = {}; }); + const setMarker: Partial> = {}; input.rows.forEach((row) => { const bucketIdentifier = getBucketIdentifier(row, by); - metric?.forEach((m) => { + metric?.forEach((m, i) => { const accumulatorValue = accumulators[m][bucketIdentifier]; const currentValue = row[m]; if (currentValue != null) { const currentNumberValues = getValueAsNumberArray(currentValue); - switch (fn) { + + switch (collapseFunctionsByMetricIndex[i]) { case 'avg': valueCounter[m][bucketIdentifier] = (valueCounter[m][bucketIdentifier] ?? 0) + currentNumberValues.length; @@ -66,8 +80,9 @@ export const collapseFn: CollapseExpressionFunction['fn'] = (input, { by, metric } }); }); - if (fn === 'avg') { - metric?.forEach((m) => { + + metric?.forEach((m, i) => { + if (collapseFunctionsByMetricIndex[i] === 'avg') { Object.keys(accumulators[m]).forEach((bucketIdentifier) => { const accumulatorValue = accumulators[m][bucketIdentifier]; const valueCount = valueCounter[m][bucketIdentifier]; @@ -75,8 +90,8 @@ export const collapseFn: CollapseExpressionFunction['fn'] = (input, { by, metric accumulators[m][bucketIdentifier] = accumulatorValue / valueCount; } }); - }); - } + } + }); return { ...input, diff --git a/x-pack/plugins/lens/common/expressions/collapse/index.ts b/x-pack/plugins/lens/common/expressions/collapse/index.ts index 06d2ccbc0fbac..5ea792e39cb0d 100644 --- a/x-pack/plugins/lens/common/expressions/collapse/index.ts +++ b/x-pack/plugins/lens/common/expressions/collapse/index.ts @@ -9,10 +9,11 @@ import { i18n } from '@kbn/i18n'; import type { CollapseExpressionFunction } from './types'; +type CollapseFunction = 'sum' | 'avg' | 'min' | 'max'; export interface CollapseArgs { by?: string[]; metric?: string[]; - fn: 'sum' | 'avg' | 'min' | 'max'; + fn: CollapseFunction | CollapseFunction[]; } /** @@ -56,6 +57,7 @@ export const collapse: CollapseExpressionFunction = { defaultMessage: 'The aggregate function to apply', }), types: ['string'], + multi: true, required: true, }, }, diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index adf791e8d2f48..cb208eb60daff 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -23,6 +23,7 @@ "dataViewFieldEditor", "dataViewEditor", "expressionGauge", + "expressionMetricVis", "expressionHeatmap", "eventAnnotation", "unifiedSearch" @@ -36,13 +37,8 @@ "spaces", "discover" ], - "configPath": [ - "xpack", - "lens" - ], - "extraPublicDirs": [ - "common/constants" - ], + "configPath": ["xpack", "lens"], + "extraPublicDirs": ["common/constants"], "requiredBundles": [ "unifiedSearch", "savedObjects", diff --git a/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts index 612cac3f6dc9d..99bc4fa0f9717 100644 --- a/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts +++ b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts @@ -76,6 +76,7 @@ describe('getLayerMetaInfo', () => { getTableSpec: jest.fn(), getVisualDefaults: jest.fn(), getSourceId: jest.fn(), + getMaxPossibleNumValues: jest.fn(), getFilters: jest.fn(), }; mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); @@ -93,6 +94,7 @@ describe('getLayerMetaInfo', () => { getTableSpec: jest.fn(() => [{ columnId: 'col1', fields: ['bytes'] }]), getVisualDefaults: jest.fn(), getSourceId: jest.fn(), + getMaxPossibleNumValues: jest.fn(), getFilters: jest.fn(() => ({ error: 'filters error' })), }; mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); @@ -149,6 +151,7 @@ describe('getLayerMetaInfo', () => { getTableSpec: jest.fn(() => [{ columnId: 'col1', fields: ['bytes'] }]), getVisualDefaults: jest.fn(), getSourceId: jest.fn(), + getMaxPossibleNumValues: jest.fn(), getFilters: jest.fn(() => ({ enabled: { kuery: [[{ language: 'kuery', query: 'memory > 40000' }]], diff --git a/x-pack/plugins/lens/public/async_services.ts b/x-pack/plugins/lens/public/async_services.ts index dcb872deb3d39..cd2a9787c9cef 100644 --- a/x-pack/plugins/lens/public/async_services.ts +++ b/x-pack/plugins/lens/public/async_services.ts @@ -18,6 +18,8 @@ export * from './datatable_visualization/datatable_visualization'; export * from './datatable_visualization'; export * from './metric_visualization/metric_visualization'; export * from './metric_visualization'; +export * from './visualizations/metric/metric_visualization'; +export * from './visualizations/metric'; export * from './pie_visualization/pie_visualization'; export * from './pie_visualization'; export * from './xy_visualization/xy_visualization'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index a77a8575a6bfe..047d8b4deffaa 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -360,6 +360,7 @@ describe('editor_frame', () => { getVisualDefaults: jest.fn(), getSourceId: jest.fn(), getFilters: jest.fn(), + getMaxPossibleNumValues: jest.fn(), }; mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index f7ffdee236979..a265f534627b6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -708,6 +708,7 @@ describe('suggestion helpers', () => { getVisualDefaults: jest.fn(), getSourceId: jest.fn(), getFilters: jest.fn(), + getMaxPossibleNumValues: jest.fn(), }, }, { activeId: 'testVis', state: {} }, @@ -742,6 +743,7 @@ describe('suggestion helpers', () => { getVisualDefaults: jest.fn(), getSourceId: jest.fn(), getFilters: jest.fn(), + getMaxPossibleNumValues: jest.fn(), }, }; defaultParams[3] = { @@ -803,6 +805,7 @@ describe('suggestion helpers', () => { getVisualDefaults: jest.fn(), getSourceId: jest.fn(), getFilters: jest.fn(), + getMaxPossibleNumValues: jest.fn(), }, }; mockVisualization1.getSuggestions.mockReturnValue([]); diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index b0610a5b26e4d..69e8d1ee9d18f 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -40,6 +40,7 @@ import { IContainer, SavedObjectEmbeddableInput, ReferenceOrValueEmbeddable, + SelfStyledEmbeddable, } from '@kbn/embeddable-plugin/public'; import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { DataViewsContract, DataView } from '@kbn/data-views-plugin/public'; @@ -71,7 +72,7 @@ import { getEditPath, DOC_TYPE } from '../../common'; import { LensAttributeService } from '../lens_attribute_service'; import type { ErrorMessage, TableInspectorAdapter } from '../editor_frame_service/types'; import { getLensInspectorService, LensInspector } from '../lens_inspector_service'; -import { SharingSavedObjectProps } from '../types'; +import { SharingSavedObjectProps, VisualizationDisplayOptions } from '../types'; import { getActiveDatasourceIdFromDoc, getIndexPatternsObjects, inferTimeField } from '../utils'; import { getLayerMetaInfo, combineQueryAndFilters } from '../app_plugin/show_underlying_data'; @@ -210,7 +211,9 @@ function getViewUnderlyingDataArgs({ export class Embeddable extends AbstractEmbeddable - implements ReferenceOrValueEmbeddable + implements + ReferenceOrValueEmbeddable, + SelfStyledEmbeddable { type = DOC_TYPE; @@ -600,6 +603,7 @@ export class Embeddable onRuntimeError={() => { this.logError('runtime'); }} + noPadding={this.visDisplayOptions?.noPadding} /> , domNode @@ -885,4 +889,20 @@ export class Embeddable this.subscription.unsubscribe(); } } + + public getSelfStyledOptions() { + return { + hideTitle: this.visDisplayOptions?.noPanelTitle, + }; + } + + private get visDisplayOptions(): VisualizationDisplayOptions | undefined { + if ( + !this.savedVis?.visualizationType || + !this.deps.visualizationMap[this.savedVis.visualizationType].getDisplayOptions + ) { + return; + } + return this.deps.visualizationMap[this.savedVis.visualizationType].getDisplayOptions!(); + } } diff --git a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx index e7debac3d7ce8..6e2627e0e1cf6 100644 --- a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx @@ -46,6 +46,7 @@ export interface ExpressionWrapperProps { onRuntimeError: () => void; executionContext?: KibanaExecutionContext; lensInspector: LensInspector; + noPadding?: boolean; } interface VisualizationErrorProps { @@ -120,6 +121,7 @@ export function ExpressionWrapper({ onRuntimeError, executionContext, lensInspector, + noPadding, }: ExpressionWrapperProps) { return ( @@ -129,7 +131,7 @@ export function ExpressionWrapper({
{ }); }); }); + + describe('getMaxPossibleNumValues', () => { + it('should pass it on to the operation when available', () => { + const prediction = 23; + const operationPredictSpy = jest + .spyOn(operationDefinitionMap.terms, 'getMaxPossibleNumValues') + .mockReturnValue(prediction); + const columnId = 'col1'; + + expect(publicAPI.getMaxPossibleNumValues(columnId)).toEqual(prediction); + expect(operationPredictSpy).toHaveBeenCalledWith( + expect.objectContaining({ operationType: 'terms' }) + ); + }); + + it('should default to null', () => { + expect(publicAPI.getMaxPossibleNumValues('non-existant')).toEqual(null); + }); + }); }); describe('#getErrorMessages', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 201dfa05b56b6..b1f02328242db 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -63,6 +63,7 @@ import { GenericIndexPatternColumn, getErrorMessages, insertNewColumn, + operationDefinitionMap, TermsIndexPatternColumn, } from './operations'; import { getReferenceRoot } from './operations/layer_helpers'; @@ -574,6 +575,15 @@ export function getIndexPatternDatasource({ timeRange ), getVisualDefaults: () => getVisualDefaultsForLayer(layer), + getMaxPossibleNumValues: (columnId) => { + if (layer && layer.columns[columnId]) { + const column = layer.columns[columnId]; + return ( + operationDefinitionMap[column.operationType].getMaxPossibleNumValues?.(column) ?? null + ); + } + return null; + }, }; }, getDatasourceSuggestionsForField(state, draggedField, filterLayers) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx index ef900ee1d7f8b..bde3dd05f2e1e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx @@ -423,4 +423,12 @@ describe('filters', () => { }); }); }); + + describe('getMaxPossibleNumValues', () => { + it('reports number of filters', () => { + expect( + filtersOperation.getMaxPossibleNumValues!(layer.columns.col1 as FiltersIndexPatternColumn) + ).toBe(2); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx index 5f3409602d93e..3890494be8b46 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx @@ -171,6 +171,8 @@ export const filtersOperation: OperationDefinition ); }, + + getMaxPossibleNumValues: (column) => column.params.filters.length, }; export const FilterList = ({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 8f3be3a59a836..2cbe6f5e0c827 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -411,6 +411,13 @@ interface BaseOperationDefinitionProps< aggs: ExpressionAstExpressionBuilder[]; esAggsIdMap: Record; }; + + /** + * Returns the maximum possible number of values for this column + * (e.g. with a top 5 values operation, we can be sure that there will never be + * more than 5 values returned or 6 if the "Other" bucket is enabled) + */ + getMaxPossibleNumValues?: (column: C) => number; } interface BaseBuildColumnArgs { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 9038b32bf15ee..cade55e9c2f7b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -1047,6 +1047,7 @@ export const termsOperation: OperationDefinition ); }, + getMaxPossibleNumValues: (column) => column.params.size + (column.params.otherBucket ? 1 : 0), }; function getLabelForRankFunctions(operationType: string) { switch (operationType) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index c4661d6799df5..424bdfd002522 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -34,6 +34,7 @@ import { DateHistogramIndexPatternColumn } from '../date_histogram'; import { getOperationSupportMatrix } from '../../../dimension_panel/operation_support'; import { FieldSelect } from '../../../dimension_panel/field_select'; import { ReferenceEditor } from '../../../dimension_panel/reference_editor'; +import { cloneDeep } from 'lodash'; import { IncludeExcludeRow } from './include_exclude_options'; // mocking random id generator function @@ -3074,4 +3075,20 @@ describe('terms', () => { ).toEqual(['unsupported']); }); }); + + describe('getMaxPossibleNumValues', () => { + it('reports correct number of values', () => { + const termsSize = 5; + + const withoutOther = cloneDeep(layer.columns.col1 as TermsIndexPatternColumn); + withoutOther.params.size = termsSize; + withoutOther.params.otherBucket = false; + + const withOther = cloneDeep(withoutOther); + withOther.params.otherBucket = true; + + expect(termsOperation.getMaxPossibleNumValues!(withoutOther)).toBe(termsSize); + expect(termsOperation.getMaxPossibleNumValues!(withOther)).toBe(termsSize + 1); + }); + }); }); diff --git a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx index dfa25c4b62fc7..b3a2e7207e77d 100644 --- a/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/dimension_editor.tsx @@ -79,13 +79,13 @@ export function MetricDimensionEditor( { - const { getMetricVisualization } = await import('../async_services'); + const { getLegacyMetricVisualization: getMetricVisualization } = await import( + '../async_services' + ); const palettes = await charts.palettes.getPalettes(); return getMetricVisualization({ paletteService: palettes, theme: core.theme }); diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/align_options.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/align_options.tsx index a400e42ac08f5..cf077e6eaa77f 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/align_options.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/align_options.tsx @@ -20,21 +20,21 @@ export const DEFAULT_TEXT_ALIGNMENT = 'left'; const alignButtonIcons = [ { id: `left`, - label: i18n.translate('xpack.lens.metricChart.alignLabel.left', { + label: i18n.translate('xpack.lens.legacyMetric.alignLabel.left', { defaultMessage: 'Align left', }), iconType: 'editorAlignLeft', }, { id: `center`, - label: i18n.translate('xpack.lens.metricChart.alignLabel.center', { + label: i18n.translate('xpack.lens.legacyMetric.alignLabel.center', { defaultMessage: 'Align center', }), iconType: 'editorAlignCenter', }, { id: `right`, - label: i18n.translate('xpack.lens.metricChart.alignLabel.right', { + label: i18n.translate('xpack.lens.legacyMetric.alignLabel.right', { defaultMessage: 'Align right', }), iconType: 'editorAlignRight', @@ -44,7 +44,7 @@ const alignButtonIcons = [ export const AlignOptions: React.FC = ({ state, setState }) => { return ( = ({ state, set display="columnCompressed" label={ <> - {i18n.translate('xpack.lens.metricChart.textFormattingLabel', { + {i18n.translate('xpack.lens.legacyMetric.textFormattingLabel', { defaultMessage: 'Text formatting', })} diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/title_position_option.tsx b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/title_position_option.tsx index c58854647c1e7..63abd195d6e5e 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/title_position_option.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/metric_config_panel/title_position_option.tsx @@ -20,11 +20,13 @@ export const DEFAULT_TITLE_POSITION = 'top'; const titlePositions = [ { id: 'top', - label: i18n.translate('xpack.lens.metricChart.titlePositions.top', { defaultMessage: 'Top' }), + label: i18n.translate('xpack.lens.legacyMetric.titlePositions.top', { + defaultMessage: 'Top', + }), }, { id: 'bottom', - label: i18n.translate('xpack.lens.metricChart.titlePositions.bottom', { + label: i18n.translate('xpack.lens.legacyMetric.titlePositions.bottom', { defaultMessage: 'Bottom', }), }, @@ -37,7 +39,7 @@ export const TitlePositionOptions: React.FC = ({ state, setS fullWidth label={ <> - {i18n.translate('xpack.lens.metricChart.titlePositionLabel', { + {i18n.translate('xpack.lens.legacyMetric.titlePositionLabel', { defaultMessage: 'Title position', })} 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 49346c48e9b16..4485571ffdb8d 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts @@ -9,7 +9,7 @@ import { SuggestionRequest, VisualizationSuggestion, TableSuggestion } from '../ import type { MetricState } from '../../common/types'; import { layerTypes } from '../../common'; import { LensIconChartMetric } from '../assets/chart_metric'; -import { supportedTypes } from './visualization'; +import { legacyMetricSupportedTypes } from './visualization'; /** * Generate suggestions for the metric chart. @@ -28,7 +28,7 @@ export function getSuggestions({ (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || table.columns.length !== 1 || table.columns[0].operation.isBucketed || - !supportedTypes.has(table.columns[0].operation.dataType) || + !legacyMetricSupportedTypes.has(table.columns[0].operation.dataType) || table.columns[0].operation.isStaticValue ) { return []; diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts index a27f6b9e849e8..d6b14e5e574c8 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getMetricVisualization } from './visualization'; +import { getLegacyMetricVisualization } from './visualization'; import type { MetricState } from '../../common/types'; import { layerTypes } from '../../common'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; @@ -35,7 +35,7 @@ function mockFrame(): FramePublicAPI { }; } -const metricVisualization = getMetricVisualization({ +const metricVisualization = getLegacyMetricVisualization({ paletteService: chartPluginMock.createPaletteRegistry(), theme: themeServiceMock.createStartContract(), }); diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index b751fc5bb274e..51087e9e44010 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -3,9 +3,7 @@ * 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 React from 'react'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n-react'; import { euiThemeVars } from '@kbn/ui-theme'; @@ -36,7 +34,7 @@ interface MetricConfig extends Omit { palette: PaletteOutput; } -export const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']); +export const legacyMetricSupportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']); const getFontSizeAndUnit = (fontSize: string) => { const [size, sizeUnit] = fontSize.split(/(\d+)/).filter(Boolean); @@ -171,7 +169,7 @@ const toExpression = ( }; }; -export const getMetricVisualization = ({ +export const getLegacyMetricVisualization = ({ paletteService, theme, }: { @@ -184,13 +182,12 @@ export const getMetricVisualization = ({ { id: 'lnsMetric', icon: LensIconChartMetric, - label: i18n.translate('xpack.lens.metric.label', { - defaultMessage: 'Metric', + label: i18n.translate('xpack.lens.legacyMetric.label', { + defaultMessage: 'Legacy Metric', }), - groupLabel: i18n.translate('xpack.lens.metric.groupLabel', { + groupLabel: i18n.translate('xpack.lens.legacyMetric.groupLabel', { defaultMessage: 'Goal and single value', }), - sortPriority: 3, }, ], @@ -212,8 +209,8 @@ export const getMetricVisualization = ({ getDescription() { return { icon: LensIconChartMetric, - label: i18n.translate('xpack.lens.metric.label', { - defaultMessage: 'Metric', + label: i18n.translate('xpack.lens.legacyMetric.label', { + defaultMessage: 'Legacy Metric', }), }; }, @@ -238,7 +235,9 @@ export const getMetricVisualization = ({ groups: [ { groupId: 'metric', - groupLabel: i18n.translate('xpack.lens.metric.label', { defaultMessage: 'Metric' }), + groupLabel: i18n.translate('xpack.lens.legacyMetric.label', { + defaultMessage: 'Legacy Metric', + }), layerId: props.state.layerId, accessors: props.state.accessor ? [ @@ -251,7 +250,7 @@ export const getMetricVisualization = ({ : [], supportsMoreColumns: !props.state.accessor, filterOperations: (op: OperationMetadata) => - !op.isBucketed && supportedTypes.has(op.dataType), + !op.isBucketed && legacyMetricSupportedTypes.has(op.dataType), enableDimensionEditor: true, required: true, }, @@ -263,7 +262,7 @@ export const getMetricVisualization = ({ return [ { type: layerTypes.DATA, - label: i18n.translate('xpack.lens.metric.addLayer', { + label: i18n.translate('xpack.lens.legacyMetric.addLayer', { defaultMessage: 'Visualization', }), }, diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index 2a87fa222910f..90bb1ea9f5cab 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -19,6 +19,7 @@ export function createMockDatasource(id: string): DatasourceMock { getVisualDefaults: jest.fn(), getSourceId: jest.fn(), getFilters: jest.fn(), + getMaxPossibleNumValues: jest.fn(), }; return { diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index ea07f1c2cdf0c..c66d538ed0511 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -56,9 +56,10 @@ import type { XyVisualizationPluginSetupPlugins, } from './xy_visualization'; import type { - MetricVisualization as MetricVisualizationType, - MetricVisualizationPluginSetupPlugins, + LegacyMetricVisualization as LegacyMetricVisualizationType, + LegacyMetricVisualizationPluginSetupPlugins, } from './metric_visualization'; +import type { MetricVisualization as MetricVisualizationType } from './visualizations/metric'; import type { DatatableVisualization as DatatableVisualizationType, DatatableVisualizationPluginSetupPlugins, @@ -223,6 +224,7 @@ export class LensPlugin { private queuedVisualizations: Array Promise)> = []; private indexpatternDatasource: IndexPatternDatasourceType | undefined; private xyVisualization: XyVisualizationType | undefined; + private legacyMetricVisualization: LegacyMetricVisualizationType | undefined; private metricVisualization: MetricVisualizationType | undefined; private pieVisualization: PieVisualizationType | undefined; private heatmapVisualization: HeatmapVisualizationType | undefined; @@ -400,6 +402,7 @@ export class LensPlugin { EditorFrameService, IndexPatternDatasource, XyVisualization, + LegacyMetricVisualization, MetricVisualization, PieVisualization, HeatmapVisualization, @@ -409,6 +412,7 @@ export class LensPlugin { this.editorFrameService = new EditorFrameService(); this.indexpatternDatasource = new IndexPatternDatasource(); this.xyVisualization = new XyVisualization(); + this.legacyMetricVisualization = new LegacyMetricVisualization(); this.metricVisualization = new MetricVisualization(); this.pieVisualization = new PieVisualization(); this.heatmapVisualization = new HeatmapVisualization(); @@ -419,7 +423,7 @@ export class LensPlugin { const dependencies: IndexPatternDatasourceSetupPlugins & XyVisualizationPluginSetupPlugins & DatatableVisualizationPluginSetupPlugins & - MetricVisualizationPluginSetupPlugins & + LegacyMetricVisualizationPluginSetupPlugins & PieVisualizationPluginSetupPlugins = { expressions, data, @@ -432,6 +436,7 @@ export class LensPlugin { this.indexpatternDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); this.datatableVisualization.setup(core, dependencies); + this.legacyMetricVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); this.pieVisualization.setup(core, dependencies); this.heatmapVisualization.setup(core, dependencies); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index f0d73d856655f..215c0ef273cbb 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -394,6 +394,13 @@ export interface DatasourcePublicAPI { lucene: Query[][]; } >; + + /** + * Returns the maximum possible number of values for this column when it can be known, otherwise null + * (e.g. with a top 5 values operation, we can be sure that there will never be more than 5 values returned + * or 6 if the "Other" bucket is enabled) + */ + getMaxPossibleNumValues: (columnId: string) => number | null; } export interface DatasourceDataPanelProps { @@ -762,6 +769,11 @@ export interface VisualizationType { showExperimentalBadge?: boolean; } +export interface VisualizationDisplayOptions { + noPanelTitle?: boolean; + noPadding?: boolean; +} + export interface Visualization { /** Plugin ID, such as "lnsXY" */ id: string; @@ -959,6 +971,11 @@ export interface Visualization { */ onEditAction?: (state: T, event: LensEditEvent) => T; + /** + * Gets custom display options for showing the visualization. + */ + getDisplayOptions?: () => VisualizationDisplayOptions; + /** * Get RenderEventCounters events for telemetry */ diff --git a/x-pack/plugins/lens/public/visualization_container.scss b/x-pack/plugins/lens/public/visualization_container.scss index 9fc16a0afc365..ba74fb711d967 100644 --- a/x-pack/plugins/lens/public/visualization_container.scss +++ b/x-pack/plugins/lens/public/visualization_container.scss @@ -27,4 +27,4 @@ .lnsSelectableErrorMessage { user-select: text; -} +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/visualizations/metric/__snapshots__/visualization.test.ts.snap b/x-pack/plugins/lens/public/visualizations/metric/__snapshots__/visualization.test.ts.snap new file mode 100644 index 0000000000000..62bb2fa3e3f67 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/__snapshots__/visualization.test.ts.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`metric visualization dimension groups configuration generates configuration 1`] = ` +Object { + "groups": Array [ + Object { + "accessors": Array [ + Object { + "columnId": "metric-col-id", + "palette": Array [], + "triggerIcon": "colorBy", + }, + ], + "enableDimensionEditor": true, + "filterOperations": [Function], + "groupId": "metric", + "groupLabel": "Primary metric", + "layerId": "first", + "required": true, + "supportFieldFormat": false, + "supportsMoreColumns": false, + }, + Object { + "accessors": Array [ + Object { + "columnId": "secondary-metric-col-id", + }, + ], + "enableDimensionEditor": true, + "filterOperations": [Function], + "groupId": "secondaryMetric", + "groupLabel": "Secondary metric", + "layerId": "first", + "required": false, + "supportFieldFormat": false, + "supportsMoreColumns": false, + }, + Object { + "accessors": Array [ + Object { + "columnId": "max-metric-col-id", + }, + ], + "enableDimensionEditor": true, + "filterOperations": [Function], + "groupId": "max", + "groupLabel": "Maximum value", + "groupTooltip": "If the maximum value is specified, the minimum value is fixed at zero.", + "layerId": "first", + "required": false, + "supportFieldFormat": false, + "supportStaticValue": true, + "supportsMoreColumns": false, + }, + Object { + "accessors": Array [ + Object { + "columnId": "breakdown-col-id", + "triggerIcon": "aggregate", + }, + ], + "enableDimensionEditor": true, + "filterOperations": [Function], + "groupId": "breakdownBy", + "groupLabel": "Break down by", + "layerId": "first", + "required": false, + "supportFieldFormat": false, + "supportsMoreColumns": false, + }, + ], +} +`; + +exports[`metric visualization dimension groups configuration operation filtering breakdownBy supports correct operations 1`] = ` +Array [ + Object { + "dataType": "number", + "isBucketed": true, + }, + Object { + "dataType": "string", + "isBucketed": true, + }, +] +`; + +exports[`metric visualization dimension groups configuration operation filtering max supports correct operations 1`] = ` +Array [ + Object { + "dataType": "number", + "isBucketed": false, + }, +] +`; + +exports[`metric visualization dimension groups configuration operation filtering metric supports correct operations 1`] = ` +Array [ + Object { + "dataType": "number", + "isBucketed": false, + }, +] +`; + +exports[`metric visualization dimension groups configuration operation filtering secondaryMetric supports correct operations 1`] = ` +Array [ + Object { + "dataType": "number", + "isBucketed": false, + }, +] +`; diff --git a/x-pack/plugins/lens/public/visualizations/metric/constants.ts b/x-pack/plugins/lens/public/visualizations/metric/constants.ts new file mode 100644 index 0000000000000..4a3830f10ec91 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/constants.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const LENS_METRIC_ID = 'lnsMetricNew'; // TODO - rename old one to "legacy" + +export const GROUP_ID = { + METRIC: 'metric', + SECONDARY_METRIC: 'secondaryMetric', + MAX: 'max', + BREAKDOWN_BY: 'breakdownBy', +} as const; diff --git a/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.test.tsx b/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.test.tsx new file mode 100644 index 0000000000000..093ae2540ba6c --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.test.tsx @@ -0,0 +1,397 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +import React, { ChangeEvent, FormEvent } from 'react'; +import { VisualizationDimensionEditorProps } from '../../types'; +import { CustomPaletteParams, PaletteOutput, PaletteRegistry } from '@kbn/coloring'; + +import { MetricVisualizationState } from './visualization'; +import { DimensionEditor } from './dimension_editor'; +import { HTMLAttributes, ReactWrapper, shallow } from 'enzyme'; +import { CollapseSetting } from '../../shared_components/collapse_setting'; +import { EuiButtonGroup, EuiColorPicker, EuiFieldText } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { LayoutDirection } from '@elastic/charts'; +import { act } from 'react-dom/test-utils'; +import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker'; +import { createMockFramePublicAPI } from '../../mocks'; +import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; + +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (fn: unknown) => fn, + }; +}); + +const SELECTORS = { + PRIMARY_METRIC_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_primary_metric"]', + SECONDARY_METRIC_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_secondary_metric"]', + BREAKDOWN_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_breakdown"]', +}; + +describe('dimension editor', () => { + const palette: PaletteOutput = { + type: 'palette', + name: 'foo', + params: { + rangeType: 'percent', + }, + }; + + const fullState: Required = { + layerId: 'first', + layerType: 'data', + metricAccessor: 'metric-col-id', + secondaryMetricAccessor: 'secondary-metric-col-id', + maxAccessor: 'max-metric-col-id', + breakdownByAccessor: 'breakdown-col-id', + collapseFn: 'sum', + subtitle: 'subtitle', + secondaryPrefix: 'extra-text', + progressDirection: 'vertical', + maxCols: 5, + color: 'static-color', + palette, + }; + + const mockedFrame = createMockFramePublicAPI(); + + const props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + } = { + layerId: 'first', + groupId: 'some-group', + accessor: 'some-accessor', + state: fullState, + frame: mockedFrame, + setState: jest.fn(), + panelRef: {} as React.MutableRefObject, + paletteService: chartPluginMock.createPaletteRegistry(), + }; + + describe('primary metric dimension', () => { + const accessor = 'primary-metric-col-id'; + + props.frame.activeData = { + first: { + type: 'datatable', + columns: [ + { + id: accessor, + name: 'foo', + meta: { + type: 'number', + }, + }, + ], + rows: [], + }, + }; + + class Harness { + public _wrapper; + + constructor( + wrapper: ReactWrapper> + ) { + this._wrapper = wrapper; + } + + private get rootComponent() { + return this._wrapper.find(DimensionEditor); + } + + public get colorPicker() { + return this._wrapper.find(EuiColorPicker); + } + + public get currentState() { + return this.rootComponent.props().state; + } + + public setColor(color: string) { + act(() => { + this.colorPicker.props().onChange!(color, {} as EuiColorPickerOutput); + }); + } + } + + const mockSetState = jest.fn(); + + const getHarnessWithState = (state: MetricVisualizationState) => + new Harness( + mountWithIntl( + + ) + ); + + it('renders when the accessor matches', () => { + const component = shallow( + + ); + + expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeTruthy(); + expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeFalsy(); + expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeFalsy(); + }); + + describe('static color controls', () => { + it('is hidden when dynamic coloring is enabled', () => { + const harnessWithPalette = getHarnessWithState({ ...fullState, palette }); + expect(harnessWithPalette.colorPicker.exists()).toBeFalsy(); + + const harnessNoPalette = getHarnessWithState({ ...fullState, palette: undefined }); + expect(harnessNoPalette.colorPicker.exists()).toBeTruthy(); + }); + + it('fills placeholder with default value', () => { + const localHarness = getHarnessWithState({ + ...fullState, + palette: undefined, + color: undefined, + }); + expect(localHarness.colorPicker.props().placeholder).toBe('Auto'); + }); + + it('sets color', () => { + const localHarness = getHarnessWithState({ + ...fullState, + palette: undefined, + color: 'some-color', + }); + + const newColor = 'new-color'; + localHarness.setColor(newColor + 1); + localHarness.setColor(newColor + 2); + localHarness.setColor(newColor + 3); + localHarness.setColor(''); + expect(mockSetState).toHaveBeenCalledTimes(4); + expect(mockSetState.mock.calls.map((args) => args[0].color)).toMatchInlineSnapshot(` + Array [ + "new-color1", + "new-color2", + "new-color3", + undefined, + ] + `); + }); + }); + }); + + describe('secondary metric dimension', () => { + const accessor = 'secondary-metric-col-id'; + + it('renders when the accessor matches', () => { + const component = shallow( + + ); + + expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeTruthy(); + expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeFalsy(); + expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeFalsy(); + }); + + it('sets metric prefix', () => { + const setState = jest.fn(); + const localState = { ...fullState, secondaryMetricAccessor: accessor }; + const component = shallow( + + ); + + const newVal = 'Metric explanation'; + component.find(EuiFieldText).props().onChange!({ + target: { value: newVal }, + } as ChangeEvent); + expect(setState).toHaveBeenCalledWith({ ...localState, secondaryPrefix: newVal }); + }); + }); + + describe('maximum dimension', () => { + const accessor = 'maximum-col-id'; + class Harness { + public _wrapper; + + constructor( + wrapper: ReactWrapper> + ) { + this._wrapper = wrapper; + } + + private get rootComponent() { + return this._wrapper.find(DimensionEditor); + } + + private get progressDirectionControl() { + return this._wrapper.find(EuiButtonGroup); + } + + public get currentState() { + return this.rootComponent.props().state; + } + + public setProgressDirection(direction: LayoutDirection) { + this.progressDirectionControl.props().onChange(direction); + this._wrapper.update(); + } + + public get progressDirectionDisabled() { + return this.progressDirectionControl.find(EuiButtonGroup).props().isDisabled; + } + + public setMaxCols(max: number) { + act(() => { + this._wrapper.find('EuiFieldNumber[data-test-subj="lnsMetric_max_cols"]').props() + .onChange!({ + target: { value: String(max) }, + } as unknown as FormEvent); + }); + } + } + + let harness: Harness; + const mockSetState = jest.fn(); + + beforeEach(() => { + harness = new Harness( + mountWithIntl( + + ) + ); + }); + + afterEach(() => mockSetState.mockClear()); + + it('toggles progress direction', () => { + expect(harness.currentState.progressDirection).toBe('vertical'); + + harness.setProgressDirection('horizontal'); + harness.setProgressDirection('vertical'); + harness.setProgressDirection('horizontal'); + + expect(mockSetState).toHaveBeenCalledTimes(3); + expect(mockSetState.mock.calls.map((args) => args[0].progressDirection)) + .toMatchInlineSnapshot(` + Array [ + "horizontal", + "vertical", + "horizontal", + ] + `); + }); + }); + + describe('breakdown-by dimension', () => { + const accessor = 'breakdown-col-id'; + + class Harness { + public _wrapper; + + constructor( + wrapper: ReactWrapper> + ) { + this._wrapper = wrapper; + } + + private get collapseSetting() { + return this._wrapper.find(CollapseSetting); + } + + public get currentCollapseFn() { + return this.collapseSetting.props().value; + } + + public setCollapseFn(fn: string) { + return this.collapseSetting.props().onChange(fn); + } + + public setMaxCols(max: number) { + act(() => { + this._wrapper.find('EuiFieldNumber[data-test-subj="lnsMetric_max_cols"]').props() + .onChange!({ + target: { value: String(max) }, + } as unknown as FormEvent); + }); + } + } + + let harness: Harness; + const mockSetState = jest.fn(); + + beforeEach(() => { + harness = new Harness( + mountWithIntl( + + ) + ); + }); + + afterEach(() => mockSetState.mockClear()); + + it('renders when the accessor matches', () => { + const component = shallow( + + ); + + expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeTruthy(); + expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeFalsy(); + expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeFalsy(); + }); + + it('supports setting a collapse function', () => { + expect(harness.currentCollapseFn).toBe(fullState.collapseFn); + const newCollapseFunc = 'min'; + harness.setCollapseFn(newCollapseFunc); + expect(mockSetState).toHaveBeenCalledWith({ ...fullState, collapseFn: newCollapseFunc }); + }); + + it('sets max columns', () => { + harness.setMaxCols(1); + harness.setMaxCols(2); + harness.setMaxCols(3); + expect(mockSetState).toHaveBeenCalledTimes(3); + expect(mockSetState.mock.calls.map((args) => args[0].maxCols)).toMatchInlineSnapshot(` + Array [ + 1, + 2, + 3, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.tsx new file mode 100644 index 0000000000000..e8a72b91f4570 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.tsx @@ -0,0 +1,405 @@ +/* + * 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, + EuiFieldText, + EuiButtonGroup, + EuiFieldNumber, + htmlIdGenerator, + EuiColorPicker, + euiPaletteColorBlind, +} from '@elastic/eui'; +import { LayoutDirection } from '@elastic/charts'; +import React, { useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + PaletteRegistry, + CustomizablePalette, + FIXED_PROGRESSION, + DEFAULT_MAX_STOP, + DEFAULT_MIN_STOP, +} from '@kbn/coloring'; +import { getDataBoundsForPalette } from '@kbn/expression-metric-vis-plugin/public'; +import { css } from '@emotion/react'; +import { isNumericFieldForDatatable } from '../../../common/expressions'; +import { + applyPaletteParams, + PalettePanelContainer, + useDebouncedValue, +} from '../../shared_components'; +import type { VisualizationDimensionEditorProps } from '../../types'; +import { defaultNumberPaletteParams, defaultPercentagePaletteParams } from './palette_config'; +import { DEFAULT_MAX_COLUMNS, MetricVisualizationState } from './visualization'; +import { CollapseSetting } from '../../shared_components/collapse_setting'; + +type Props = VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; +}; + +export function DimensionEditor(props: Props) { + const { state, setState, accessor } = props; + + const setPrefix = useCallback( + (prefix: string) => setState({ ...state, secondaryPrefix: prefix }), + [setState, state] + ); + + const { inputValue: prefixInputVal, handleInputChange: handlePrefixChange } = + useDebouncedValue( + { + onChange: setPrefix, + value: state.secondaryPrefix || '', + }, + { allowFalsyValue: true } + ); + + switch (accessor) { + case state?.metricAccessor: + return ( +
+ +
+ ); + case state.secondaryMetricAccessor: + return ( +
+ + handlePrefixChange(value)} + /> + +
+ ); + case state.maxAccessor: + return ( +
+ +
+ ); + case state.breakdownByAccessor: + return ( +
+ +
+ ); + default: + return null; + } +} + +function BreakdownByEditor({ setState, state }: Props) { + const setMaxCols = useCallback( + (columns: string) => { + setState({ ...state, maxCols: parseInt(columns, 10) }); + }, + [setState, state] + ); + + const { inputValue: currentMaxCols, handleInputChange: handleMaxColsChange } = + useDebouncedValue({ + onChange: setMaxCols, + value: String(state.maxCols ?? DEFAULT_MAX_COLUMNS), + }); + + return ( + <> + + handleMaxColsChange(value)} + /> + + { + setState({ + ...state, + collapseFn, + }); + }} + /> + + ); +} + +function MaximumEditor({ setState, state }: Props) { + const idPrefix = htmlIdGenerator()(); + return ( + + { + const newDirection = id.replace(idPrefix, '') as LayoutDirection; + setState({ + ...state, + progressDirection: newDirection, + }); + }} + /> + + ); +} + +function PrimaryMetricEditor(props: Props) { + const { state, setState, frame, accessor } = props; + + const [isPaletteOpen, setIsPaletteOpen] = useState(false); + + const currentData = frame.activeData?.[state.layerId]; + + if (accessor == null || !isNumericFieldForDatatable(currentData, accessor)) { + return null; + } + + const hasDynamicColoring = Boolean(state?.palette); + + const startWithPercentPalette = Boolean(state.maxAccessor || state.breakdownByAccessor); + + const activePalette = state?.palette || { + type: 'palette', + name: (startWithPercentPalette ? defaultPercentagePaletteParams : defaultNumberPaletteParams) + .name, + params: { + ...(startWithPercentPalette ? defaultPercentagePaletteParams : defaultNumberPaletteParams), + }, + }; + + const currentMinMax = getDataBoundsForPalette( + { + metric: state.metricAccessor!, + max: state.maxAccessor, + breakdownBy: state.breakdownByAccessor, + }, + frame.activeData?.[state.layerId] + ); + + const displayStops = applyPaletteParams(props.paletteService, activePalette, { + min: currentMinMax.min ?? DEFAULT_MIN_STOP, + max: currentMinMax.max ?? DEFAULT_MAX_STOP, + }); + + const togglePalette = () => setIsPaletteOpen(!isPaletteOpen); + + const idPrefix = htmlIdGenerator()(); + return ( + <> + + { + const colorMode = id.replace(idPrefix, '') as 'static' | 'dynamic'; + + const params = + colorMode === 'dynamic' + ? { + palette: { + ...activePalette, + params: { + ...activePalette.params, + stops: displayStops, + }, + }, + } + : { + palette: undefined, + }; + setState({ + ...state, + ...params, + }); + }} + /> + + {!hasDynamicColoring && } + {hasDynamicColoring && ( + <> + + + + color)} + type={FIXED_PROGRESSION} + onClick={togglePalette} + /> + + + + {i18n.translate('xpack.lens.paletteTableGradient.customize', { + defaultMessage: 'Edit', + })} + + + { + setState({ + ...state, + palette: newPalette, + }); + }} + /> + + + + + + )} + + ); +} + +function StaticColorControls({ state, setState }: Pick) { + const colorLabel = i18n.translate('xpack.lens.metric.color', { + defaultMessage: 'Color', + }); + + const setColor = useCallback( + (color: string) => { + setState({ ...state, color: color === '' ? undefined : color }); + }, + [setState, state] + ); + + const { inputValue: currentColor, handleInputChange: handleColorChange } = + useDebouncedValue( + { + onChange: setColor, + value: state.color || '', + }, + { allowFalsyValue: true } + ); + + return ( + + handleColorChange(color)} + color={currentColor} + placeholder={i18n.translate('xpack.lens.metric.colorPlaceholder', { + defaultMessage: 'Auto', + })} + aria-label={colorLabel} + showAlpha={false} + swatches={euiPaletteColorBlind()} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/visualizations/metric/index.ts b/x-pack/plugins/lens/public/visualizations/metric/index.ts new file mode 100644 index 0000000000000..acc7b1a51cb3a --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/index.ts @@ -0,0 +1,26 @@ +/* + * 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 '@kbn/core/public'; +import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import type { EditorFrameSetup } from '../../types'; + +export interface MetricVisualizationPluginSetupPlugins { + editorFrame: EditorFrameSetup; + charts: ChartsPluginSetup; +} + +export class MetricVisualization { + setup(core: CoreSetup, { editorFrame, charts }: MetricVisualizationPluginSetupPlugins) { + editorFrame.registerVisualization(async () => { + const { getMetricVisualization } = await import('../../async_services'); + const palettes = await charts.palettes.getPalettes(); + + return getMetricVisualization({ paletteService: palettes }); + }); + } +} diff --git a/x-pack/plugins/lens/public/visualizations/metric/metric_visualization.ts b/x-pack/plugins/lens/public/visualizations/metric/metric_visualization.ts new file mode 100644 index 0000000000000..78f082b8c0e29 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/metric_visualization.ts @@ -0,0 +1,8 @@ +/* + * 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 './visualization'; diff --git a/x-pack/plugins/lens/public/visualizations/metric/palette_config.tsx b/x-pack/plugins/lens/public/visualizations/metric/palette_config.tsx new file mode 100644 index 0000000000000..ee8497ec02c5e --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/palette_config.tsx @@ -0,0 +1,35 @@ +/* + * 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 '@kbn/coloring'; +import { defaultPaletteParams as sharedDefaultParams } from '../../shared_components'; + +export const RANGE_MIN = 0; + +export const defaultPercentagePaletteParams: RequiredPaletteParamTypes = { + ...sharedDefaultParams, + name: 'status', + rangeType: 'percent', + steps: 3, + maxSteps: 5, + continuity: 'all', + colorStops: [], + stops: [], +}; + +export const defaultNumberPaletteParams: RequiredPaletteParamTypes = { + ...sharedDefaultParams, + name: 'status', + rangeType: 'number', + rangeMin: -Infinity, + rangeMax: Infinity, + steps: 3, + maxSteps: 5, + continuity: 'all', + colorStops: [], + stops: [], +}; diff --git a/x-pack/plugins/lens/public/visualizations/metric/suggestions.test.ts b/x-pack/plugins/lens/public/visualizations/metric/suggestions.test.ts new file mode 100644 index 0000000000000..6276e6d3b9b77 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/suggestions.test.ts @@ -0,0 +1,359 @@ +/* + * 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 { layerTypes } from '../../../common'; +import { MetricVisualizationState } from './visualization'; +import { LensIconChartMetric } from '../../assets/chart_metric'; + +const metricColumn = { + columnId: 'metric-column', + operation: { + isBucketed: false, + dataType: 'number' as const, + scale: 'ratio' as const, + label: 'Metric', + }, +}; + +const bucketColumn = { + columnId: 'top-values-col', + operation: { + isBucketed: true, + dataType: 'string' as const, + label: 'Top Values', + }, +}; + +describe('metric suggestions', () => { + describe('no suggestions', () => { + test('layer mismatch', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn], + changeType: 'unchanged', + }, + keptLayerIds: ['unknown-layer'], + }) + ).toHaveLength(0); + }); + + test('too many bucketed columns', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + bucketColumn, + // one too many + { + ...bucketColumn, + columnId: 'metric-column2', + }, + ], + changeType: 'unchanged', + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + test('too many metric columns', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + metricColumn, + // one too many + { + ...metricColumn, + columnId: 'metric-column2', + }, + ], + changeType: 'unchanged', + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + test('table includes a column of an unsupported format', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + metricColumn, + { + ...metricColumn, + columnId: 'metric-column2', + }, + ], + changeType: 'unchanged', + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + test('unchanged data when active visualization', () => { + const unchangedSuggestion = { + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn], + changeType: 'unchanged' as const, + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + } as MetricVisualizationState, + keptLayerIds: ['first'], + }; + expect(getSuggestions(unchangedSuggestion)).toHaveLength(0); + }); + }); + + describe('when active visualization', () => { + describe('initial change (e.g. dragging first field to workspace)', () => { + test('maps metric column to primary metric', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn], + changeType: 'initial', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + } as MetricVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + layerType: layerTypes.DATA, + metricAccessor: metricColumn.columnId, + // should ignore bucketed column for initial drag + }, + title: 'Metric', + hide: true, + previewIcon: LensIconChartMetric, + score: 0.51, + }, + ]); + }); + + test('maps bucketed column to breakdown-by dimension', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [bucketColumn], + changeType: 'initial', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + } as MetricVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + layerType: layerTypes.DATA, + breakdownByAccessor: bucketColumn.columnId, + }, + title: 'Metric', + hide: true, + previewIcon: LensIconChartMetric, + score: 0.51, + }, + ]); + }); + + test('drops mapped columns that do not exist anymore on the table', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [bucketColumn], + changeType: 'initial', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + metricAccessor: 'non_existent', + } as MetricVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + layerType: layerTypes.DATA, + metricAccessor: undefined, + breakdownByAccessor: bucketColumn.columnId, + }, + title: 'Metric', + hide: true, + previewIcon: LensIconChartMetric, + score: 0.51, + }, + ]); + }); + + test('drops excludes max and secondary metric dimensions from suggestions', () => { + const suggestedState = getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn], + changeType: 'extended', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + secondaryMetricAccessor: 'some-accessor', + maxAccessor: 'some-accessor', + } as MetricVisualizationState, + keptLayerIds: ['first'], + })[0].state; + + expect(suggestedState.secondaryMetricAccessor).toBeUndefined(); + expect(suggestedState.maxAccessor).toBeUndefined(); + }); + + test('no suggestions for tables with both metric and bucket', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn, bucketColumn], + changeType: 'initial', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + } as MetricVisualizationState, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + }); + + describe('extending (e.g. dragging subsequent fields to workspace)', () => { + test('maps metric column to primary metric', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [bucketColumn, metricColumn], + changeType: 'extended', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + breakdownByAccessor: bucketColumn.columnId, + } as MetricVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + layerType: layerTypes.DATA, + metricAccessor: metricColumn.columnId, + breakdownByAccessor: bucketColumn.columnId, + }, + title: 'Metric', + hide: true, + previewIcon: LensIconChartMetric, + score: 0.52, + }, + ]); + }); + + test('maps bucketed column to breakdown-by dimension', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn, bucketColumn], + changeType: 'extended', + }, + state: { + layerId: 'first', + layerType: layerTypes.DATA, + metricAccessor: metricColumn.columnId, + } as MetricVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + layerType: layerTypes.DATA, + metricAccessor: metricColumn.columnId, + breakdownByAccessor: bucketColumn.columnId, + }, + title: 'Metric', + hide: true, + previewIcon: LensIconChartMetric, + score: 0.52, + }, + ]); + }); + }); + }); + + describe('when NOT active visualization', () => { + test('maps metric and bucket columns to primary metric and breakdown', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [metricColumn, bucketColumn], + changeType: 'unchanged', // doesn't matter + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + layerType: layerTypes.DATA, + metricAccessor: metricColumn.columnId, + breakdownByAccessor: bucketColumn.columnId, + }, + title: 'Metric', + hide: true, + previewIcon: LensIconChartMetric, + score: 0.52, + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/metric/suggestions.ts b/x-pack/plugins/lens/public/visualizations/metric/suggestions.ts new file mode 100644 index 0000000000000..cdb11812bc4b8 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/suggestions.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TableSuggestion, Visualization } from '../../types'; +import { LensIconChartMetric } from '../../assets/chart_metric'; +import { layerTypes } from '../../../common'; +import { metricLabel, MetricVisualizationState, supportedDataTypes } from './visualization'; + +const MAX_BUCKETED_COLUMNS = 1; +const MAX_METRIC_COLUMNS = 1; + +const hasLayerMismatch = (keptLayerIds: string[], table: TableSuggestion) => + keptLayerIds.length > 1 || (keptLayerIds.length && table.layerId !== keptLayerIds[0]); + +export const getSuggestions: Visualization['getSuggestions'] = ({ + table, + state, + keptLayerIds, +}) => { + const isActive = Boolean(state); + + const metricColumns = table.columns.filter( + ({ operation }) => supportedDataTypes.has(operation.dataType) && !operation.isBucketed + ); + + const bucketedColumns = table.columns.filter(({ operation }) => operation.isBucketed); + + const unsupportedColumns = table.columns.filter( + ({ operation }) => !supportedDataTypes.has(operation.dataType) && !operation.isBucketed + ); + + const couldNeverFit = + unsupportedColumns.length || + bucketedColumns.length > MAX_BUCKETED_COLUMNS || + metricColumns.length > MAX_METRIC_COLUMNS; + + if ( + !table.columns.length || + hasLayerMismatch(keptLayerIds, table) || + couldNeverFit || + // dragging the first field + (isActive && + table.changeType === 'initial' && + metricColumns.length && + bucketedColumns.length) || + (isActive && table.changeType === 'unchanged') + ) { + return []; + } + + const baseSuggestion = { + state: { + ...state, + layerId: table.layerId, + layerType: layerTypes.DATA, + }, + title: metricLabel, + previewIcon: LensIconChartMetric, + score: 0.5, + // don't show suggestions since we're in tech preview + hide: true, + }; + + const accessorMappings: Pick = + { + metricAccessor: metricColumns[0]?.columnId, + breakdownByAccessor: bucketedColumns[0]?.columnId, + }; + + baseSuggestion.score += 0.01 * Object.values(accessorMappings).filter(Boolean).length; + + const suggestion = { + ...baseSuggestion, + state: { + ...baseSuggestion.state, + ...accessorMappings, + secondaryMetricAccessor: undefined, + maxAccessor: undefined, + }, + }; + + return [suggestion]; +}; diff --git a/x-pack/plugins/lens/public/visualizations/metric/toolbar.test.tsx b/x-pack/plugins/lens/public/visualizations/metric/toolbar.test.tsx new file mode 100644 index 0000000000000..130c0555af073 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/toolbar.test.tsx @@ -0,0 +1,119 @@ +/* + * 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, { ChangeEvent } from 'react'; +import { CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { Toolbar } from './toolbar'; +import { MetricVisualizationState } from './visualization'; +import { createMockFramePublicAPI } from '../../mocks'; +import { HTMLAttributes, ReactWrapper } from 'enzyme'; +import { EuiFieldText } from '@elastic/eui'; +import { ToolbarButton } from '@kbn/kibana-react-plugin/public'; +import { act } from 'react-dom/test-utils'; + +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (fn: unknown) => fn, + }; +}); + +describe('metric toolbar', () => { + const palette: PaletteOutput = { + type: 'palette', + name: 'foo', + params: { + rangeType: 'percent', + }, + }; + + const fullState: Required = { + layerId: 'first', + layerType: 'data', + metricAccessor: 'metric-col-id', + secondaryMetricAccessor: 'secondary-metric-col-id', + maxAccessor: 'max-metric-col-id', + breakdownByAccessor: 'breakdown-col-id', + collapseFn: 'sum', + subtitle: 'subtitle', + secondaryPrefix: 'extra-text', + progressDirection: 'vertical', + maxCols: 5, + color: 'static-color', + palette, + }; + + const frame = createMockFramePublicAPI(); + + class Harness { + public _wrapper; + + constructor(wrapper: ReactWrapper>) { + this._wrapper = wrapper; + } + + private get subtitleField() { + return this._wrapper.find(EuiFieldText); + } + + public get textOptionsButton() { + const toolbarButtons = this._wrapper.find(ToolbarButton); + return toolbarButtons.at(0); + } + + public toggleOpenTextOptions() { + this.textOptionsButton.simulate('click'); + } + + public setSubtitle(subtitle: string) { + act(() => { + this.subtitleField.props().onChange!({ + target: { value: subtitle }, + } as unknown as ChangeEvent); + }); + } + } + + const mockSetState = jest.fn(); + + const getHarnessWithState = (state: MetricVisualizationState) => + new Harness(mountWithIntl()); + + afterEach(() => mockSetState.mockClear()); + + describe('text options', () => { + it('sets a subtitle', () => { + const localHarness = getHarnessWithState({ ...fullState, breakdownByAccessor: undefined }); + + localHarness.toggleOpenTextOptions(); + + const newSubtitle = 'new subtitle hey'; + localHarness.setSubtitle(newSubtitle + ' 1'); + localHarness.setSubtitle(newSubtitle + ' 2'); + localHarness.setSubtitle(newSubtitle + ' 3'); + expect(mockSetState.mock.calls.map(([state]) => state.subtitle)).toMatchInlineSnapshot(` + Array [ + "new subtitle hey 1", + "new subtitle hey 2", + "new subtitle hey 3", + ] + `); + }); + + it('hides text options when has breakdown by', () => { + expect( + getHarnessWithState({ + ...fullState, + breakdownByAccessor: 'some-accessor', + }).textOptionsButton.exists() + ).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/metric/toolbar.tsx b/x-pack/plugins/lens/public/visualizations/metric/toolbar.tsx new file mode 100644 index 0000000000000..342bb0a30d576 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/toolbar.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 React, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { VisualizationToolbarProps } from '../../types'; +import { ToolbarPopover, useDebouncedValue } from '../../shared_components'; +import { MetricVisualizationState } from './visualization'; + +export function Toolbar(props: VisualizationToolbarProps) { + const { state, setState } = props; + + const setSubtitle = useCallback( + (prefix: string) => setState({ ...state, subtitle: prefix }), + [setState, state] + ); + + const { inputValue: subtitleInputVal, handleInputChange: handleSubtitleChange } = + useDebouncedValue( + { + onChange: setSubtitle, + value: state.subtitle || '', + }, + { allowFalsyValue: true } + ); + + const hasBreakdownBy = Boolean(state.breakdownByAccessor); + + return ( + + {!hasBreakdownBy && ( + + + handleSubtitleChange(value)} + /> + + + )} + + ); +} diff --git a/x-pack/plugins/lens/public/visualizations/metric/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/metric/visualization.test.ts new file mode 100644 index 0000000000000..a919652b7c520 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/visualization.test.ts @@ -0,0 +1,635 @@ +/* + * 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 { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; +import { CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; +import { ExpressionAstExpression, ExpressionAstFunction } from '@kbn/expressions-plugin/common'; +import { euiLightVars } from '@kbn/ui-theme'; +import { layerTypes } from '../..'; +import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; +import { + DatasourceLayers, + DatasourcePublicAPI, + OperationDescriptor, + OperationMetadata, + Visualization, +} from '../../types'; +import { GROUP_ID } from './constants'; +import { getMetricVisualization, MetricVisualizationState } from './visualization'; + +const paletteService = chartPluginMock.createPaletteRegistry(); + +describe('metric visualization', () => { + const visualization = getMetricVisualization({ + paletteService, + }); + + const palette: PaletteOutput = { + type: 'palette', + name: 'foo', + params: { + rangeType: 'percent', + }, + }; + + const fullState: Required = { + layerId: 'first', + layerType: 'data', + metricAccessor: 'metric-col-id', + secondaryMetricAccessor: 'secondary-metric-col-id', + maxAccessor: 'max-metric-col-id', + breakdownByAccessor: 'breakdown-col-id', + collapseFn: 'sum', + subtitle: 'subtitle', + secondaryPrefix: 'extra-text', + progressDirection: 'vertical', + maxCols: 5, + color: 'static-color', + palette, + }; + + const mockFrameApi = createMockFramePublicAPI(); + + describe('initialization', () => { + test('returns a default state', () => { + expect(visualization.initialize(() => 'some-id')).toEqual({ + layerId: 'some-id', + layerType: layerTypes.DATA, + }); + }); + + test('returns persisted state', () => { + expect(visualization.initialize(() => fullState.layerId, fullState)).toEqual(fullState); + }); + }); + + describe('dimension groups configuration', () => { + test('generates configuration', () => { + expect( + visualization.getConfiguration({ + state: fullState, + layerId: fullState.layerId, + frame: mockFrameApi, + }) + ).toMatchSnapshot(); + }); + + test('color-by-value', () => { + expect( + visualization.getConfiguration({ + state: fullState, + layerId: fullState.layerId, + frame: mockFrameApi, + }).groups[0].accessors + ).toMatchInlineSnapshot(` + Array [ + Object { + "columnId": "metric-col-id", + "palette": Array [], + "triggerIcon": "colorBy", + }, + ] + `); + + expect( + visualization.getConfiguration({ + state: { ...fullState, palette: undefined }, + layerId: fullState.layerId, + frame: mockFrameApi, + }).groups[0].accessors + ).toMatchInlineSnapshot(` + Array [ + Object { + "columnId": "metric-col-id", + "palette": undefined, + "triggerIcon": undefined, + }, + ] + `); + }); + + test('collapse function', () => { + expect( + visualization.getConfiguration({ + state: fullState, + layerId: fullState.layerId, + frame: mockFrameApi, + }).groups[3].accessors + ).toMatchInlineSnapshot(` + Array [ + Object { + "columnId": "breakdown-col-id", + "triggerIcon": "aggregate", + }, + ] + `); + + expect( + visualization.getConfiguration({ + state: { ...fullState, collapseFn: undefined }, + layerId: fullState.layerId, + frame: mockFrameApi, + }).groups[3].accessors + ).toMatchInlineSnapshot(` + Array [ + Object { + "columnId": "breakdown-col-id", + "triggerIcon": undefined, + }, + ] + `); + }); + + describe('operation filtering', () => { + const unsupportedDataType = 'string'; + + const operations: OperationMetadata[] = [ + { + isBucketed: true, + dataType: 'number', + }, + { + isBucketed: true, + dataType: unsupportedDataType, + }, + { + isBucketed: false, + dataType: 'number', + }, + { + isBucketed: false, + dataType: unsupportedDataType, + }, + ]; + + const testConfig = visualization + .getConfiguration({ + state: fullState, + layerId: fullState.layerId, + frame: mockFrameApi, + }) + .groups.map(({ groupId, filterOperations }) => [groupId, filterOperations]); + + it.each(testConfig)('%s supports correct operations', (_, filterFn) => { + expect( + operations.filter(filterFn as (operation: OperationMetadata) => boolean) + ).toMatchSnapshot(); + }); + }); + }); + + describe('generating an expression', () => { + const maxPossibleNumValues = 7; + let datasourceLayers: DatasourceLayers; + beforeEach(() => { + const mockDatasource = createMockDatasource('testDatasource'); + mockDatasource.publicAPIMock.getMaxPossibleNumValues.mockReturnValue(maxPossibleNumValues); + mockDatasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + isStaticValue: false, + } as OperationDescriptor); + + datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + it('is null when no metric accessor', () => { + const state: MetricVisualizationState = { + layerId: 'first', + layerType: 'data', + metricAccessor: undefined, + }; + + expect(visualization.toExpression(state, datasourceLayers)).toBeNull(); + }); + + it('builds single metric', () => { + expect( + visualization.toExpression( + { + ...fullState, + breakdownByAccessor: undefined, + collapseFn: undefined, + }, + datasourceLayers + ) + ).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object { + "breakdownBy": Array [], + "color": Array [ + "static-color", + ], + "max": Array [ + "max-metric-col-id", + ], + "maxCols": Array [ + 5, + ], + "metric": Array [ + "metric-col-id", + ], + "minTiles": Array [], + "palette": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "name": Array [ + "mocked", + ], + }, + "function": "system_palette", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "progressDirection": Array [ + "vertical", + ], + "secondaryMetric": Array [ + "secondary-metric-col-id", + ], + "secondaryPrefix": Array [ + "extra-text", + ], + "subtitle": Array [ + "subtitle", + ], + }, + "function": "metricVis", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + + it('builds breakdown by metric', () => { + expect(visualization.toExpression({ ...fullState, collapseFn: undefined }, datasourceLayers)) + .toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object { + "breakdownBy": Array [ + "breakdown-col-id", + ], + "color": Array [ + "static-color", + ], + "max": Array [ + "max-metric-col-id", + ], + "maxCols": Array [ + 5, + ], + "metric": Array [ + "metric-col-id", + ], + "minTiles": Array [ + 7, + ], + "palette": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "name": Array [ + "mocked", + ], + }, + "function": "system_palette", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "progressDirection": Array [ + "vertical", + ], + "secondaryMetric": Array [ + "secondary-metric-col-id", + ], + "secondaryPrefix": Array [ + "extra-text", + ], + "subtitle": Array [ + "subtitle", + ], + }, + "function": "metricVis", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + + describe('with collapse function', () => { + it('builds breakdown by metric with collapse function', () => { + const ast = visualization.toExpression( + { + ...fullState, + collapseFn: 'sum', + // Turning off an accessor to make sure it gets filtered out from the collapse arguments + secondaryMetricAccessor: undefined, + }, + datasourceLayers + ) as ExpressionAstExpression; + + expect(ast.chain).toHaveLength(2); + expect(ast.chain[0]).toMatchInlineSnapshot(` + Object { + "arguments": Object { + "by": Array [], + "fn": Array [ + "sum", + "sum", + ], + "metric": Array [ + "metric-col-id", + "max-metric-col-id", + ], + }, + "function": "lens_collapse", + "type": "function", + } + `); + expect(ast.chain[1].arguments.minTiles).toHaveLength(0); + expect(ast.chain[1].arguments.breakdownBy).toHaveLength(0); + }); + + it('always applies max function to static max dimensions', () => { + ( + datasourceLayers.first as jest.Mocked + ).getOperationForColumnId.mockReturnValueOnce({ + isStaticValue: true, + } as OperationDescriptor); + + const ast = visualization.toExpression( + { + ...fullState, + collapseFn: 'sum', // this should be overridden for the max dimension + }, + datasourceLayers + ) as ExpressionAstExpression; + + expect(ast.chain).toHaveLength(2); + expect(ast.chain[0]).toMatchInlineSnapshot(` + Object { + "arguments": Object { + "by": Array [], + "fn": Array [ + "sum", + "sum", + "max", + ], + "metric": Array [ + "metric-col-id", + "secondary-metric-col-id", + "max-metric-col-id", + ], + }, + "function": "lens_collapse", + "type": "function", + } + `); + }); + }); + + it('incorporates datasource expression if provided', () => { + const datasourceFn: ExpressionAstFunction = { + type: 'function', + function: 'some-data-function', + arguments: {}, + }; + + const datasourceExpressionsByLayers: Record = { + first: { type: 'expression', chain: [datasourceFn] }, + }; + + const ast = visualization.toExpression( + fullState, + datasourceLayers, + {}, + datasourceExpressionsByLayers + ) as ExpressionAstExpression; + + expect(ast.chain).toHaveLength(3); + expect(ast.chain[0]).toEqual(datasourceFn); + }); + + describe('static color', () => { + it('uses color from state', () => { + const color = 'color-fun'; + + expect( + ( + visualization.toExpression( + { + ...fullState, + color, + }, + datasourceLayers + ) as ExpressionAstExpression + ).chain[1].arguments.color[0] + ).toBe(color); + }); + + it('can use a default color', () => { + expect( + ( + visualization.toExpression( + { + ...fullState, + color: undefined, + }, + datasourceLayers + ) as ExpressionAstExpression + ).chain[1].arguments.color[0] + ).toBe(euiLightVars.euiColorPrimary); + + expect( + ( + visualization.toExpression( + { + ...fullState, + maxAccessor: undefined, + color: undefined, + }, + datasourceLayers + ) as ExpressionAstExpression + ).chain[1].arguments.color + ).toEqual([]); + }); + }); + }); + + it('clears a layer', () => { + expect(visualization.clearLayer(fullState, 'some-id')).toMatchInlineSnapshot(` + Object { + "color": "static-color", + "layerId": "first", + "layerType": "data", + "maxCols": 5, + "progressDirection": "vertical", + "subtitle": "subtitle", + } + `); + }); + + test('getLayerIds returns the single layer ID', () => { + expect(visualization.getLayerIds(fullState)).toEqual([fullState.layerId]); + }); + + it('gives a description', () => { + expect(visualization.getDescription(fullState)).toMatchInlineSnapshot(` + Object { + "icon": [Function], + "label": "Metric", + } + `); + }); + + describe('getting supported layers', () => { + it('works without state', () => { + const supportedLayers = visualization.getSupportedLayers(); + expect(supportedLayers[0].initialDimensions).toBeUndefined(); + expect(supportedLayers).toMatchInlineSnapshot(` + Array [ + Object { + "initialDimensions": undefined, + "label": "Visualization", + "type": "data", + }, + ] + `); + }); + + it('includes max static value dimension when state provided', () => { + const supportedLayers = visualization.getSupportedLayers(fullState); + expect(supportedLayers[0].initialDimensions).toHaveLength(1); + expect(supportedLayers[0].initialDimensions![0]).toEqual( + expect.objectContaining({ + groupId: GROUP_ID.MAX, + staticValue: 0, + }) + ); + }); + }); + + it('sets dimensions', () => { + const state = {} as MetricVisualizationState; + const columnId = 'col-id'; + expect( + visualization.setDimension({ + prevState: state, + columnId, + groupId: GROUP_ID.METRIC, + layerId: 'some-id', + frame: mockFrameApi, + }) + ).toEqual({ + metricAccessor: columnId, + }); + expect( + visualization.setDimension({ + prevState: state, + columnId, + groupId: GROUP_ID.SECONDARY_METRIC, + layerId: 'some-id', + frame: mockFrameApi, + }) + ).toEqual({ + secondaryMetricAccessor: columnId, + }); + expect( + visualization.setDimension({ + prevState: state, + columnId, + groupId: GROUP_ID.MAX, + layerId: 'some-id', + frame: mockFrameApi, + }) + ).toEqual({ + maxAccessor: columnId, + }); + expect( + visualization.setDimension({ + prevState: state, + columnId, + groupId: GROUP_ID.BREAKDOWN_BY, + layerId: 'some-id', + frame: mockFrameApi, + }) + ).toEqual({ + breakdownByAccessor: columnId, + }); + }); + + describe('removing a dimension', () => { + const removeDimensionParam: Parameters< + Visualization['removeDimension'] + >[0] = { + layerId: 'some-id', + columnId: '', + frame: mockFrameApi, + prevState: fullState, + }; + + it('removes metric dimension', () => { + const removed = visualization.removeDimension({ + ...removeDimensionParam, + columnId: fullState.metricAccessor!, + }); + + expect(removed).not.toHaveProperty('metricAccessor'); + expect(removed).not.toHaveProperty('palette'); + }); + it('removes secondary metric dimension', () => { + const removed = visualization.removeDimension({ + ...removeDimensionParam, + columnId: fullState.secondaryMetricAccessor!, + }); + + expect(removed).not.toHaveProperty('secondaryMetricAccessor'); + expect(removed).not.toHaveProperty('secondaryPrefix'); + }); + it('removes max dimension', () => { + const removed = visualization.removeDimension({ + ...removeDimensionParam, + columnId: fullState.maxAccessor!, + }); + + expect(removed).not.toHaveProperty('maxAccessor'); + }); + it('removes breakdown-by dimension', () => { + const removed = visualization.removeDimension({ + ...removeDimensionParam, + columnId: fullState.breakdownByAccessor!, + }); + + expect(removed).not.toHaveProperty('breakdownByAccessor'); + expect(removed).not.toHaveProperty('collapseFn'); + }); + }); + + it('implements custom display options', () => { + expect(visualization.getDisplayOptions!()).toEqual({ + noPanelTitle: true, + noPadding: true, + }); + }); +}); diff --git a/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx b/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx new file mode 100644 index 0000000000000..c5c15934f1ef2 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx @@ -0,0 +1,414 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n-react'; +import { render } from 'react-dom'; +import { Ast, AstFunction } from '@kbn/interpreter'; +import { PaletteOutput, PaletteRegistry, CUSTOM_PALETTE, CustomPaletteParams } from '@kbn/coloring'; +import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public'; +import { LayoutDirection } from '@elastic/charts'; +import { euiLightVars } from '@kbn/ui-theme'; +import { LayerType } from '../../../common'; +import { getSuggestions } from './suggestions'; +import { LensIconChartMetric } from '../../assets/chart_metric'; +import { Visualization, OperationMetadata, DatasourceLayers } from '../../types'; +import { layerTypes } from '../../../common'; +import { GROUP_ID, LENS_METRIC_ID } from './constants'; +import { DimensionEditor } from './dimension_editor'; +import { Toolbar } from './toolbar'; +import { generateId } from '../../id_generator'; + +export const DEFAULT_MAX_COLUMNS = 3; + +export interface MetricVisualizationState { + layerId: string; + layerType: LayerType; + metricAccessor?: string; + secondaryMetricAccessor?: string; + maxAccessor?: string; + breakdownByAccessor?: string; + // the dimensions can optionally be single numbers + // computed by collapsing all rows + collapseFn?: string; + subtitle?: string; + secondaryPrefix?: string; + progressDirection?: LayoutDirection; + color?: string; + palette?: PaletteOutput; + maxCols?: number; +} + +export const supportedDataTypes = new Set(['number']); + +// TODO - deduplicate with gauges? +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: MetricVisualizationState, + datasourceLayers: DatasourceLayers, + datasourceExpressionsByLayers: Record | undefined = {} +): Ast | null => { + if (!state.metricAccessor) { + return null; + } + + const datasource = datasourceLayers[state.layerId]; + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; + + const maxPossibleTiles = + // if there's a collapse function, no need to calculate since we're dealing with a single tile + state.breakdownByAccessor && !state.collapseFn + ? datasource.getMaxPossibleNumValues(state.breakdownByAccessor) + : null; + + const getCollapseFnArguments = () => { + const metric = [state.metricAccessor, state.secondaryMetricAccessor, state.maxAccessor].filter( + Boolean + ); + + const fn = metric.map((accessor) => { + if (accessor !== state.maxAccessor) { + return state.collapseFn; + } else { + const isMaxStatic = Boolean( + datasource.getOperationForColumnId(state.maxAccessor!)?.isStaticValue + ); + // we do this because the user expects the static value they set to be the same + // even if they define a collapse on the breakdown by + return isMaxStatic ? 'max' : state.collapseFn; + } + }); + + return { + by: [], + metric, + fn, + }; + }; + + return { + type: 'expression', + chain: [ + ...(datasourceExpression?.chain ?? []), + ...(state.collapseFn + ? [ + { + type: 'function', + function: 'lens_collapse', + arguments: getCollapseFnArguments(), + } as AstFunction, + ] + : []), + { + type: 'function', + function: 'metricVis', // TODO import from plugin + arguments: { + metric: state.metricAccessor ? [state.metricAccessor] : [], + secondaryMetric: state.secondaryMetricAccessor ? [state.secondaryMetricAccessor] : [], + secondaryPrefix: state.secondaryPrefix ? [state.secondaryPrefix] : [], + max: state.maxAccessor ? [state.maxAccessor] : [], + breakdownBy: + state.breakdownByAccessor && !state.collapseFn ? [state.breakdownByAccessor] : [], + subtitle: state.subtitle ? [state.subtitle] : [], + progressDirection: state.progressDirection ? [state.progressDirection] : [], + color: state.color + ? [state.color] + : state.maxAccessor + ? [euiLightVars.euiColorPrimary] + : [], + palette: state.palette?.params + ? [ + paletteService + .get(CUSTOM_PALETTE) + .toExpression(computePaletteParams(state.palette.params as CustomPaletteParams)), + ] + : [], + maxCols: [state.maxCols ?? DEFAULT_MAX_COLUMNS], + minTiles: maxPossibleTiles ? [maxPossibleTiles] : [], + }, + }, + ], + }; +}; + +export const metricLabel = i18n.translate('xpack.lens.metric.label', { + defaultMessage: 'Metric', +}); +const metricGroupLabel = i18n.translate('xpack.lens.metric.groupLabel', { + defaultMessage: 'Goal and single value', +}); + +export const getMetricVisualization = ({ + paletteService, +}: { + paletteService: PaletteRegistry; +}): Visualization => ({ + id: LENS_METRIC_ID, + + visualizationTypes: [ + { + id: LENS_METRIC_ID, + icon: LensIconChartMetric, + label: metricLabel, + groupLabel: metricGroupLabel, + showExperimentalBadge: true, + sortPriority: 3, + }, + ], + + getVisualizationTypeId() { + return LENS_METRIC_ID; + }, + + clearLayer(state) { + const newState = { ...state }; + delete newState.metricAccessor; + delete newState.secondaryMetricAccessor; + delete newState.secondaryPrefix; + delete newState.breakdownByAccessor; + delete newState.collapseFn; + delete newState.maxAccessor; + delete newState.palette; + // TODO - clear more? + return newState; + }, + + getLayerIds(state) { + return [state.layerId]; + }, + + getDescription() { + return { + icon: LensIconChartMetric, + label: metricLabel, + }; + }, + + getSuggestions, + + initialize(addNewLayer, state, mainPalette) { + return ( + state ?? { + layerId: addNewLayer(), + layerType: layerTypes.DATA, + palette: mainPalette, + } + ); + }, + triggers: [VIS_EVENT_TO_TRIGGER.filter], + + getConfiguration(props) { + const hasColoring = props.state.palette != null; + const stops = props.state.palette?.params?.stops || []; + const isSupportedMetric = (op: OperationMetadata) => + !op.isBucketed && supportedDataTypes.has(op.dataType); + + const isSupportedDynamicMetric = (op: OperationMetadata) => + !op.isBucketed && supportedDataTypes.has(op.dataType) && !op.isStaticValue; + + const isBucketed = (op: OperationMetadata) => op.isBucketed; + return { + groups: [ + { + groupId: GROUP_ID.METRIC, + groupLabel: i18n.translate('xpack.lens.primaryMetric.label', { + defaultMessage: 'Primary metric', + }), + layerId: props.state.layerId, + accessors: props.state.metricAccessor + ? [ + { + columnId: props.state.metricAccessor, + triggerIcon: hasColoring ? 'colorBy' : undefined, + palette: hasColoring ? stops.map(({ color }) => color) : undefined, + }, + ] + : [], + supportsMoreColumns: !props.state.metricAccessor, + filterOperations: isSupportedDynamicMetric, + enableDimensionEditor: true, + supportFieldFormat: false, + required: true, + }, + { + groupId: GROUP_ID.SECONDARY_METRIC, + groupLabel: i18n.translate('xpack.lens.metric.secondaryMetric', { + defaultMessage: 'Secondary metric', + }), + layerId: props.state.layerId, + accessors: props.state.secondaryMetricAccessor + ? [ + { + columnId: props.state.secondaryMetricAccessor, + }, + ] + : [], + supportsMoreColumns: !props.state.secondaryMetricAccessor, + filterOperations: isSupportedDynamicMetric, + enableDimensionEditor: true, + supportFieldFormat: false, + required: false, + }, + { + groupId: GROUP_ID.MAX, + groupLabel: i18n.translate('xpack.lens.metric.max', { defaultMessage: 'Maximum value' }), + layerId: props.state.layerId, + accessors: props.state.maxAccessor + ? [ + { + columnId: props.state.maxAccessor, + }, + ] + : [], + supportsMoreColumns: !props.state.maxAccessor, + filterOperations: isSupportedMetric, + enableDimensionEditor: true, + supportFieldFormat: false, + supportStaticValue: true, + required: false, + groupTooltip: i18n.translate('xpack.lens.metric.maxTooltip', { + defaultMessage: + 'If the maximum value is specified, the minimum value is fixed at zero.', + }), + }, + { + groupId: GROUP_ID.BREAKDOWN_BY, + groupLabel: i18n.translate('xpack.lens.metric.breakdownBy', { + defaultMessage: 'Break down by', + }), + layerId: props.state.layerId, + accessors: props.state.breakdownByAccessor + ? [ + { + columnId: props.state.breakdownByAccessor, + triggerIcon: props.state.collapseFn ? ('aggregate' as const) : undefined, + }, + ] + : [], + supportsMoreColumns: !props.state.breakdownByAccessor, + filterOperations: isBucketed, + enableDimensionEditor: true, + supportFieldFormat: false, + required: false, + }, + ], + }; + }, + + getSupportedLayers(state) { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.metric.addLayer', { + defaultMessage: 'Visualization', + }), + initialDimensions: state + ? [ + { + groupId: 'max', + columnId: generateId(), + staticValue: 0, + }, + ] + : undefined, + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return state.layerType; + } + }, + + toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers) => + toExpression(paletteService, state, datasourceLayers, datasourceExpressionsByLayers), + + setDimension({ prevState, columnId, groupId }) { + const updated = { ...prevState }; + + switch (groupId) { + case GROUP_ID.METRIC: + updated.metricAccessor = columnId; + break; + case GROUP_ID.SECONDARY_METRIC: + updated.secondaryMetricAccessor = columnId; + break; + case GROUP_ID.MAX: + updated.maxAccessor = columnId; + break; + case GROUP_ID.BREAKDOWN_BY: + updated.breakdownByAccessor = columnId; + break; + } + + return updated; + }, + + removeDimension({ prevState, layerId, columnId }) { + const updated = { ...prevState }; + + if (prevState.metricAccessor === columnId) { + delete updated.metricAccessor; + delete updated.palette; + } + if (prevState.secondaryMetricAccessor === columnId) { + delete updated.secondaryMetricAccessor; + delete updated.secondaryPrefix; + } + if (prevState.maxAccessor === columnId) { + delete updated.maxAccessor; + } + if (prevState.breakdownByAccessor === columnId) { + delete updated.breakdownByAccessor; + delete updated.collapseFn; + } + + return updated; + }, + + renderToolbar(domElement, props) { + render( + + + , + domElement + ); + }, + + renderDimensionEditor(domElement, props) { + render( + + + , + domElement + ); + }, + + getErrorMessages(state) { + // Is it possible to break it? + return undefined; + }, + + getDisplayOptions() { + return { + noPanelTitle: true, + noPadding: true, + }; + }, +}); diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index df55e06710599..9f548139a47d6 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -34,6 +34,7 @@ { "path": "../../../src/plugins/field_formats/tsconfig.json"}, { "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json"}, { "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json"}, + { "path": "../../../src/plugins/chart_expressions/expression_metric/tsconfig.json"}, { "path": "../../../src/plugins/data_view_editor/tsconfig.json"}, { "path": "../../../src/plugins/event_annotation/tsconfig.json"}, { "path": "../../../src/plugins/chart_expressions/expression_xy/tsconfig.json"}, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 18f8a1b047cc0..5fa089154fb5e 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -721,26 +721,9 @@ "xpack.lens.lineMarker.positionRequirementTooltip": "Vous devez sélectionner une icône ou afficher le nom pour pouvoir en modifier la position.", "xpack.lens.lineMarker.textVisibility": "Décoration du texte", "xpack.lens.metric.addLayer": "Visualisation", - "xpack.lens.metric.dynamicColoring.background": "Remplir", "xpack.lens.metric.dynamicColoring.label": "Couleur par valeur", - "xpack.lens.metric.dynamicColoring.none": "Aucun", - "xpack.lens.metric.dynamicColoring.text": "Texte", "xpack.lens.metric.groupLabel": "Valeur d’objectif et unique", "xpack.lens.metric.label": "Indicateur", - "xpack.lens.metricChart.alignLabel.center": "Aligner au centre", - "xpack.lens.metricChart.alignLabel.left": "Aligner à gauche", - "xpack.lens.metricChart.alignLabel.right": "Aligner à droite", - "xpack.lens.metricChart.metricSize.extraLarge": "XL", - "xpack.lens.metricChart.metricSize.extraSmall": "XS", - "xpack.lens.metricChart.metricSize.large": "L", - "xpack.lens.metricChart.metricSize.medium": "M", - "xpack.lens.metricChart.metricSize.small": "S", - "xpack.lens.metricChart.metricSize.xxl": "XXL", - "xpack.lens.metricChart.textFormattingLabel": "Formatage du texte", - "xpack.lens.metricChart.titleAlignLabel": "Aligner", - "xpack.lens.metricChart.titlePositionLabel": "Position du titre", - "xpack.lens.metricChart.titlePositions.bottom": "Bas", - "xpack.lens.metricChart.titlePositions.top": "Haut", "xpack.lens.pageTitle": "Lens", "xpack.lens.paletteHeatmapGradient.customize": "Modifier", "xpack.lens.paletteHeatmapGradient.customizeLong": "Modifier la palette", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 887226f7c8a7a..9b7c62eef73bb 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -722,26 +722,9 @@ "xpack.lens.lineMarker.positionRequirementTooltip": "位置を変更するには、アイコンを選択するか、名前を表示する必要があります", "xpack.lens.lineMarker.textVisibility": "テキスト装飾", "xpack.lens.metric.addLayer": "ビジュアライゼーション", - "xpack.lens.metric.dynamicColoring.background": "塗りつぶし", "xpack.lens.metric.dynamicColoring.label": "値別の色", - "xpack.lens.metric.dynamicColoring.none": "なし", - "xpack.lens.metric.dynamicColoring.text": "テキスト", "xpack.lens.metric.groupLabel": "目標値と単一の値", "xpack.lens.metric.label": "メトリック", - "xpack.lens.metricChart.alignLabel.center": "中央に合わせる", - "xpack.lens.metricChart.alignLabel.left": "左に合わせる", - "xpack.lens.metricChart.alignLabel.right": "右に合わせる", - "xpack.lens.metricChart.metricSize.extraLarge": "XL", - "xpack.lens.metricChart.metricSize.extraSmall": "XS", - "xpack.lens.metricChart.metricSize.large": "L", - "xpack.lens.metricChart.metricSize.medium": "M", - "xpack.lens.metricChart.metricSize.small": "S", - "xpack.lens.metricChart.metricSize.xxl": "XXL", - "xpack.lens.metricChart.textFormattingLabel": "テキスト書式", - "xpack.lens.metricChart.titleAlignLabel": "配置", - "xpack.lens.metricChart.titlePositionLabel": "タイトル位置", - "xpack.lens.metricChart.titlePositions.bottom": "一番下", - "xpack.lens.metricChart.titlePositions.top": "トップ", "xpack.lens.pageTitle": "レンズ", "xpack.lens.paletteHeatmapGradient.customize": "編集", "xpack.lens.paletteHeatmapGradient.customizeLong": "パレットを編集", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 131c5a3057086..09b788bf8606e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -722,26 +722,8 @@ "xpack.lens.lineMarker.positionRequirementTooltip": "必须选择图标或显示名称才能更改其位置", "xpack.lens.lineMarker.textVisibility": "文本装饰", "xpack.lens.metric.addLayer": "可视化", - "xpack.lens.metric.dynamicColoring.background": "填充", - "xpack.lens.metric.dynamicColoring.label": "按值上色", - "xpack.lens.metric.dynamicColoring.none": "无", - "xpack.lens.metric.dynamicColoring.text": "文本", "xpack.lens.metric.groupLabel": "目标值和单值", "xpack.lens.metric.label": "指标", - "xpack.lens.metricChart.alignLabel.center": "中间对齐", - "xpack.lens.metricChart.alignLabel.left": "左对齐", - "xpack.lens.metricChart.alignLabel.right": "右对齐", - "xpack.lens.metricChart.metricSize.extraLarge": "XL", - "xpack.lens.metricChart.metricSize.extraSmall": "XS", - "xpack.lens.metricChart.metricSize.large": "L", - "xpack.lens.metricChart.metricSize.medium": "M", - "xpack.lens.metricChart.metricSize.small": "S", - "xpack.lens.metricChart.metricSize.xxl": "XXL", - "xpack.lens.metricChart.textFormattingLabel": "文本格式", - "xpack.lens.metricChart.titleAlignLabel": "对齐", - "xpack.lens.metricChart.titlePositionLabel": "标题位置", - "xpack.lens.metricChart.titlePositions.bottom": "底部", - "xpack.lens.metricChart.titlePositions.top": "顶部", "xpack.lens.pageTitle": "Lens", "xpack.lens.paletteHeatmapGradient.customize": "编辑", "xpack.lens.paletteHeatmapGradient.customizeLong": "编辑调色板", diff --git a/x-pack/test/examples/screenshotting/index.ts b/x-pack/test/examples/screenshotting/index.ts index 94a29f382a771..f8c8327217d15 100644 --- a/x-pack/test/examples/screenshotting/index.ts +++ b/x-pack/test/examples/screenshotting/index.ts @@ -35,7 +35,7 @@ export default function ({ aggs={aggCount id="1" enabled=true schema="metric"} aggs={aggMax id="1" enabled=true schema="metric" field="bytes"} aggs={aggTerms id="2" enabled=true schema="segment" field="response.raw" size=4 order="desc" orderBy="1"} - | metricVis metric={visdimension 0} + | legacyMetricVis metric={visdimension 0} ` ); await testSubjects.click('run');