diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/bucket_ops.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/bucket_ops.ts index 56a2883192c0d..08a86bbc6514f 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/bucket_ops.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/bucket_ops.ts @@ -194,11 +194,11 @@ export const bucketTermsOperationSchema = schema.object( schema.object({ type: schema.literal('column'), /** - * Metric to be used for the column + * Metric to be used for the column by index number (0 based) */ metric: schema.number({ meta: { - description: 'Metric to be used for the column', + description: 'Metric to be used for the column by index number (0 based)', }, }), /** diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.test.ts index 183b7b572ea9d..05f2beacce129 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.test.ts @@ -28,17 +28,20 @@ describe('Metric Schema', () => { it('validates count metric operation', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'count', - field: 'test_field', - fit: false, - sub_label: 'Count of records', - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - alignments: { - labels: 'left', - value: 'right', + metrics: [ + { + type: 'primary', + operation: 'count', + field: 'test_field', + fit: false, + sub_label: 'Count of records', + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, + alignments: { + labels: 'left', + value: 'right', + }, }, - }, + ], }; const validated = metricStateSchema.validate(input); @@ -48,17 +51,20 @@ describe('Metric Schema', () => { it('validates metric with icon configuration', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'sum', - field: 'price', - fit: false, - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - icon: { - name: 'visMetric', - align: 'left', + metrics: [ + { + type: 'primary', + operation: 'sum', + field: 'price', + fit: false, + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, + icon: { + name: 'visMetric', + align: 'left', + }, + alignments: { labels: 'left', value: 'left' }, }, - alignments: { labels: 'left', value: 'left' }, - }, + ], }; const validated = metricStateSchema.validate(input); @@ -71,20 +77,23 @@ describe('Metric Schema', () => { it('validates metric with color configuration', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'average', - field: 'temperature', - fit: false, - alignments: { labels: 'left', value: 'left' }, - color: { - type: 'dynamic', - range: 'absolute', - steps: [ - { type: 'from', from: 0, color: '#blue' }, - { type: 'to', to: 100, color: '#red' }, - ], + metrics: [ + { + type: 'primary', + operation: 'average', + field: 'temperature', + fit: false, + alignments: { labels: 'left', value: 'left' }, + color: { + type: 'dynamic', + range: 'absolute', + steps: [ + { type: 'from', from: 0, color: '#blue' }, + { type: 'to', to: 100, color: '#red' }, + ], + }, }, - }, + ], }; const validated = metricStateSchema.validate(input); @@ -94,20 +103,23 @@ describe('Metric Schema', () => { it('validates metric with background chart', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'max', - field: 'cpu_usage', - fit: false, - alignments: { labels: 'left', value: 'left' }, - background_chart: { - type: 'bar', - direction: 'horizontal', - goal_value: { - operation: 'static_value', - value: 80, + metrics: [ + { + type: 'primary', + operation: 'max', + field: 'cpu_usage', + fit: false, + alignments: { labels: 'left', value: 'left' }, + background_chart: { + type: 'bar', + direction: 'horizontal', + goal_value: { + operation: 'static_value', + value: 80, + }, }, }, - }, + ], }; const validated = metricStateSchema.validate(input); @@ -117,20 +129,23 @@ describe('Metric Schema', () => { it('should throw for invalid color by value configuration', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'average', - field: 'temperature', - color: { - type: 'dynamic', - range: 'percentage', - steps: [ - { type: 'from', from: 0, color: '#blue' }, - { type: 'to', to: 100, color: '#red' }, - ], + metrics: [ + { + type: 'primary', + operation: 'average', + field: 'temperature', + color: { + type: 'dynamic', + range: 'percentage', + steps: [ + { type: 'from', from: 0, color: '#blue' }, + { type: 'to', to: 100, color: '#red' }, + ], + }, + fit: false, + alignments: { labels: 'left', value: 'left' }, }, - fit: false, - alignments: { labels: 'left', value: 'left' }, - }, + ], }; expect(() => metricStateSchema.validate(input)).toThrow(); @@ -141,22 +156,26 @@ describe('Metric Schema', () => { it('validates with secondary metric', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'sum', - field: 'revenue', - fit: false, - alignments: { labels: 'left', value: 'left' }, - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - }, - secondary_metric: { - operation: 'sum', - field: 'cost', - prefix: '$', - compare: { - to: 'primary', + metrics: [ + { + type: 'primary', + operation: 'sum', + field: 'revenue', + fit: false, + alignments: { labels: 'left', value: 'left' }, + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, }, - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - }, + { + type: 'secondary', + operation: 'sum', + field: 'cost', + prefix: '$', + compare: { + to: 'primary', + }, + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, + }, + ], }; const validated = metricStateSchema.validate(input); @@ -166,23 +185,27 @@ describe('Metric Schema', () => { it('validates with colored secondary metric', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'sum', - field: 'revenue', - fit: false, - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - alignments: { labels: 'left', value: 'left' }, - }, - secondary_metric: { - operation: 'sum', - field: 'profit', - prefix: '', - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - color: { - type: 'static', - color: '#green', + metrics: [ + { + type: 'primary', + operation: 'sum', + field: 'revenue', + fit: false, + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, + alignments: { labels: 'left', value: 'left' }, }, - }, + { + type: 'secondary', + operation: 'sum', + field: 'profit', + prefix: '', + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, + color: { + type: 'static', + color: '#green', + }, + }, + ], }; const validated = metricStateSchema.validate(input); @@ -194,13 +217,16 @@ describe('Metric Schema', () => { it('validates terms breakdown', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'sum', - field: 'sales', - fit: false, - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - alignments: { labels: 'left', value: 'left' }, - }, + metrics: [ + { + type: 'primary', + operation: 'sum', + field: 'sales', + fit: false, + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, + alignments: { labels: 'left', value: 'left' }, + }, + ], breakdown_by: { operation: 'terms', fields: ['category'], @@ -220,13 +246,16 @@ describe('Metric Schema', () => { it('validates date histogram breakdown', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'sum', - field: 'sales', - fit: false, - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - alignments: { labels: 'left', value: 'left' }, - }, + metrics: [ + { + type: 'primary', + operation: 'sum', + field: 'sales', + fit: false, + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, + alignments: { labels: 'left', value: 'left' }, + }, + ], breakdown_by: { operation: 'date_histogram', field: 'timestamp', @@ -247,9 +276,12 @@ describe('Metric Schema', () => { it('throws on missing metric operation', () => { const input = { ...baseMetricConfig, - metric: { - field: 'test_field', - }, + metrics: [ + { + type: 'primary', + field: 'test_field', + }, + ], }; expect(() => metricStateSchema.validate(input)).toThrow(); @@ -258,13 +290,16 @@ describe('Metric Schema', () => { it('throws on invalid alignment value', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'count', - field: 'test_field', - alignments: { - labels: 'invalid', + metrics: [ + { + type: 'primary', + operation: 'count', + field: 'test_field', + alignments: { + labels: 'invalid', + }, }, - }, + ], }; expect(() => metricStateSchema.validate(input)).toThrow(); @@ -273,10 +308,13 @@ describe('Metric Schema', () => { it('throws on invalid breakdown collapse_by value', () => { const input = { ...baseMetricConfig, - metric: { - operation: 'sum', - field: 'sales', - }, + metrics: [ + { + type: 'primary', + operation: 'sum', + field: 'sales', + }, + ], breakdown_by: { operation: 'terms', fields: ['category'], @@ -286,6 +324,75 @@ describe('Metric Schema', () => { expect(() => metricStateSchema.validate(input)).toThrow(); }); + + it('throws if metric type is missing', () => { + const input = { + ...baseMetricConfig, + metrics: [ + { + operation: 'sum', + field: 'test_field', + }, + ], + }; + + expect(() => metricStateSchema.validate(input)).toThrow(); + }); + + it('throws for two primary metrics', () => { + const input = { + ...baseMetricConfig, + metrics: [ + { + type: 'primary', + operation: 'sum', + field: 'test_field', + }, + { + type: 'primary', + operation: 'sum', + field: 'test_field', + }, + ], + }; + + expect(() => metricStateSchema.validate(input)).toThrow(); + }); + + it('throws for two secondary metrics', () => { + const input = { + ...baseMetricConfig, + metrics: [ + { + type: 'secondary', + operation: 'sum', + field: 'test_field', + }, + { + type: 'secondary', + operation: 'sum', + field: 'test_field', + }, + ], + }; + + expect(() => metricStateSchema.validate(input)).toThrow(); + }); + + it('throws if the only metric is secondary', () => { + const input = { + ...baseMetricConfig, + metrics: [ + { + type: 'secondary', + operation: 'sum', + field: 'test_field', + }, + ], + }; + + expect(() => metricStateSchema.validate(input)).toThrow(); + }); }); describe('complex configurations', () => { @@ -294,41 +401,45 @@ describe('Metric Schema', () => { ...baseMetricConfig, title: 'Sales Overview', description: 'Sales metrics breakdown by category', - metric: { - operation: 'sum', - field: 'sales', - sub_label: 'Total Sales', - fit: false, - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - alignments: { - labels: 'left', - value: 'right', - }, - icon: { - name: 'visMetric', - align: 'right', - }, - color: { - type: 'dynamic', - range: 'absolute', - steps: [ - { type: 'from', from: 0, color: '#red' }, - { type: 'to', to: 1000, color: '#green' }, - ], - }, - background_chart: { - type: 'trend', + metrics: [ + { + type: 'primary', + operation: 'sum', + field: 'sales', + sub_label: 'Total Sales', + fit: false, + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, + alignments: { + labels: 'left', + value: 'right', + }, + icon: { + name: 'visMetric', + align: 'right', + }, + color: { + type: 'dynamic', + range: 'absolute', + steps: [ + { type: 'from', from: 0, color: '#red' }, + { type: 'to', to: 1000, color: '#green' }, + ], + }, + background_chart: { + type: 'trend', + }, }, - }, - secondary_metric: { - operation: 'sum', - field: 'profit', - prefix: '$', - compare: { - to: 'primary', + { + type: 'secondary', + operation: 'sum', + field: 'profit', + prefix: '$', + compare: { + to: 'primary', + }, + empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, }, - empty_as_null: LENS_EMPTY_AS_NULL_DEFAULT_VALUE, - }, + ], breakdown_by: { operation: 'terms', fields: ['category'], @@ -352,12 +463,15 @@ describe('Metric Schema', () => { type: 'esql', query: 'FROM my-index | LIMIT 100', }, - metric: { - operation: 'value', - column: 'unique_count', - fit: false, - alignments: { labels: 'left', value: 'left' }, - }, + metrics: [ + { + type: 'primary', + operation: 'value', + column: 'unique_count', + fit: false, + alignments: { labels: 'left', value: 'left' }, + }, + ], }; const validated = metricStateSchema.validate(input); diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts index 390ca3019e513..5c3fb4673794d 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts @@ -9,6 +9,10 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; +import { + LENS_METRIC_BREAKDOWN_DEFAULT_MAX_COLUMNS, + LENS_METRIC_STATE_DEFAULTS, +} from '@kbn/lens-common'; import { metricOperationDefinitionSchema, esqlColumnSchema, @@ -39,22 +43,35 @@ const compareToSchemaShared = schema.object( { meta: { id: 'metricChartCompareToShared' } } ); -export const complementaryVizSchema = schema.oneOf([ - schema.object( +const barBackgroundChartSchema = schema.object({ + type: schema.literal('bar'), + /** + * Direction of the bar. Possible values: + * - 'vertical': Bar is oriented vertically + * - 'horizontal': Bar is oriented horizontally + */ + direction: schema.maybe(schema.oneOf([schema.literal('vertical'), schema.literal('horizontal')])), +}); + +export const complementaryVizSchemaNoESQL = schema.oneOf([ + barBackgroundChartSchema.extends({ + /** + * Goal value + */ + goal_value: metricOperationDefinitionSchema, + }), + schema.object({ + type: schema.literal('trend'), + }), +]); + +export const complementaryVizSchemaESQL = schema.oneOf([ + barBackgroundChartSchema.extends( { - type: schema.literal('bar'), - /** - * Direction of the bar. Possible values: - * - 'vertical': Bar is oriented vertically - * - 'horizontal': Bar is oriented horizontally - */ - direction: schema.maybe( - schema.oneOf([schema.literal('vertical'), schema.literal('horizontal')]) - ), /** * Goal value */ - goal_value: metricOperationDefinitionSchema, + goal_value: esqlColumnSchema, }, { meta: { id: 'metricComplementaryBar' } } ), @@ -66,7 +83,25 @@ export const complementaryVizSchema = schema.oneOf([ ), ]); +const metricStateBackgroundChartSchemaNoESQL = { + /** + * Complementary visualization + */ + background_chart: schema.maybe(complementaryVizSchemaNoESQL), +}; + +const metricStateBackgroundChartSchemaESQL = { + /** + * Complementary visualization + */ + background_chart: schema.maybe(complementaryVizSchemaESQL), +}; + const metricStatePrimaryMetricOptionsSchema = { + // this is used to differentiate primary and secondary metrics + // unfortunately given the lack of tuple schema support we need to have some way + // to avoid default injection in the wrong type + type: schema.literal('primary'), /** * Sub label */ @@ -85,7 +120,7 @@ const metricStatePrimaryMetricOptionsSchema = { */ labels: horizontalAlignmentSchema({ meta: { description: 'Alignments for labels' }, - defaultValue: 'left', + defaultValue: LENS_METRIC_STATE_DEFAULTS.titlesTextAlign, }), /** * Alignments for value. Possible values: @@ -95,12 +130,15 @@ const metricStatePrimaryMetricOptionsSchema = { */ value: horizontalAlignmentSchema({ meta: { description: 'Alignments for value' }, - defaultValue: 'left', + defaultValue: LENS_METRIC_STATE_DEFAULTS.valuesTextAlign, }), }, { + defaultValue: { + labels: LENS_METRIC_STATE_DEFAULTS.titlesTextAlign, + value: LENS_METRIC_STATE_DEFAULTS.valuesTextAlign, + }, meta: { id: 'metricPrimaryMetricAlignments' }, - defaultValue: { labels: 'left', value: 'left' }, } ), /** @@ -124,7 +162,7 @@ const metricStatePrimaryMetricOptionsSchema = { */ align: leftRightAlignmentSchema({ meta: { description: 'Icon alignment' }, - defaultValue: 'right', + defaultValue: LENS_METRIC_STATE_DEFAULTS.iconAlign, }), }, { meta: { id: 'metricIconConfig', description: 'Icon configuration for primary metric' } } @@ -138,13 +176,13 @@ const metricStatePrimaryMetricOptionsSchema = { * Where to apply the color (background or value) */ apply_color_to: schema.maybe(applyColorToSchema), - /** - * Complementary visualization - */ - background_chart: schema.maybe(complementaryVizSchema), }; const metricStateSecondaryMetricOptionsSchema = { + // this is used to differentiate primary and secondary metrics + // unfortunately given the lack of tuple schema support we need to have some way + // to avoid default injection in the wrong type + type: schema.literal('secondary'), /** * Prefix */ @@ -180,7 +218,7 @@ const metricStateBreakdownByOptionsSchema = { * Number of columns */ columns: schema.number({ - defaultValue: 5, + defaultValue: LENS_METRIC_BREAKDOWN_DEFAULT_MAX_COLUMNS, meta: { description: 'Number of columns' }, }), /** @@ -197,67 +235,90 @@ const metricStateBreakdownByOptionsSchema = { collapse_by: schema.maybe(collapseBySchema), }; -export const metricStateSchemaNoESQL = schema.object( - { - type: schema.literal('metric'), - ...sharedPanelInfoSchema, - ...dslOnlyPanelInfoSchema, - ...layerSettingsSchema, - ...datasetSchema, - /** - * Primary value configuration, must define operation. - */ - metric: mergeAllMetricsWithChartDimensionSchemaWithRefBasedOps( - metricStatePrimaryMetricOptionsSchema - ), - /** - * Secondary value configuration, must define operation. - */ - secondary_metric: schema.maybe( - mergeAllMetricsWithChartDimensionSchemaWithRefBasedOps( - metricStateSecondaryMetricOptionsSchema - ) - ), - /** - * Configure how to break down the metric (e.g. show one metric per term). - */ - breakdown_by: schema.maybe( - mergeAllBucketsWithChartDimensionSchema(metricStateBreakdownByOptionsSchema) - ), - }, - { meta: { id: 'metricNoESQL' } } +function isSecondaryMetric( + metric: PrimaryMetricType | SecondaryMetricType +): metric is SecondaryMetricType { + return metric.type === 'secondary'; +} + +function isPrimaryMetric( + metric: PrimaryMetricType | SecondaryMetricType +): metric is PrimaryMetricType { + return metric.type === 'primary'; +} + +function validateMetrics(metrics: (PrimaryMetricType | SecondaryMetricType)[]) { + const [firstMetric, secondMetric] = metrics; + if (secondMetric) { + const isFirstSecondary = isSecondaryMetric(firstMetric); + const isSecondPrimary = isPrimaryMetric(secondMetric); + if (isFirstSecondary || isSecondPrimary) { + return 'When two metrics are defined, the primary metric must be the first item and the secondary metric the second item.'; + } + } + const isFirstSecondary = isSecondaryMetric(firstMetric); + if (isFirstSecondary) { + return 'The first metric must be the primary metric.'; + } +} + +const primaryMetricSchemaNoESQL = mergeAllMetricsWithChartDimensionSchemaWithRefBasedOps({ + ...metricStatePrimaryMetricOptionsSchema, + ...metricStateBackgroundChartSchemaNoESQL, +}); +const secondaryMetricSchemaNoESQL = mergeAllMetricsWithChartDimensionSchemaWithRefBasedOps( + metricStateSecondaryMetricOptionsSchema ); -export const esqlMetricState = schema.object( - { - type: schema.literal('metric'), - ...sharedPanelInfoSchema, - ...layerSettingsSchema, - ...datasetEsqlTableSchema, - /** - * Primary value configuration, must define operation. - */ - metric: esqlColumnOperationWithLabelAndFormatSchema.extends( - metricStatePrimaryMetricOptionsSchema - ), - /** - * Secondary value configuration, must define operation. - */ - secondary_metric: schema.maybe( - esqlColumnOperationWithLabelAndFormatSchema.extends(metricStateSecondaryMetricOptionsSchema) - ), - /** - * Configure how to break down the metric (e.g. show one metric per term). - */ - breakdown_by: schema.maybe( - esqlColumnSchema.extends(metricStateBreakdownByOptionsSchema, { - meta: { id: 'metricBreakdownByEsql' }, - }) - ), - }, - { meta: { id: 'metricESQL' } } +export const metricStateSchemaNoESQL = schema.object({ + type: schema.literal('metric'), + ...sharedPanelInfoSchema, + ...dslOnlyPanelInfoSchema, + ...layerSettingsSchema, + ...datasetSchema, + /** + * Primary value configuration, must define operation. + */ + metrics: schema.arrayOf(schema.oneOf([primaryMetricSchemaNoESQL, secondaryMetricSchemaNoESQL]), { + minSize: 1, + maxSize: 2, + validate: validateMetrics, + }), + /** + * Configure how to break down the metric (e.g. show one metric per term). + */ + breakdown_by: schema.maybe( + mergeAllBucketsWithChartDimensionSchema(metricStateBreakdownByOptionsSchema) + ), +}); + +const primaryMetricESQL = esqlColumnOperationWithLabelAndFormatSchema + .extends(metricStatePrimaryMetricOptionsSchema) + .extends(metricStateBackgroundChartSchemaESQL); + +const secondaryMetricESQL = esqlColumnOperationWithLabelAndFormatSchema.extends( + metricStateSecondaryMetricOptionsSchema ); +export const esqlMetricState = schema.object({ + type: schema.literal('metric'), + ...sharedPanelInfoSchema, + ...layerSettingsSchema, + ...datasetEsqlTableSchema, + /** + * Primary value configuration, must define operation. + */ + metrics: schema.arrayOf(schema.oneOf([primaryMetricESQL, secondaryMetricESQL]), { + minSize: 1, + maxSize: 2, + validate: validateMetrics, + }), + /** + * Configure how to break down the metric (e.g. show one metric per term). + */ + breakdown_by: schema.maybe(esqlColumnSchema.extends(metricStateBreakdownByOptionsSchema)), +}); + export const metricStateSchema = schema.oneOf([metricStateSchemaNoESQL, esqlMetricState], { meta: { id: 'metricChartSchema' }, }); @@ -265,3 +326,10 @@ export const metricStateSchema = schema.oneOf([metricStateSchemaNoESQL, esqlMetr export type MetricState = TypeOf; export type MetricStateNoESQL = TypeOf; export type MetricStateESQL = TypeOf; + +export type PrimaryMetricType = + | TypeOf + | TypeOf; +export type SecondaryMetricType = + | TypeOf + | TypeOf; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/complex.mock.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/complex.mock.ts deleted file mode 100644 index c7a4cbfa8fba4..0000000000000 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/complex.mock.ts +++ /dev/null @@ -1,106 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { LensAttributes } from '../../types'; - -/** - * Complex metric generated from kibana - */ -export const complexMetricAttributes: LensAttributes = { - title: 'Metric - Complex', - description: 'Complex Lens Metric', - visualizationType: 'lnsMetric', - state: { - visualization: { - layerId: '73144967-199a-451f-a407-e5e5e543cb9e', - layerType: 'data', - metricAccessor: '594aa5e9-9163-4b22-a19d-89b3546561d9', - secondaryMetricAccessor: 'c6c134aa-e1eb-4484-a5da-f864b2ed5095', - maxAccessor: 'f041d9d0-db1d-4648-8320-a58449159841', - color: '#FFf', - showBar: true, - secondaryTrend: { - type: 'none', - }, - secondaryLabelPosition: 'before', - }, - query: { - query: '', - language: 'kuery', - }, - filters: [], - datasourceStates: { - formBased: { - layers: { - '73144967-199a-451f-a407-e5e5e543cb9e': { - columns: { - '594aa5e9-9163-4b22-a19d-89b3546561d9': { - label: 'Count of records', - dataType: 'number', - operationType: 'count', - isBucketed: false, - sourceField: '___records___', - params: { - // @ts-expect-error why is this type erroring? - emptyAsNull: true, - }, - }, - 'c6c134aa-e1eb-4484-a5da-f864b2ed5095': { - label: 'Average of bytes', - dataType: 'number', - operationType: 'average', - sourceField: 'bytes', - isBucketed: false, - params: { - // @ts-expect-error why is this type erroring? - emptyAsNull: true, - }, - }, - 'f041d9d0-db1d-4648-8320-a58449159841': { - label: 'Maximum of bytes', - dataType: 'number', - operationType: 'max', - sourceField: 'bytes', - isBucketed: false, - params: { - // @ts-expect-error why is this type erroring? - emptyAsNull: true, - }, - }, - }, - columnOrder: [ - '594aa5e9-9163-4b22-a19d-89b3546561d9', - 'c6c134aa-e1eb-4484-a5da-f864b2ed5095', - 'f041d9d0-db1d-4648-8320-a58449159841', - ], - incompleteColumns: {}, - sampling: 1, - }, - }, - }, - // @ts-expect-error why is this type erroring? - indexpattern: { - layers: {}, - }, - textBased: { - layers: {}, - }, - }, - internalReferences: [], - adHocDataViews: {}, - }, - version: 2, - references: [ - { - type: 'index-pattern', - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'indexpattern-datasource-layer-73144967-199a-451f-a407-e5e5e543cb9e', - }, - ], -} satisfies LensAttributes; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/lens_api_config.mock.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/lens_api_config.mock.ts new file mode 100644 index 0000000000000..dba52f68e4eb9 --- /dev/null +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/lens_api_config.mock.ts @@ -0,0 +1,174 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { MetricState } from '../../schema'; + +export const breakdownMetricAPIAttributes = { + type: 'metric', + title: 'Metric - Breakdown', + description: 'Metric with breakdown', + dataset: { type: 'dataView', id: 'testId' }, + metrics: [ + { + type: 'primary', + operation: 'count', + empty_as_null: true, + color: { + type: 'dynamic', + range: 'absolute', + steps: [{ type: 'from', from: 0, color: 'red' }], + }, + }, + { + type: 'secondary', + operation: 'average', + field: 'bytes', + }, + ], + breakdown_by: { + operation: 'terms', + fields: ['extension.keyword'], + size: 5, + }, +} as MetricState; + +export const complexMetricAPIAttributes = { + type: 'metric', + title: 'Metric - Complex case', + description: 'Metric with background chart and breakdown', + dataset: { type: 'dataView', id: 'testId' }, + metrics: [ + { + type: 'primary', + operation: 'count', + empty_as_null: true, + color: { + type: 'dynamic', + range: 'absolute', + steps: [{ type: 'from', from: 0, color: 'red' }], + }, + background_chart: { + type: 'bar', + goal_value: { + operation: 'percentile', + field: 'bytes', + percentile: 95, + }, + }, + }, + { + type: 'secondary', + operation: 'average', + field: 'bytes', + compare: { + to: 'baseline', + baseline: 100, + palette: 'status', + value: false, + }, + }, + ], + breakdown_by: { + operation: 'terms', + fields: ['extension.keyword'], + size: 5, + }, +} as MetricState; + +export const simpleMetricAPIAttributes = { + type: 'metric', + title: 'Simple Metric', + description: 'A simple metric visualization', + dataset: { type: 'dataView', id: 'testId' }, + metrics: [ + { + type: 'primary', + operation: 'count', + label: 'Count of records', + empty_as_null: true, + }, + ], +} as MetricState; + +export const complexESQLMetricAPIAttributes = { + type: 'metric', + title: 'Metric - ESQL Complex case', + description: 'ESQL Metric with background chart and breakdown', + dataset: { type: 'esql', query: 'FROM logs | STATS ...' }, + metrics: [ + { + type: 'primary', + operation: 'value', + column: 'count', + color: { + type: 'dynamic', + range: 'absolute', + steps: [{ type: 'from', from: 0, color: 'red' }], + }, + background_chart: { + type: 'bar', + goal_value: { + operation: 'value', + column: 'bytes', + }, + }, + }, + { + type: 'secondary', + operation: 'value', + column: 'bytes', + compare: { + to: 'baseline', + baseline: 100, + palette: 'status', + value: false, + }, + }, + ], + breakdown_by: { + operation: 'value', + column: 'extension.keyword', + }, +} as MetricState; + +export const metricAPIWithTermsRankedBySecondary = { + type: 'metric', + title: 'Metric - Breakdown ranked by secondary', + description: 'Metric with breakdown ranked by secondary metric', + dataset: { type: 'dataView', id: 'testId' }, + ignore_global_filters: false, + sampling: 1, + metrics: [ + { + type: 'primary', + operation: 'count', + empty_as_null: true, + color: { + type: 'dynamic', + range: 'absolute', + steps: [{ type: 'from', from: 0, color: 'red' }], + }, + }, + { + type: 'secondary', + operation: 'average', + field: 'bytes', + }, + ], + breakdown_by: { + operation: 'terms', + fields: ['extension.keyword'], + size: 5, + rank_by: { + type: 'column', + metric: 1, + direction: 'desc', + }, + }, +} as MetricState; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/breakdown.mock.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/lens_state_config.mock.ts similarity index 69% rename from src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/breakdown.mock.ts rename to src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/lens_state_config.mock.ts index f2ad3e39d2cd7..4f4e6ac5c3508 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/breakdown.mock.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/lens_state_config.mock.ts @@ -170,6 +170,168 @@ export const breakdownMetricAttributes: LensAttributes = { ], } satisfies LensAttributes; +/** + * Complex metric generated from kibana + */ +export const complexMetricAttributes: LensAttributes = { + title: 'Metric - Complex', + description: 'Complex Lens Metric', + visualizationType: 'lnsMetric', + state: { + visualization: { + layerId: '73144967-199a-451f-a407-e5e5e543cb9e', + layerType: 'data', + metricAccessor: '594aa5e9-9163-4b22-a19d-89b3546561d9', + secondaryMetricAccessor: 'c6c134aa-e1eb-4484-a5da-f864b2ed5095', + maxAccessor: 'f041d9d0-db1d-4648-8320-a58449159841', + color: '#FFf', + showBar: true, + secondaryTrend: { + type: 'none', + }, + secondaryLabelPosition: 'before', + }, + query: { + query: '', + language: 'kuery', + }, + filters: [], + datasourceStates: { + formBased: { + layers: { + '73144967-199a-451f-a407-e5e5e543cb9e': { + columns: { + '594aa5e9-9163-4b22-a19d-89b3546561d9': { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + sourceField: '___records___', + params: { + // @ts-expect-error why is this type erroring? + emptyAsNull: true, + }, + }, + 'c6c134aa-e1eb-4484-a5da-f864b2ed5095': { + label: 'Average of bytes', + dataType: 'number', + operationType: 'average', + sourceField: 'bytes', + isBucketed: false, + params: { + // @ts-expect-error why is this type erroring? + emptyAsNull: true, + }, + }, + 'f041d9d0-db1d-4648-8320-a58449159841': { + label: 'Maximum of bytes', + dataType: 'number', + operationType: 'max', + sourceField: 'bytes', + isBucketed: false, + params: { + // @ts-expect-error why is this type erroring? + emptyAsNull: true, + }, + }, + }, + columnOrder: [ + '594aa5e9-9163-4b22-a19d-89b3546561d9', + 'c6c134aa-e1eb-4484-a5da-f864b2ed5095', + 'f041d9d0-db1d-4648-8320-a58449159841', + ], + incompleteColumns: {}, + sampling: 1, + }, + }, + }, + // @ts-expect-error why is this type erroring? + indexpattern: { + layers: {}, + }, + textBased: { + layers: {}, + }, + }, + internalReferences: [], + adHocDataViews: {}, + }, + version: 2, + references: [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'indexpattern-datasource-layer-73144967-199a-451f-a407-e5e5e543cb9e', + }, + ], +} satisfies LensAttributes; + +/** + * Simple metric generated from kibana + */ +export const simpleMetricAttributes: LensAttributes = { + title: 'Lens Metric - By Ref', + description: '', + visualizationType: 'lnsMetric', + state: { + visualization: { + layerId: '2821bd27-b805-4dea-a7d4-123c248e63b1', + layerType: 'data', + metricAccessor: '812a7944-731e-4967-8b84-1c8bba4ff04b', + secondaryTrend: { + type: 'none', + }, + secondaryLabelPosition: 'before', + }, + query: { + query: '', + language: 'kuery', + }, + filters: [], + datasourceStates: { + formBased: { + layers: { + '2821bd27-b805-4dea-a7d4-123c248e63b1': { + columns: { + '812a7944-731e-4967-8b84-1c8bba4ff04b': { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + sourceField: '___records___', + params: { + // @ts-expect-error why is this type erroring? + emptyAsNull: true, + }, + }, + }, + columnOrder: ['812a7944-731e-4967-8b84-1c8bba4ff04b'], + incompleteColumns: {}, + sampling: 1, + }, + }, + }, + // @ts-expect-error why is this type erroring? + indexpattern: { + layers: {}, + }, + textBased: { + layers: {}, + }, + }, + internalReferences: [], + adHocDataViews: {}, + }, + version: 2, + references: [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'indexpattern-datasource-layer-2821bd27-b805-4dea-a7d4-123c248e63b1', + }, + ], +} satisfies LensAttributes; + /** * Metric with formula reference columns and rank_by in the terms bucket operation */ diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/metric.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/metric.test.ts index a5ca739c378bb..40700c0de443e 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/metric.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/metric.test.ts @@ -8,29 +8,58 @@ */ import { metricStateSchema } from '../../schema/charts/metric'; -import { validateConverter } from '../validate'; -import { simpleMetricAttributes } from './simple.mock'; +import { validateAPIConverter, validateConverter } from '../validate'; import { + simpleMetricAttributes, breakdownMetricAttributes, + complexMetricAttributes, breakdownMetricWithFormulaRefColumnsAttributes, -} from './breakdown.mock'; -import { complexMetricAttributes } from './complex.mock'; +} from './lens_state_config.mock'; +import { + simpleMetricAPIAttributes, + breakdownMetricAPIAttributes, + complexMetricAPIAttributes, + complexESQLMetricAPIAttributes, + metricAPIWithTermsRankedBySecondary, +} from './lens_api_config.mock'; describe('Metric', () => { - it('should convert a simple metric', () => { - validateConverter(simpleMetricAttributes, metricStateSchema); - }); + describe('validateConverter', () => { + it('should convert a simple metric', () => { + validateConverter(simpleMetricAttributes, metricStateSchema); + }); + + it('should convert a complex metric', () => { + validateConverter(complexMetricAttributes, metricStateSchema); + }); - it('should convert a complex metric', () => { - validateConverter(complexMetricAttributes, metricStateSchema); + it('should convert a breakdown-by metric', () => { + validateConverter(breakdownMetricAttributes, metricStateSchema); + }); }); + describe('validateAPIConverter', () => { + it('should convert a simple metric', () => { + validateAPIConverter(simpleMetricAPIAttributes, metricStateSchema); + }); + + it('should convert a complex metric', () => { + validateAPIConverter(complexMetricAPIAttributes, metricStateSchema); + }); + + it('should convert a breakdown-by metric', () => { + validateAPIConverter(breakdownMetricAPIAttributes, metricStateSchema); + }); + + it('should convert a complex ESQL metric chart', () => { + validateAPIConverter(complexESQLMetricAPIAttributes, metricStateSchema); + }); - it('should convert a breakdown-by metric', () => { - validateConverter(breakdownMetricAttributes, metricStateSchema); + it('should convert a metric with a terms agg ranked by secondary metric', () => { + validateAPIConverter(metricAPIWithTermsRankedBySecondary, metricStateSchema); + }); }); - // TODO: This test should succeed once this https://github.com/elastic/kibana/pull/247119 is merged - it.skip('should convert a breakdown-by metric with formula reference columns and rank_by in the terms bucket operation', () => { + it('should convert a breakdown-by metric with formula reference columns and rank_by in the terms bucket operation', () => { validateConverter(breakdownMetricWithFormulaRefColumnsAttributes, metricStateSchema); }); }); diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/simple.mock.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/simple.mock.ts deleted file mode 100644 index 8dec68e22bade..0000000000000 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/simple.mock.ts +++ /dev/null @@ -1,76 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { LensAttributes } from '../../types'; - -/** - * Simple metric generated from kibana - */ -export const simpleMetricAttributes: LensAttributes = { - title: 'Lens Metric - By Ref', - description: '', - visualizationType: 'lnsMetric', - state: { - visualization: { - layerId: '2821bd27-b805-4dea-a7d4-123c248e63b1', - layerType: 'data', - metricAccessor: '812a7944-731e-4967-8b84-1c8bba4ff04b', - secondaryTrend: { - type: 'none', - }, - secondaryLabelPosition: 'before', - }, - query: { - query: '', - language: 'kuery', - }, - filters: [], - datasourceStates: { - formBased: { - layers: { - '2821bd27-b805-4dea-a7d4-123c248e63b1': { - columns: { - '812a7944-731e-4967-8b84-1c8bba4ff04b': { - label: 'Count of records', - dataType: 'number', - operationType: 'count', - isBucketed: false, - sourceField: '___records___', - params: { - // @ts-expect-error why is this type erroring? - emptyAsNull: true, - }, - }, - }, - columnOrder: ['812a7944-731e-4967-8b84-1c8bba4ff04b'], - incompleteColumns: {}, - sampling: 1, - }, - }, - }, - // @ts-expect-error why is this type erroring? - indexpattern: { - layers: {}, - }, - textBased: { - layers: {}, - }, - }, - internalReferences: [], - adHocDataViews: {}, - }, - version: 2, - references: [ - { - type: 'index-pattern', - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'indexpattern-datasource-layer-2821bd27-b805-4dea-a7d4-123c248e63b1', - }, - ], -} satisfies LensAttributes; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.test.ts deleted file mode 100644 index d30263c1ad3d4..0000000000000 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.test.ts +++ /dev/null @@ -1,447 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { - fromAPItoLensState, - fromLensStateToAPI, - LENS_METRIC_COMPARE_TO_PALETTE_DEFAULT, -} from './metric'; -import type { MetricState } from '../../schema'; -import { has, merge } from 'lodash'; -import { metricStateSchema } from '../../schema/charts/metric'; - -type InputTypeMetricChart = Omit< - MetricState, - 'sampling' | 'ignore_global_filters' | 'metric' | 'filters' | 'query' -> & { - ignore_global_filters?: MetricState['ignore_global_filters']; - sampling?: MetricState['sampling']; - metric: Omit & - Partial>; -}; - -const defaultValues = [ - { - path: 'metric', - value: { - metric: { - fit: false, - alignments: { - labels: 'left', - value: 'left', - }, - }, - } as const, - }, - { - path: 'breakdown_by', - value: { - breakdown_by: { - rank_by: { direction: 'asc', type: 'alphabetical' }, - }, - } as const, - }, - { - path: 'secondary_metric.compare', - value: { - secondary_metric: { - compare: { - palette: LENS_METRIC_COMPARE_TO_PALETTE_DEFAULT, - }, - } as const, - }, - }, -]; - -/** - * Mind that this won't include query/filters validation/defaults - */ -function validateAndApiToApiTransforms(originalObject: InputTypeMetricChart) { - const apiConverted = fromAPItoLensState(metricStateSchema.validate(originalObject)); - const apiCovertedWithFiltersAndQuery = { - ...apiConverted, - state: { - ...apiConverted.state, - filters: [], - query: { query: '', language: 'kuery' }, - }, - }; - return fromLensStateToAPI(apiCovertedWithFiltersAndQuery); -} - -function mergeWithDefaults(originalObject: InputTypeMetricChart) { - const defaults = [ - { - sampling: 1, - ignore_global_filters: false, - }, - ]; - for (const { path, value } of defaultValues) { - if (has(originalObject, path)) { - // @ts-expect-error - Need to figure out how to type this better - defaults.push(value); - } - } - // @ts-expect-error - Need to figure out how to type this better - return merge(...structuredClone(defaults), structuredClone(originalObject)); -} - -describe('metric chart transformations', () => { - describe('roundtrip conversion', () => { - it('basic metric chart with ad hoc dataView', () => { - const basicMetricConfig: InputTypeMetricChart = { - type: 'metric', - title: 'Test Metric', - dataset: { - type: 'index', - index: 'test-index', - time_field: '@timestamp', - }, - metric: { - operation: 'count', - label: 'Count of documents', - // @ts-expect-error - Need to figure out how get the right input type - empty_as_null: false, - }, - }; - - const finalAPIState = validateAndApiToApiTransforms(basicMetricConfig); - expect(finalAPIState).toEqual(mergeWithDefaults(basicMetricConfig)); - }); - - it('basic metric chart with dataView', () => { - const basicMetricConfig: InputTypeMetricChart = { - type: 'metric', - title: 'Test Metric', - description: 'A test metric chart', - dataset: { - type: 'dataView', - id: 'test-id', - }, - metric: { - operation: 'count', - label: 'Count of documents', - // @ts-expect-error - Need to figure out how get the right input type - empty_as_null: false, - }, - }; - - const finalAPIState = validateAndApiToApiTransforms(basicMetricConfig); - expect(mergeWithDefaults(basicMetricConfig)).toEqual(finalAPIState); - }); - - it('chart with secondary metric', () => { - const metricWithSecondaryConfig: InputTypeMetricChart = { - type: 'metric', - title: 'Test Metric with Secondary', - dataset: { - type: 'index', - index: 'test-index', - time_field: '@timestamp', - }, - metric: { - operation: 'unique_count', - // @ts-expect-error - Need to figure out how get the right input type - field: 'price', - label: 'Count of Prices', - fit: true, - color: { - type: 'static', - color: '#FF0000', - }, - empty_as_null: false, - }, - secondary_metric: { - operation: 'sum', - field: 'quantity', - label: 'Total Quantity', - prefix: 'Total: ', - empty_as_null: false, - }, - }; - - const finalAPIState = validateAndApiToApiTransforms(metricWithSecondaryConfig); - expect(mergeWithDefaults(metricWithSecondaryConfig)).toEqual(finalAPIState); - }); - - it('metric chart with breakdown', () => { - const metricWithBreakdownConfig: InputTypeMetricChart = { - type: 'metric', - title: 'Test Metric with Breakdown', - dataset: { - type: 'index', - index: 'test-index', - time_field: '@timestamp', - }, - metric: { - operation: 'sum', - // @ts-expect-error - Need to figure out how get the right input type - field: 'revenue', - label: 'Total Revenue', - icon: { - name: 'dollar', - align: 'left', - }, - empty_as_null: false, - }, - breakdown_by: { - operation: 'terms', - fields: ['category'], - columns: 3, - size: 5, - collapse_by: 'sum', - // encode the rank as it would be detected by the transforms - rank_by: { type: 'column', metric: 0, direction: 'desc' }, - }, - }; - - const finalAPIState = validateAndApiToApiTransforms(metricWithBreakdownConfig); - expect(mergeWithDefaults(metricWithBreakdownConfig)).toEqual(finalAPIState); - }); - - it('metric chart with background chart (bar)', () => { - const metricWithBarConfig: InputTypeMetricChart = { - type: 'metric', - title: 'Test Metric with Bar Background', - dataset: { - type: 'index', - index: 'test-index', - time_field: '@timestamp', - }, - metric: { - operation: 'count', - label: 'Document Count', - // @ts-expect-error - Need to figure out how get the right input type - empty_as_null: false, - background_chart: { - type: 'bar', - direction: 'horizontal', - goal_value: { - operation: 'max', - field: 'max_value', - }, - }, - }, - }; - - const finalAPIState = validateAndApiToApiTransforms(metricWithBarConfig); - expect(mergeWithDefaults(metricWithBarConfig)).toEqual(finalAPIState); - }); - - it('ESQL-based metric chart', () => { - const esqlMetricConfig: InputTypeMetricChart = { - type: 'metric', - title: 'Test ESQL Metric', - description: 'A test metric chart using ESQL', - dataset: { - type: 'esql', - query: 'FROM test-index | STATS count = COUNT(*)', - }, - metric: { - operation: 'value', - // @ts-expect-error - Need to figure out how get the right input type - column: 'count', - fit: true, - }, - }; - - const finalAPIState = validateAndApiToApiTransforms(esqlMetricConfig); - expect(mergeWithDefaults(esqlMetricConfig)).toEqual(finalAPIState); - }); - - it('comprehensive metric chart with ad hoc data view', () => { - const comprehensiveMetricConfig: InputTypeMetricChart = { - type: 'metric', - title: 'Comprehensive Test Metric', - description: 'A comprehensive metric chart with all features', - dataset: { - type: 'index', - index: 'comprehensive-index', - time_field: '@timestamp', - }, - metric: { - operation: 'sum', - // @ts-expect-error - Need to figure out how get the right input type - field: 'response_time', - label: 'Sum Response Time', - sub_label: 'milliseconds', - fit: true, - icon: { - name: 'clock', - align: 'right', - }, - color: { - type: 'static', - color: '#00FF00', - }, - background_chart: { - type: 'trend', - }, - empty_as_null: false, - }, - secondary_metric: { - operation: 'count', - label: 'Request Count', - prefix: 'Requests: ', - compare: { - to: 'primary', - icon: false, - value: true, - }, - empty_as_null: false, - }, - breakdown_by: { - operation: 'terms', - fields: ['service_name'], - columns: 5, - size: 10, - // encode the rank as it would be detected by the transforms - rank_by: { type: 'column', metric: 0, direction: 'desc' }, - }, - }; - - const finalAPIState = validateAndApiToApiTransforms(comprehensiveMetricConfig); - expect(mergeWithDefaults(comprehensiveMetricConfig)).toEqual(finalAPIState); - }); - - it('comprehensive metric chart with data view', () => { - const comprehensiveMetricConfig: InputTypeMetricChart = { - type: 'metric', - title: 'Comprehensive Test Metric', - description: 'A comprehensive metric chart with all features', - dataset: { - type: 'dataView', - id: 'my-custom-data-view-id', - }, - metric: { - operation: 'average', - // @ts-expect-error - Need to figure out how get the right input type - field: 'response_time', - label: 'Avg Response Time', - sub_label: 'milliseconds', - alignments: { - labels: 'center', - value: 'right', - }, - fit: true, - icon: { - name: 'clock', - align: 'right', - }, - color: { - type: 'dynamic', - steps: [ - { type: 'from', from: 0, color: '#00FF00' }, - { type: 'exact', value: 300, color: '#FFFF00' }, - { type: 'to', to: 300, color: '#FF0000' }, - ], - range: 'absolute', - }, - background_chart: { - type: 'trend', - }, - }, - secondary_metric: { - operation: 'count', - label: 'Request Count', - prefix: 'Requests: ', - color: { - type: 'static', - color: '#0000FF', - }, - empty_as_null: false, - }, - breakdown_by: { - operation: 'terms', - fields: ['service_name'], - columns: 5, - size: 10, - // encode the rank as it would be detected by the transforms - rank_by: { type: 'column', metric: 0, direction: 'desc' }, - }, - }; - const finalAPIState = validateAndApiToApiTransforms(comprehensiveMetricConfig); - expect(mergeWithDefaults(comprehensiveMetricConfig)).toEqual(finalAPIState); - }); - - it('comprehensive ESQL-based metric chart with compare to feature', () => { - const esqlMetricConfig: InputTypeMetricChart = { - type: 'metric', - title: 'Test ESQL Metric', - description: 'A test metric chart using ESQL', - dataset: { - type: 'esql', - query: - 'FROM test-index | STATS countA = COUNT(*) WHERE a > 1, countB = COUNT(*) WHERE b > 1', - }, - metric: { - operation: 'value', - // @ts-expect-error - Need to figure out how get the right input type - column: 'countA', - alignments: { - labels: 'left', - value: 'left', - }, - fit: true, - }, - secondary_metric: { - operation: 'value', - column: 'countB', - compare: { - to: 'primary', - icon: true, - value: false, - }, - }, - }; - - // Convert API config to Lens state and back - const finalAPIState = validateAndApiToApiTransforms(esqlMetricConfig); - expect(mergeWithDefaults(esqlMetricConfig)).toEqual(finalAPIState); - }); - - it('should handle apply color to property', () => { - const applyToColorMetricChart: InputTypeMetricChart = { - type: 'metric', - title: 'Comprehensive Test Metric', - description: 'A comprehensive metric chart with all features', - dataset: { - type: 'dataView', - id: 'my-custom-data-view-id', - }, - metric: { - operation: 'average', - // @ts-expect-error - Need to figure out how get the right input type - field: 'response_time', - label: 'Avg Response Time', - sub_label: 'milliseconds', - alignments: { - labels: 'center', - value: 'right', - }, - fit: true, - icon: { - name: 'clock', - align: 'right', - }, - color: { - type: 'static', - color: '#00FF00', - }, - background_chart: { - type: 'trend', - }, - apply_color_to: 'value', - }, - }; - const finalAPIState = validateAndApiToApiTransforms(applyToColorMetricChart); - expect(mergeWithDefaults(applyToColorMetricChart)).toEqual(finalAPIState); - }); - }); -}); diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.ts index f89af82f2f309..546b41543d856 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/charts/metric.ts @@ -7,17 +7,18 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { - FormBasedLayer, - FormBasedPersistedState, - MetricVisualizationState, - PersistedIndexPatternLayer, - TextBasedLayer, - TypedLensSerializedState, +import { + LENS_METRIC_BREAKDOWN_DEFAULT_MAX_COLUMNS, + LENS_METRIC_STATE_DEFAULTS, + type FormBasedPersistedState, + type MetricVisualizationState, + type PersistedIndexPatternLayer, + type TextBasedLayer, + type TypedLensSerializedState, } from '@kbn/lens-common'; import type { SavedObjectReference } from '@kbn/core/types'; import type { DataViewSpec } from '@kbn/data-views-plugin/common'; -import type { LensAttributes } from '../../types'; +import type { DeepWriteable, LensAttributes } from '../../types'; import { DEFAULT_LAYER_ID } from '../../types'; import { addLayerColumn, @@ -26,17 +27,22 @@ import { buildReferences, generateApiLayer, getAdhocDataviews, + isTextBasedLayer, + nonNullable, operationFromColumn, } from '../utils'; import { fromBucketLensApiToLensState } from '../columns/buckets'; import { getValueApiColumn, getValueColumn } from '../columns/esql_column'; import type { MetricState } from '../../schema'; import { fromMetricAPItoLensState } from '../columns/metric'; -import type { LensApiAllMetricOperations } from '../../schema/metric_ops'; import type { LensApiBucketOperations } from '../../schema/bucket_ops'; -import type { DeepMutable, DeepPartial } from '../utils'; import { generateLayer } from '../utils'; -import type { MetricStateESQL, MetricStateNoESQL } from '../../schema/charts/metric'; +import type { + MetricStateESQL, + MetricStateNoESQL, + PrimaryMetricType, + SecondaryMetricType, +} from '../../schema/charts/metric'; import { getSharedChartLensStateToAPI, getSharedChartAPIToLensState, @@ -50,11 +56,11 @@ import { fromStaticColorAPIToLensState, fromStaticColorLensStateToAPI, } from '../coloring'; +import { isAPIColumnOfBucketType, isAPIColumnOfMetricType } from '../columns/utils'; + +type MetricApiCompareType = Extract, { compare: any }>['compare']; -type MetricApiCompareType = Extract< - Required, - { compare: any } ->['compare']; +type WritableMetricStateWithoutDataset = DeepWriteable>; const ACCESSOR = 'metric_accessor'; const HISTOGRAM_COLUMN_NAME = 'x_date_histogram'; @@ -66,6 +72,19 @@ function getAccessorName(type: 'metric' | 'max' | 'breakdown' | 'secondary') { return `${ACCESSOR}_${type}`; } +function getCompareVisualsState(compare: MetricApiCompareType) { + if (compare.icon && compare.value) { + return 'both'; + } + if (compare.icon === true || (compare.icon == null && compare.value === false)) { + return 'icon'; + } + if (compare.value === true || (compare.value == null && compare.icon === false)) { + return 'value'; + } + return 'both'; +} + function fromCompareAPIToLensState(compareToConfig: MetricApiCompareType): { secondaryTrend: Extract; } { @@ -74,57 +93,73 @@ function fromCompareAPIToLensState(compareToConfig: MetricApiCompareType): { type: 'dynamic', baselineValue: compareToConfig.to === 'primary' ? compareToConfig.to : compareToConfig.baseline, - visuals: - compareToConfig.icon && compareToConfig.value - ? 'both' - : compareToConfig.icon - ? 'icon' - : 'value', + visuals: getCompareVisualsState(compareToConfig), reversed: compareToConfig.palette?.includes('reversed') ?? LENS_METRIC_COMPARE_TO_REVERSED, paletteId: compareToConfig.palette ?? LENS_METRIC_COMPARE_TO_PALETTE_DEFAULT, }, }; } +function isSecondaryMetric(metric: MetricState['metrics'][number]): metric is SecondaryMetricType { + return metric.type === 'secondary'; +} + +function isPrimaryMetric(metric: MetricState['metrics'][number]): metric is PrimaryMetricType { + return metric.type === 'primary'; +} + function buildVisualizationState(config: MetricState): MetricVisualizationState { const layer = config; + const [primaryMetric, secondaryMetric] = layer.metrics; + + if (isSecondaryMetric(primaryMetric)) { + throw new Error('The first metric must be the primary metric.'); + } + + if (secondaryMetric && isPrimaryMetric(secondaryMetric)) { + throw new Error('The second metric must be the secondary metric.'); + } + return { layerId: DEFAULT_LAYER_ID, layerType: 'data', metricAccessor: getAccessorName('metric'), - ...(layer.metric.color?.type === 'static' - ? fromStaticColorAPIToLensState(layer.metric.color) + ...(primaryMetric.color?.type === 'static' + ? fromStaticColorAPIToLensState(primaryMetric.color) : {}), - ...(layer.metric.color?.type === 'dynamic' - ? { palette: fromColorByValueAPIToLensState(layer.metric.color) } + ...(primaryMetric.color?.type === 'dynamic' + ? { palette: fromColorByValueAPIToLensState(primaryMetric.color) } : {}), - ...(layer.metric.apply_color_to ? { applyColorTo: layer.metric.apply_color_to } : {}), - subtitle: layer.metric.sub_label ?? '', + ...(primaryMetric.apply_color_to ? { applyColorTo: primaryMetric.apply_color_to } : {}), + subtitle: primaryMetric.sub_label ?? '', showBar: false, - valueFontMode: layer.metric.fit ? 'fit' : 'default', - ...(layer.metric.alignments + valueFontMode: primaryMetric.fit ? 'fit' : 'default', + ...(primaryMetric.alignments ? { - valuesTextAlign: layer.metric.alignments.value, - titlesTextAlign: layer.metric.alignments.labels, + valuesTextAlign: primaryMetric.alignments.value, + titlesTextAlign: primaryMetric.alignments.labels, } : {}), - ...(layer.metric.icon + ...(primaryMetric.icon ? { - icon: layer.metric.icon.name, - iconAlign: layer.metric.icon.align, + icon: primaryMetric.icon.name, + iconAlign: primaryMetric.icon.align, } : {}), - ...(layer.secondary_metric + ...(secondaryMetric ? { secondaryMetricAccessor: getAccessorName('secondary'), - secondaryPrefix: layer.secondary_metric.prefix, - secondaryAlign: layer.metric.alignments?.value, - ...(layer.secondary_metric.compare - ? fromCompareAPIToLensState(layer.secondary_metric.compare) + ...('prefix' in secondaryMetric && secondaryMetric.prefix + ? { secondaryPrefix: secondaryMetric.prefix } + : {}), + secondaryAlign: + 'alignments' in primaryMetric ? primaryMetric.alignments?.value : undefined, + ...('compare' in secondaryMetric && secondaryMetric.compare + ? fromCompareAPIToLensState(secondaryMetric.compare) : {}), - ...(layer.secondary_metric.color?.type === 'static' - ? { secondaryTrend: { type: 'static', color: layer.secondary_metric.color.color } } + ...(secondaryMetric.color?.type === 'static' + ? { secondaryTrend: { type: 'static', color: secondaryMetric.color.color } } : {}), } : {}), @@ -135,21 +170,23 @@ function buildVisualizationState(config: MetricState): MetricVisualizationState } : {}), collapseFn: layer.breakdown_by?.collapse_by, - ...(layer.metric?.background_chart?.type === 'bar' + ...(primaryMetric?.background_chart?.type === 'bar' ? { maxAccessor: getAccessorName('max'), showBar: true, - progressDirection: layer.metric.background_chart.direction, + ...(primaryMetric.background_chart.direction != null + ? { progressDirection: primaryMetric.background_chart.direction } + : {}), } : {}), - ...(layer.metric.background_chart?.type === 'trend' + ...(primaryMetric.background_chart?.type === 'trend' ? { trendlineLayerId: `${DEFAULT_LAYER_ID}_trendline`, trendlineLayerType: 'metricTrendline', trendlineMetricAccessor: `${ACCESSOR}_trendline`, trendlineTimeAccessor: HISTOGRAM_COLUMN_NAME, - ...(layer.secondary_metric + ...(secondaryMetric ? { trendlineSecondaryMetricAccessor: `${ACCESSOR}_secondary_trendline`, } @@ -186,179 +223,249 @@ function fromCompareLensStateToAPI( }; } -function reverseBuildVisualizationState( - visualization: MetricVisualizationState, - layer: Omit | TextBasedLayer, - layerId: string, - adHocDataViews: Record, - references: SavedObjectReference[], - adhocReferences?: SavedObjectReference[] -): MetricState { - const metricAccessor = getMetricAccessor(visualization); - if (metricAccessor == null) { - throw new Error('Metric accessor is missing in the visualization state'); +function buildFromTextBasedLayer( + layer: TextBasedLayer, + metricAccessor: string, + visualization: MetricVisualizationState +): WritableMetricStateWithoutDataset { + return enrichConfigurationWithVisualizationProperties( + { + type: 'metric', + ...generateApiLayer(layer), + metrics: [ + { + type: 'primary', + ...getValueApiColumn(metricAccessor, layer), + ...(visualization.maxAccessor + ? { + background_chart: { + type: 'bar', + goal_value: getValueApiColumn(visualization.maxAccessor, layer), + ...(visualization.progressDirection + ? { direction: visualization.progressDirection } + : {}), + }, + } + : {}), + }, + visualization.secondaryMetricAccessor + ? { + type: 'secondary', + ...getValueApiColumn(visualization.secondaryMetricAccessor, layer), + } + : undefined, + ].filter(nonNullable) as MetricState['metrics'], + ...(visualization.breakdownByAccessor + ? { + breakdown_by: { + ...getValueApiColumn(visualization.breakdownByAccessor, layer), + columns: visualization.maxCols ?? LENS_METRIC_BREAKDOWN_DEFAULT_MAX_COLUMNS, + }, + } + : {}), + }, + visualization + ); +} + +function buildFromFormBasedLayer( + layer: PersistedIndexPatternLayer, + metricAccessor: string, + visualization: MetricVisualizationState +): WritableMetricStateWithoutDataset { + const metric = operationFromColumn(metricAccessor, layer); + if (!metric || !isAPIColumnOfMetricType(metric)) { + throw Error('The primary metric must refer to a metric operation.'); } - const dataset = buildDatasetState(layer, layerId, adHocDataViews, references, adhocReferences); + const maxValue = visualization.maxAccessor + ? operationFromColumn(visualization.maxAccessor, layer) + : undefined; - if (!dataset || dataset.type == null) { - throw new Error('Unsupported dataset type'); + if (maxValue && isAPIColumnOfBucketType(maxValue)) { + throw Error('The max value must refer to a metric operation.'); + } + + const primaryMetric = { + type: 'primary', + ...metric, + ...(maxValue + ? { + background_chart: { + type: 'bar', + goal_value: maxValue, + ...(visualization.progressDirection + ? { direction: visualization.progressDirection } + : {}), + }, + } + : {}), + } as PrimaryMetricType; + + const metrics = [primaryMetric] as [PrimaryMetricType, SecondaryMetricType?]; + + const secondaryMetricOperation = visualization.secondaryMetricAccessor + ? operationFromColumn(visualization.secondaryMetricAccessor, layer) + : undefined; + + if (secondaryMetricOperation) { + if (!isAPIColumnOfMetricType(secondaryMetricOperation)) { + throw Error('The secondary metric must refer to a metric operation.'); + } + const secondaryMetric = { + type: 'secondary', + ...secondaryMetricOperation, + } as SecondaryMetricType; + metrics.push(secondaryMetric); } - let props: DeepPartial> = generateApiLayer(layer); + // eslint-disable-next-line @typescript-eslint/naming-convention + const breakdown_by = visualization.breakdownByAccessor + ? operationFromColumn(visualization.breakdownByAccessor, layer) + : undefined; + if (breakdown_by && !isAPIColumnOfBucketType(breakdown_by)) { + throw Error('The breakdown by must refer to a bucket operation.'); + } - if (dataset.type === 'esql' || dataset.type === 'table') { - const esqlLayer = layer as TextBasedLayer; - props = { - ...props, - metric: getValueApiColumn(metricAccessor, esqlLayer), - ...(visualization.secondaryMetricAccessor - ? { - secondary_metric: { - ...getValueApiColumn(visualization.secondaryMetricAccessor, esqlLayer), - ...(visualization.maxAccessor - ? { - background_chart: { - type: 'bar', - goal_value: getValueApiColumn(visualization.maxAccessor, esqlLayer), - direction: visualization.progressDirection, - }, - } - : {}), - }, - } - : {}), - ...(visualization.breakdownByAccessor + return enrichConfigurationWithVisualizationProperties( + { + type: 'metric', + ...generateApiLayer(layer), + metrics: metrics as MetricState['metrics'], + ...(breakdown_by ? { breakdown_by: { - ...getValueApiColumn(visualization.breakdownByAccessor, esqlLayer), - columns: visualization.maxCols, + ...breakdown_by, + columns: visualization.maxCols ?? LENS_METRIC_BREAKDOWN_DEFAULT_MAX_COLUMNS, }, } : {}), - } as MetricState; - } else if (dataset.type === 'dataView' || dataset.type === 'index') { - const formLayer = layer as FormBasedLayer; - const metric = operationFromColumn(metricAccessor, formLayer) as LensApiAllMetricOperations; - // eslint-disable-next-line @typescript-eslint/naming-convention - const secondary_metric = visualization.secondaryMetricAccessor - ? (operationFromColumn( - visualization.secondaryMetricAccessor, - formLayer - ) as LensApiAllMetricOperations) - : undefined; - // eslint-disable-next-line @typescript-eslint/naming-convention - const max_value = visualization.maxAccessor - ? (operationFromColumn(visualization.maxAccessor, formLayer) as LensApiAllMetricOperations) - : undefined; - // eslint-disable-next-line @typescript-eslint/naming-convention - const breakdown_by = visualization.breakdownByAccessor - ? operationFromColumn(visualization.breakdownByAccessor, formLayer) - : undefined; - - props = { - ...props, - metric: { - ...metric, - ...(max_value ?? props.metric?.background_chart - ? { - background_chart: { - ...(max_value - ? { - type: 'bar', - goal_value: max_value, - direction: visualization.progressDirection, - } - : props.metric?.background_chart), - }, - } - : {}), - }, - ...(secondary_metric ? { secondary_metric: { ...secondary_metric } } : {}), - ...(breakdown_by ? { breakdown_by } : {}), - } as MetricState; - } + }, + visualization + ); +} + +function enrichConfigurationWithVisualizationProperties( + state: WritableMetricStateWithoutDataset, + visualization: MetricVisualizationState +): WritableMetricStateWithoutDataset { + const [primaryMetric, secondaryMetric] = state.metrics; - if (props.metric) { + if (isSecondaryMetric(primaryMetric)) { + throw new Error('The first metric must be the primary metric.'); + } + if (secondaryMetric != null && isPrimaryMetric(secondaryMetric)) { + throw new Error('The second metric must be the secondary metric.'); + } + if (primaryMetric) { if (visualization.subtitle) { - props.metric.sub_label = visualization.subtitle; + primaryMetric.sub_label = visualization.subtitle; } if (visualization.trendlineLayerType) { - props.metric.background_chart = { ...props.metric.background_chart, type: 'trend' }; + primaryMetric.background_chart = { ...primaryMetric.background_chart, type: 'trend' }; } if (visualization.color) { - props.metric.color = fromStaticColorLensStateToAPI(visualization.color); + primaryMetric.color = fromStaticColorLensStateToAPI(visualization.color); } if (visualization.palette) { const colorByValue = fromColorByValueLensStateToAPI(visualization.palette); if (colorByValue?.range === 'absolute') { - props.metric.color = colorByValue; + primaryMetric.color = colorByValue; } } if (visualization.applyColorTo) { - props.metric.apply_color_to = visualization.applyColorTo; + primaryMetric.apply_color_to = visualization.applyColorTo; } if (visualization.icon) { - props.metric.icon = { + primaryMetric.icon = { name: visualization.icon, - align: visualization.iconAlign, + align: visualization.iconAlign ?? LENS_METRIC_STATE_DEFAULTS.iconAlign, }; } if (visualization.valuesTextAlign || visualization.titlesTextAlign) { - props.metric.alignments = { - ...(visualization.valuesTextAlign ? { value: visualization.valuesTextAlign } : {}), - ...(visualization.titlesTextAlign ? { labels: visualization.titlesTextAlign } : {}), + primaryMetric.alignments = { + value: visualization.valuesTextAlign ?? LENS_METRIC_STATE_DEFAULTS.valuesTextAlign, + labels: visualization.titlesTextAlign ?? LENS_METRIC_STATE_DEFAULTS.titlesTextAlign, }; } - props.metric.fit = visualization.valueFontMode === 'fit'; + primaryMetric.fit = visualization.valueFontMode === 'fit'; } - if (props.secondary_metric) { + if (secondaryMetric) { if (visualization.secondaryTrend?.type === 'dynamic') { - props.secondary_metric.compare = fromCompareLensStateToAPI(visualization.secondaryTrend); + secondaryMetric.compare = fromCompareLensStateToAPI(visualization.secondaryTrend); } if (visualization.secondaryPrefix) { - props.secondary_metric.prefix = visualization.secondaryPrefix; + secondaryMetric.prefix = visualization.secondaryPrefix; } if (visualization.secondaryTrend?.type === 'static' && visualization.secondaryTrend?.color) { - props.secondary_metric.color = { + secondaryMetric.color = { type: 'static', color: visualization.secondaryTrend.color, }; } } - if (props.breakdown_by) { + if (state.breakdown_by) { if (visualization.maxCols) { - props.breakdown_by.columns = visualization.maxCols; + state.breakdown_by.columns = visualization.maxCols; } if (visualization.collapseFn) { - props.breakdown_by.collapse_by = visualization.collapseFn; + state.breakdown_by.collapse_by = visualization.collapseFn; } } + return state; +} + +function reverseBuildVisualizationState( + visualization: MetricVisualizationState, + layer: PersistedIndexPatternLayer | TextBasedLayer, + layerId: string, + adHocDataViews: Record, + references: SavedObjectReference[], + adhocReferences?: SavedObjectReference[] +): MetricState { + const metricAccessor = getMetricAccessor(visualization); + if (metricAccessor == null) { + throw new Error('Metric accessor is missing in the visualization state'); + } + + const dataset = buildDatasetState(layer, layerId, adHocDataViews, references, adhocReferences); + + if (!dataset || dataset.type == null) { + throw new Error('Unsupported dataset type'); + } return { - type: 'metric', dataset: dataset satisfies MetricState['dataset'], - ...props, + ...(isTextBasedLayer(layer) + ? buildFromTextBasedLayer(layer, metricAccessor, visualization) + : buildFromFormBasedLayer(layer, metricAccessor, visualization)), } as MetricState; } function buildFormBasedLayer(layer: MetricStateNoESQL): FormBasedPersistedState['layers'] { - const columns = fromMetricAPItoLensState(layer.metric as LensApiAllMetricOperations); + const [primaryMetric, secondaryMetric] = layer.metrics ?? []; + if (!isAPIColumnOfMetricType(primaryMetric) || isSecondaryMetric(primaryMetric)) { + throw Error('The primary metric must refer to a metric operation.'); + } + const newPrimaryColumns = fromMetricAPItoLensState(primaryMetric); + const newSecondaryColumns = secondaryMetric + ? fromMetricAPItoLensState(secondaryMetric) + : undefined; const layers: Record = { ...generateLayer(DEFAULT_LAYER_ID, layer), - ...(layer.metric?.background_chart?.type === 'trend' + ...(primaryMetric.background_chart?.type === 'trend' ? generateLayer(TRENDLINE_LAYER_ID, layer) : {}), }; @@ -370,17 +477,23 @@ function buildFormBasedLayer(layer: MetricStateNoESQL): FormBasedPersistedState[ trendLineLayer.linkToLayers = [DEFAULT_LAYER_ID]; } - addLayerColumn(defaultLayer, getAccessorName('metric'), columns); + addLayerColumn(defaultLayer, getAccessorName('metric'), newPrimaryColumns); if (trendLineLayer) { - addLayerColumn(trendLineLayer, `${ACCESSOR}_trendline`, columns); - addLayerColumn(trendLineLayer, HISTOGRAM_COLUMN_NAME, columns); + addLayerColumn(trendLineLayer, `${ACCESSOR}_trendline`, newPrimaryColumns); + addLayerColumn(trendLineLayer, HISTOGRAM_COLUMN_NAME, newPrimaryColumns); } if (layer.breakdown_by) { const columnName = getAccessorName('breakdown'); const breakdownColumn = fromBucketLensApiToLensState( layer.breakdown_by as LensApiBucketOperations, - columns.map((col) => ({ column: col, id: getAccessorName('metric') })) + [ + ...newPrimaryColumns.map((col) => ({ column: col, id: getAccessorName('metric') })), + ...(newSecondaryColumns ?? []).map((col) => ({ + column: col, + id: getAccessorName('secondary'), + })), + ] ); addLayerColumn(defaultLayer, columnName, breakdownColumn, true); @@ -389,21 +502,17 @@ function buildFormBasedLayer(layer: MetricStateNoESQL): FormBasedPersistedState[ } } - if (layer.secondary_metric) { + if (newSecondaryColumns?.length) { const columnName = getAccessorName('secondary'); - const newColumn = fromMetricAPItoLensState( - layer.secondary_metric as LensApiAllMetricOperations - ); - - addLayerColumn(defaultLayer, columnName, newColumn); + addLayerColumn(defaultLayer, columnName, newSecondaryColumns); if (trendLineLayer) { - addLayerColumn(trendLineLayer, `${columnName}_trendline`, newColumn, false, 'X0'); + addLayerColumn(trendLineLayer, `${columnName}_trendline`, newSecondaryColumns, false, 'X0'); } } - if (layer.metric?.background_chart?.type === 'bar') { + if (primaryMetric.background_chart?.type === 'bar') { const columnName = getAccessorName('max'); - const newColumn = fromMetricAPItoLensState(layer.metric.background_chart.goal_value); + const newColumn = fromMetricAPItoLensState(primaryMetric.background_chart.goal_value); addLayerColumn(defaultLayer, columnName, newColumn); if (trendLineLayer) { @@ -415,22 +524,29 @@ function buildFormBasedLayer(layer: MetricStateNoESQL): FormBasedPersistedState[ } function getValueColumns(layer: MetricStateESQL) { + const [primaryMetric, secondaryMetric] = layer.metrics ?? []; + if (isSecondaryMetric(primaryMetric)) { + throw Error('The primary metric must refer to a metric operation.'); + } + if (secondaryMetric && isPrimaryMetric(secondaryMetric)) { + throw Error('The secondary metric must refer to a metric operation.'); + } return [ ...(layer.breakdown_by ? [getValueColumn(getAccessorName('breakdown'), layer.breakdown_by.column)] : []), - getValueColumn(getAccessorName('metric'), layer.metric.column, 'number'), - ...(layer.metric?.background_chart?.type === 'bar' + getValueColumn(getAccessorName('metric'), primaryMetric.column, 'number'), + ...(primaryMetric.background_chart?.type === 'bar' ? [ getValueColumn( getAccessorName('max'), - layer.metric.background_chart.goal_value.operation, + primaryMetric.background_chart.goal_value.column, 'number' ), ] : []), - ...(layer.secondary_metric - ? [getValueColumn(getAccessorName('secondary'), layer.secondary_metric.column)] + ...(secondaryMetric + ? [getValueColumn(getAccessorName('secondary'), secondaryMetric.column)] : []), ]; } diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/utils.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/utils.test.ts index b948f997c9295..cde2f0ac1a439 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/utils.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/utils.test.ts @@ -147,13 +147,16 @@ describe('buildDatasourceStates', () => { type: 'esql', query: 'from test | limit 10', }, - metric: { - operation: 'value', - label: 'test', - column: 'test', - fit: false, - alignments: { labels: 'left', value: 'left' }, - }, + metrics: [ + { + type: 'primary', + operation: 'value', + label: 'test', + column: 'test', + fit: false, + alignments: { labels: 'left', value: 'left' }, + }, + ], sampling: 1, ignore_global_filters: false, }, @@ -415,13 +418,16 @@ describe('filtersAndQueryToLensState', () => { type: 'esql', query: 'from test | limit 10', }, - metric: { - operation: 'value', - label: 'test', - column: 'test', - fit: false, - alignments: { labels: 'left', value: 'left' }, - }, + metrics: [ + { + type: 'primary', + operation: 'value', + label: 'test', + column: 'test', + fit: false, + alignments: { labels: 'left', value: 'left' }, + }, + ], sampling: 1, ignore_global_filters: false, filters: [ @@ -447,13 +453,16 @@ describe('filtersAndQueryToLensState', () => { type: 'esql', query: 'from test | limit 10', }, - metric: { - operation: 'value', - label: 'test', - column: 'test', - fit: false, - alignments: { labels: 'left', value: 'left' }, - }, + metrics: [ + { + type: 'primary', + operation: 'value', + label: 'test', + column: 'test', + fit: false, + alignments: { labels: 'left', value: 'left' }, + }, + ], sampling: 1, ignore_global_filters: false, }; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/types.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/types.ts index 85e5bbb7b0b32..20399e5fff03a 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/types.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/types.ts @@ -21,6 +21,7 @@ export type DataViewsCommon = Pick; export type LensAttributes = TypedLensByValueInput['attributes']; export const DEFAULT_LAYER_ID = 'layer_0'; +export type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; type Identity = T extends object ? { diff --git a/x-pack/examples/lens_config_builder_example/public/app.tsx b/x-pack/examples/lens_config_builder_example/public/app.tsx index cbfef62f843db..9e8c5cae4792d 100644 --- a/x-pack/examples/lens_config_builder_example/public/app.tsx +++ b/x-pack/examples/lens_config_builder_example/public/app.tsx @@ -54,16 +54,19 @@ export const App = (props: { type: 'esql', query: 'from kibana_sample_data_logs | stats totalBytes = sum(bytes)', }, - metric: { - operation: 'value', - column: 'totalBytes', - label: 'Total Bytes Value', - fit: false, - alignments: { - value: 'left', - labels: 'left', + metrics: [ + { + type: 'primary', + operation: 'value', + column: 'totalBytes', + label: 'Total Bytes Value', + fit: false, + alignments: { + value: 'left', + labels: 'left', + }, }, - }, + ], ignore_global_filters: true, sampling: 1, });