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 999b83a513882..02a11608cc061 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 @@ -65,8 +65,6 @@ type MetricApiCompareType = Extract, { compare: an type WritableMetricStateWithoutDataset = DeepWriteable>; const ACCESSOR = 'metric_accessor'; -const HISTOGRAM_COLUMN_NAME = 'x_date_histogram'; -const TRENDLINE_LAYER_ID = 'layer_0_trendline'; export const LENS_METRIC_COMPARE_TO_PALETTE_DEFAULT: KbnPaletteId = 'compare_to'; const LENS_METRIC_COMPARE_TO_REVERSED = false; @@ -191,19 +189,18 @@ function buildVisualizationState(config: MetricState): MetricVisualizationState ...(primaryMetric.background_chart?.type === 'trend' ? { - trendlineLayerId: `${DEFAULT_LAYER_ID}_trendline`, + trendlineLayerId: `trendline_layer`, trendlineLayerType: 'metricTrendline', - trendlineMetricAccessor: `${ACCESSOR}_trendline`, - trendlineTimeAccessor: HISTOGRAM_COLUMN_NAME, - ...(secondaryMetric + trendlineMetricAccessor: `trendline_y`, + trendlineTimeAccessor: 'trendline_x', + ...(layer.breakdown_by ? { - trendlineSecondaryMetricAccessor: `${ACCESSOR}_secondary_trendline`, + trendlineBreakdownByAccessor: `trendline_by`, } : {}), - - ...(layer.breakdown_by + ...(secondaryMetric ? { - trendlineBreakdownByAccessor: `${ACCESSOR}_breakdown_trendline`, + trendlineSecondaryMetricAccessor: `trendline_secondary`, } : {}), } @@ -499,23 +496,11 @@ function buildFormBasedLayer(layer: MetricStateNoESQL): FormBasedPersistedState[ const layers: Record = { ...generateLayer(DEFAULT_LAYER_ID, layer), - ...(primaryMetric.background_chart?.type === 'trend' - ? generateLayer(TRENDLINE_LAYER_ID, layer) - : {}), }; const defaultLayer = layers[DEFAULT_LAYER_ID]; - const trendLineLayer = layers[TRENDLINE_LAYER_ID]; - - if (trendLineLayer) { - trendLineLayer.linkToLayers = [DEFAULT_LAYER_ID]; - } addLayerColumn(defaultLayer, getAccessorName('metric'), newPrimaryColumns); - if (trendLineLayer) { - addLayerColumn(trendLineLayer, `${ACCESSOR}_trendline`, newPrimaryColumns); - addLayerColumn(trendLineLayer, HISTOGRAM_COLUMN_NAME, newPrimaryColumns); - } if (layer.breakdown_by) { const columnName = getAccessorName('breakdown'); @@ -530,18 +515,11 @@ function buildFormBasedLayer(layer: MetricStateNoESQL): FormBasedPersistedState[ ] ); addLayerColumn(defaultLayer, columnName, breakdownColumn, true); - - if (trendLineLayer) { - addLayerColumn(trendLineLayer, `${columnName}_trendline`, breakdownColumn, true); - } } if (newSecondaryColumns?.length) { const columnName = getAccessorName('secondary'); addLayerColumn(defaultLayer, columnName, newSecondaryColumns); - if (trendLineLayer) { - addLayerColumn(trendLineLayer, `${columnName}_trendline`, newSecondaryColumns, false, 'X0'); - } } if (primaryMetric.background_chart?.type === 'bar') { @@ -549,9 +527,6 @@ function buildFormBasedLayer(layer: MetricStateNoESQL): FormBasedPersistedState[ const newColumn = fromMetricAPItoLensState(primaryMetric.background_chart.max_value); addLayerColumn(defaultLayer, columnName, newColumn); - if (trendLineLayer) { - addLayerColumn(trendLineLayer, `${columnName}_trendline`, newColumn, false, 'X0'); - } } return layers; diff --git a/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 26c6bc31ac738..0a43066755c8e 100644 --- a/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -36,6 +36,7 @@ import type { LensDocument, } from '@kbn/lens-common'; import { COLOR_MAPPING_OFF_BY_DEFAULT } from '../../../common/constants'; +import { hydrateState } from '../../runtime_state'; import { buildExpression } from './expression_helpers'; import { getActiveDatasourceIdFromDoc, sortDataViewRefs } from '../../utils'; @@ -279,15 +280,22 @@ export async function initializeSources( references, }); + const hydratedDatasourceStates = hydrateState( + visualizationState.activeId, + visualizationState.state, + initializedDatasourceStates, + indexPatterns + ); + return { indexPatterns, indexPatternRefs, annotationGroups, - datasourceStates: initializedDatasourceStates, + datasourceStates: hydratedDatasourceStates, visualizationState: initializeVisualization({ visualizationMap, visualizationState, - datasourceStates, + datasourceStates: hydratedDatasourceStates, references, initialContext, annotationGroups, @@ -418,7 +426,7 @@ export async function persistedStateToExpression( }, { isFullEditor: false } ); - const datasourceStates = initializeDatasources({ + const initializedDatasourceStates = initializeDatasources({ datasourceMap, datasourceStates: datasourceStatesFromSO, references: [...references, ...(internalReferences || [])], @@ -426,6 +434,13 @@ export async function persistedStateToExpression( indexPatternRefs, }); + const datasourceStates = hydrateState( + visualizationType, + persistedVisualizationState, + initializedDatasourceStates, + indexPatterns + ); + const activeVisualizationState = initializeVisualization({ visualizationMap: visualizations, visualizationState: { diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/metric_trendline/converter.test.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/metric_trendline/converter.test.ts new file mode 100644 index 0000000000000..832c86c8c6763 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/metric_trendline/converter.test.ts @@ -0,0 +1,355 @@ +/* + * 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 { + CountIndexPatternColumn, + DatasourceStates, + FormBasedPrivateState, + GenericIndexPatternColumn, + IndexPatternMap, + MetricVisualizationState, + TermsIndexPatternColumn, +} from '@kbn/lens-common'; +import { hydrateMetricTrendlineLayer } from './converter'; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const INDEX_PATTERN_ID = 'my-index-pattern'; +const MAIN_LAYER_ID = 'layer_0'; +const TRENDLINE_LAYER_ID = 'layer_0_trendline'; +const TIME_FIELD = '@timestamp'; + +const countColumn: CountIndexPatternColumn = { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + sourceField: '___records___', + params: { emptyAsNull: true }, +}; + +const avgColumn = { + label: 'Average of bytes', + dataType: 'number' as const, + operationType: 'average' as const, + isBucketed: false, + sourceField: 'bytes', + params: { emptyAsNull: true }, +}; + +const termsColumn: TermsIndexPatternColumn = { + label: 'Top 5 values of extension', + dataType: 'string', + operationType: 'terms', + isBucketed: true, + sourceField: 'extension.keyword', + params: { + size: 5, + orderBy: { type: 'column', columnId: 'metric_accessor' }, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + parentFormat: { id: 'terms' }, + include: [], + exclude: [], + includeIsRegex: false, + excludeIsRegex: false, + }, +}; + +/** Builds the minimal FormBasedPrivateState needed for tests */ +function buildFormBasedState( + extraColumns: Record = {}, + extraColumnOrder: string[] = [] +): FormBasedPrivateState { + return { + currentIndexPatternId: INDEX_PATTERN_ID, + layers: { + [MAIN_LAYER_ID]: { + indexPatternId: INDEX_PATTERN_ID, + columns: { + metric_accessor: countColumn, + ...extraColumns, + } as FormBasedPrivateState['layers'][string]['columns'], + columnOrder: ['metric_accessor', ...extraColumnOrder], + sampling: 1, + ignoreGlobalFilters: false, + }, + }, + }; +} + +/** Wraps a FormBasedPrivateState in the DatasourceStates envelope */ +function wrapDatasourceStates(state: FormBasedPrivateState): DatasourceStates { + return { formBased: { isLoading: false, state } }; +} + +const indexPatterns: IndexPatternMap = { + [INDEX_PATTERN_ID]: { + id: INDEX_PATTERN_ID, + title: 'my-index-*', + timeFieldName: TIME_FIELD, + fields: [], + getFieldByName: () => undefined, + getFormatterForField: () => ({ convert: (v: unknown) => v }), + hasRestrictions: false, + isPersisted: true, + spec: {}, + }, +}; + +const indexPatternsNoTime: IndexPatternMap = { + [INDEX_PATTERN_ID]: { + ...indexPatterns[INDEX_PATTERN_ID], + timeFieldName: undefined, + }, +}; + +/** Minimal MetricVisualizationState with trendline fields */ +function buildVizState( + overrides: Partial = {} +): MetricVisualizationState { + return { + layerId: MAIN_LAYER_ID, + layerType: 'data', + metricAccessor: 'metric_accessor', + trendlineLayerId: TRENDLINE_LAYER_ID, + trendlineLayerType: 'metricTrendline', + trendlineMetricAccessor: 'metric_accessor_trendline', + trendlineTimeAccessor: 'x_date_histogram', + ...overrides, + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('hydrateMetricTrendlineLayer', () => { + describe('no-op guards', () => { + it('returns datasourceStates unchanged when visualizationType is not lnsMetric', () => { + const states = wrapDatasourceStates(buildFormBasedState()); + const result = hydrateMetricTrendlineLayer('lnsXY', buildVizState(), states, indexPatterns); + expect(result).toBe(states); + }); + + it('returns datasourceStates unchanged when visualizationType is null', () => { + const states = wrapDatasourceStates(buildFormBasedState()); + const result = hydrateMetricTrendlineLayer(null, buildVizState(), states, indexPatterns); + expect(result).toBe(states); + }); + + it('returns datasourceStates unchanged when trendlineLayerId is not set', () => { + const states = wrapDatasourceStates(buildFormBasedState()); + const vizState = buildVizState({ trendlineLayerId: undefined }); + const result = hydrateMetricTrendlineLayer('lnsMetric', vizState, states, indexPatterns); + expect(result).toBe(states); + }); + + it('returns datasourceStates unchanged when formBased datasource is missing', () => { + const states: DatasourceStates = { textBased: { isLoading: false, state: {} } }; + const result = hydrateMetricTrendlineLayer( + 'lnsMetric', + buildVizState(), + states, + indexPatterns + ); + expect(result).toBe(states); + }); + + it('returns datasourceStates unchanged when main layer is missing', () => { + const stateWithNoMainLayer: FormBasedPrivateState = { + currentIndexPatternId: INDEX_PATTERN_ID, + layers: {}, + }; + const states = wrapDatasourceStates(stateWithNoMainLayer); + const result = hydrateMetricTrendlineLayer( + 'lnsMetric', + buildVizState(), + states, + indexPatterns + ); + expect(result).toBe(states); + }); + + it('returns datasourceStates unchanged when indexPattern has no timeFieldName', () => { + const states = wrapDatasourceStates(buildFormBasedState()); + const result = hydrateMetricTrendlineLayer( + 'lnsMetric', + buildVizState(), + states, + indexPatternsNoTime + ); + expect(result).toBe(states); + }); + + it('is idempotent: returns datasourceStates unchanged when trendline layer already exists', () => { + const formBasedState = buildFormBasedState(); + formBasedState.layers[TRENDLINE_LAYER_ID] = { + indexPatternId: INDEX_PATTERN_ID, + linkToLayers: [MAIN_LAYER_ID], + columns: { x_date_histogram: {} as GenericIndexPatternColumn }, + columnOrder: ['x_date_histogram'], + sampling: 1, + ignoreGlobalFilters: false, + }; + const states = wrapDatasourceStates(formBasedState); + const result = hydrateMetricTrendlineLayer( + 'lnsMetric', + buildVizState(), + states, + indexPatterns + ); + expect(result).toBe(states); + }); + }); + + describe('trendline layer creation — primary metric only', () => { + let result: DatasourceStates; + + beforeEach(() => { + const states = wrapDatasourceStates(buildFormBasedState()); + result = hydrateMetricTrendlineLayer('lnsMetric', buildVizState(), states, indexPatterns); + }); + + it('adds the trendline layer to the formBased datasource state', () => { + const layers = (result.formBased!.state as FormBasedPrivateState).layers; + expect(layers[TRENDLINE_LAYER_ID]).toBeDefined(); + }); + + it('preserves the main layer unchanged', () => { + const layers = (result.formBased!.state as FormBasedPrivateState).layers; + expect(layers[MAIN_LAYER_ID]).toBeDefined(); + expect(Object.keys(layers[MAIN_LAYER_ID].columns)).toEqual(['metric_accessor']); + }); + + it('sets linkToLayers pointing to the main layer', () => { + const layer = (result.formBased!.state as FormBasedPrivateState).layers[TRENDLINE_LAYER_ID]; + expect(layer.linkToLayers).toEqual([MAIN_LAYER_ID]); + }); + + it('sets indexPatternId matching the main layer', () => { + const layer = (result.formBased!.state as FormBasedPrivateState).layers[TRENDLINE_LAYER_ID]; + expect(layer.indexPatternId).toBe(INDEX_PATTERN_ID); + }); + + it('creates a date_histogram column at trendlineTimeAccessor using the DataView time field', () => { + const layer = (result.formBased!.state as FormBasedPrivateState).layers[TRENDLINE_LAYER_ID]; + const timeCol = layer.columns.x_date_histogram; + expect(timeCol).toMatchObject({ + operationType: 'date_histogram', + dataType: 'date', + isBucketed: true, + sourceField: TIME_FIELD, + params: { interval: 'auto' }, + }); + }); + + it('copies the primary metric column at trendlineMetricAccessor', () => { + const layer = (result.formBased!.state as FormBasedPrivateState).layers[TRENDLINE_LAYER_ID]; + expect(layer.columns.metric_accessor_trendline).toEqual(countColumn); + }); + + it('puts time column before metric column in columnOrder', () => { + const layer = (result.formBased!.state as FormBasedPrivateState).layers[TRENDLINE_LAYER_ID]; + expect(layer.columnOrder).toEqual(['x_date_histogram', 'metric_accessor_trendline']); + }); + + it('does not mutate the original datasourceStates', () => { + const original = wrapDatasourceStates(buildFormBasedState()); + hydrateMetricTrendlineLayer('lnsMetric', buildVizState(), original, indexPatterns); + const layers = (original.formBased!.state as FormBasedPrivateState).layers; + expect(layers[TRENDLINE_LAYER_ID]).toBeUndefined(); + }); + }); + + describe('trendline layer creation — with secondary metric', () => { + it('copies the secondary metric column at trendlineSecondaryMetricAccessor', () => { + const states = wrapDatasourceStates( + buildFormBasedState({ secondary_accessor: avgColumn }, ['secondary_accessor']) + ); + const vizState = buildVizState({ + secondaryMetricAccessor: 'secondary_accessor', + trendlineSecondaryMetricAccessor: 'secondary_accessor_trendline', + }); + const result = hydrateMetricTrendlineLayer('lnsMetric', vizState, states, indexPatterns); + const layer = (result.formBased!.state as FormBasedPrivateState).layers[TRENDLINE_LAYER_ID]; + + expect(layer.columns.secondary_accessor_trendline).toEqual(avgColumn); + expect(layer.columnOrder).toContain('secondary_accessor_trendline'); + }); + + it('does not include secondary column in trendline when secondaryMetricAccessor is not in main layer', () => { + const states = wrapDatasourceStates(buildFormBasedState()); + const vizState = buildVizState({ + secondaryMetricAccessor: 'missing_secondary', + trendlineSecondaryMetricAccessor: 'secondary_accessor_trendline', + }); + const result = hydrateMetricTrendlineLayer('lnsMetric', vizState, states, indexPatterns); + const layer = (result.formBased!.state as FormBasedPrivateState).layers[TRENDLINE_LAYER_ID]; + + expect(layer.columns.secondary_accessor_trendline).toBeUndefined(); + }); + }); + + describe('trendline layer creation — with breakdown', () => { + it('copies the breakdown column at trendlineBreakdownByAccessor after the time column', () => { + const states = wrapDatasourceStates( + buildFormBasedState({ breakdown_accessor: termsColumn }, ['breakdown_accessor']) + ); + const vizState = buildVizState({ + breakdownByAccessor: 'breakdown_accessor', + trendlineBreakdownByAccessor: 'breakdown_accessor_trendline', + }); + const result = hydrateMetricTrendlineLayer('lnsMetric', vizState, states, indexPatterns); + const layer = (result.formBased!.state as FormBasedPrivateState).layers[TRENDLINE_LAYER_ID]; + + expect(layer.columns.breakdown_accessor_trendline).toEqual(termsColumn); + // breakdown bucket should appear after the time column, before metrics + const order = layer.columnOrder; + expect(order.indexOf('x_date_histogram')).toBeLessThan( + order.indexOf('breakdown_accessor_trendline') + ); + expect(order.indexOf('breakdown_accessor_trendline')).toBeLessThan( + order.indexOf('metric_accessor_trendline') + ); + }); + }); + + describe('trendline layer creation — all dimensions present', () => { + it('produces a layer with time, breakdown, primary metric, and secondary metric columns', () => { + const states = wrapDatasourceStates( + buildFormBasedState({ secondary_accessor: avgColumn, breakdown_accessor: termsColumn }, [ + 'breakdown_accessor', + 'secondary_accessor', + ]) + ); + const vizState = buildVizState({ + secondaryMetricAccessor: 'secondary_accessor', + trendlineSecondaryMetricAccessor: 'secondary_accessor_trendline', + breakdownByAccessor: 'breakdown_accessor', + trendlineBreakdownByAccessor: 'breakdown_accessor_trendline', + }); + const result = hydrateMetricTrendlineLayer('lnsMetric', vizState, states, indexPatterns); + const layer = (result.formBased!.state as FormBasedPrivateState).layers[TRENDLINE_LAYER_ID]; + + expect(Object.keys(layer.columns)).toEqual( + expect.arrayContaining([ + 'x_date_histogram', + 'breakdown_accessor_trendline', + 'metric_accessor_trendline', + 'secondary_accessor_trendline', + ]) + ); + // column order: time → breakdown → primary metric → secondary metric + expect(layer.columnOrder).toEqual([ + 'x_date_histogram', + 'breakdown_accessor_trendline', + 'metric_accessor_trendline', + 'secondary_accessor_trendline', + ]); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/metric_trendline/converter.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/metric_trendline/converter.ts new file mode 100644 index 0000000000000..842e19819d7f6 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/metric_trendline/converter.ts @@ -0,0 +1,146 @@ +/* + * 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 { + LENS_METRIC_ID, + type DatasourceStates, + type FormBasedLayer, + type FormBasedPrivateState, + type GenericIndexPatternColumn, + type IndexPatternMap, + type MetricVisualizationState, +} from '@kbn/lens-common'; + +const isMetricVisualizationState = (state: unknown): state is MetricVisualizationState => + typeof state === 'object' && state !== null && 'layerId' in state && 'layerType' in state; + +const isFormBasedPrivateState = (state: unknown): state is FormBasedPrivateState => + typeof state === 'object' && + state !== null && + 'currentIndexPatternId' in state && + 'layers' in state; + +/** + * Hydrates the metric trendline datasource layer at runtime when it is + * referenced in the visualization state but absent from the datasource layers. + * + * This happens when a Metric chart with `background_chart.type === 'trend'` is + * built via the API schema transform: the static transform intentionally omits + * the trendline layer so that the correct `date_histogram` column (keyed to the + * DataView's actual time field) can only be produced once the real IndexPattern + * is available. + * + * This function is called after `initializeDatasources()` in both initialization + * paths (editor via `initializeSources` and embeddable via + * `persistedStateToExpression`) via the `hydrateState` helper. + * + * The function is idempotent: if the trendline layer already exists in the + * datasource state (e.g. charts saved from the Lens editor) it returns the + * original state unchanged. + */ +export function hydrateMetricTrendlineLayer( + visualizationType: string | null | undefined, + visualizationState: unknown, + datasourceStates: DatasourceStates, + indexPatterns: IndexPatternMap +): DatasourceStates { + if (visualizationType !== LENS_METRIC_ID) return datasourceStates; + + if (!isMetricVisualizationState(visualizationState)) return datasourceStates; + const vizState = visualizationState; + + if (!vizState.trendlineLayerId) return datasourceStates; + + const formBasedDatasource = datasourceStates.formBased; + if (!formBasedDatasource) return datasourceStates; + + if (!isFormBasedPrivateState(formBasedDatasource.state)) return datasourceStates; + const formBasedState = formBasedDatasource.state; + + // Idempotent: layer already present + if (formBasedState.layers[vizState.trendlineLayerId]) return datasourceStates; + + const mainLayer = formBasedState.layers[vizState.layerId]; + if (!mainLayer) return datasourceStates; + + const indexPattern = indexPatterns[mainLayer.indexPatternId]; + if (!indexPattern?.timeFieldName) return datasourceStates; + + const trendlineColumns: FormBasedLayer['columns'] = {}; + const columnOrder: string[] = []; + + // Time accessor — date_histogram using the DataView's actual time field + const { trendlineTimeAccessor } = vizState; + if (trendlineTimeAccessor) { + trendlineColumns[trendlineTimeAccessor] = { + label: indexPattern.timeFieldName, + dataType: 'date', + operationType: 'date_histogram', + sourceField: indexPattern.timeFieldName, + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + } as GenericIndexPatternColumn; + columnOrder.push(trendlineTimeAccessor); + } + + // Breakdown accessor (bucket) — insert after time accessor + const { trendlineBreakdownByAccessor, breakdownByAccessor } = vizState; + if ( + trendlineBreakdownByAccessor && + breakdownByAccessor && + mainLayer.columns[breakdownByAccessor] + ) { + trendlineColumns[trendlineBreakdownByAccessor] = { + ...mainLayer.columns[breakdownByAccessor], + }; + columnOrder.push(trendlineBreakdownByAccessor); + } + + // Primary metric — copy from main layer + const { trendlineMetricAccessor, metricAccessor } = vizState; + if (trendlineMetricAccessor && metricAccessor && mainLayer.columns[metricAccessor]) { + trendlineColumns[trendlineMetricAccessor] = { ...mainLayer.columns[metricAccessor] }; + columnOrder.push(trendlineMetricAccessor); + } + + // Secondary metric — copy from main layer if present + const { trendlineSecondaryMetricAccessor, secondaryMetricAccessor } = vizState; + if ( + trendlineSecondaryMetricAccessor && + secondaryMetricAccessor && + mainLayer.columns[secondaryMetricAccessor] + ) { + trendlineColumns[trendlineSecondaryMetricAccessor] = { + ...mainLayer.columns[secondaryMetricAccessor], + }; + columnOrder.push(trendlineSecondaryMetricAccessor); + } + + const trendlineLayer: FormBasedLayer = { + indexPatternId: mainLayer.indexPatternId, + linkToLayers: [vizState.layerId], + columns: trendlineColumns, + columnOrder, + sampling: 1, + ignoreGlobalFilters: false, + }; + + return { + ...datasourceStates, + formBased: { + ...formBasedDatasource, + state: { + ...formBasedState, + layers: { + ...formBasedState.layers, + [vizState.trendlineLayerId]: trendlineLayer, + }, + }, + }, + }; +} diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/metric_trendline/index.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/metric_trendline/index.ts new file mode 100644 index 0000000000000..968be96c608e4 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/metric_trendline/index.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 { hydrateMetricTrendlineLayer } from './converter'; diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/index.test.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/index.test.ts new file mode 100644 index 0000000000000..6be49d24f7a74 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/index.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { + DatasourceStates, + FormBasedPrivateState, + IndexPatternMap, + MetricVisualizationState, +} from '@kbn/lens-common'; +import { hydrateState } from '.'; + +const INDEX_PATTERN_ID = 'my-index-pattern'; +const MAIN_LAYER_ID = 'layer_0'; +const TRENDLINE_LAYER_ID = 'layer_0_trendline'; + +const indexPatterns: IndexPatternMap = { + [INDEX_PATTERN_ID]: { + id: INDEX_PATTERN_ID, + title: 'my-index-*', + timeFieldName: '@timestamp', + fields: [], + getFieldByName: () => undefined, + getIndexPattern: () => 'my-index-*', + hasRestrictions: false, + isPersisted: true, + spec: {}, + }, +}; + +const metricDatasourceStates: DatasourceStates = { + formBased: { + isLoading: false, + state: { + currentIndexPatternId: INDEX_PATTERN_ID, + layers: { + [MAIN_LAYER_ID]: { + indexPatternId: INDEX_PATTERN_ID, + columns: { + metric_accessor: { + label: 'Count', + dataType: 'number', + operationType: 'count', + isBucketed: false, + sourceField: '___records___', + params: { emptyAsNull: true }, + }, + }, + columnOrder: ['metric_accessor'], + sampling: 1, + ignoreGlobalFilters: false, + }, + }, + } as FormBasedPrivateState, + }, +}; + +const metricVizState: MetricVisualizationState = { + layerId: MAIN_LAYER_ID, + layerType: 'data', + metricAccessor: 'metric_accessor', + trendlineLayerId: TRENDLINE_LAYER_ID, + trendlineLayerType: 'metricTrendline', + trendlineMetricAccessor: 'metric_accessor_trendline', + trendlineTimeAccessor: 'x_date_histogram', +}; + +describe('hydrateState', () => { + it('runs all registered hydrators, producing a trendline layer for lnsMetric', () => { + const result = hydrateState('lnsMetric', metricVizState, metricDatasourceStates, indexPatterns); + const layers = (result.formBased!.state as FormBasedPrivateState).layers; + expect(layers[TRENDLINE_LAYER_ID]).toBeDefined(); + expect(layers[TRENDLINE_LAYER_ID].columns.x_date_histogram).toMatchObject({ + operationType: 'date_histogram', + sourceField: '@timestamp', + }); + }); + + it('is a no-op for non-metric visualization types', () => { + const result = hydrateState('lnsXY', metricVizState, metricDatasourceStates, indexPatterns); + expect(result).toBe(metricDatasourceStates); + }); + + it('passes through empty datasource states without throwing', () => { + const empty: DatasourceStates = {}; + const result = hydrateState('lnsMetric', metricVizState, empty, indexPatterns); + expect(result).toBe(empty); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/index.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/index.ts index 58472aed8d903..ffcd018eb725a 100644 --- a/x-pack/platform/plugins/shared/lens/public/runtime_state/index.ts +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/index.ts @@ -5,4 +5,44 @@ * 2.0. */ +import type { DatasourceStates, IndexPatternMap } from '@kbn/lens-common'; +import { hydrateMetricTrendlineLayer } from './converters/metric_trendline'; + export * from './converters/raw_color_mappings'; + +/** + * A hydration function that fills in datasource state that cannot be computed + * during the static API→LensState transform because it requires runtime + * information (e.g. the DataView's actual time field). + * + * Each hydrator must be idempotent: if the state is already complete it should + * return the original `datasourceStates` reference unchanged. + */ +export type StateHydrator = ( + visualizationType: string | null | undefined, + visualizationState: unknown, + datasourceStates: DatasourceStates, + indexPatterns: IndexPatternMap +) => DatasourceStates; + +/** + * Registry of all state hydrators. Add new ones here — `hydrateState` will + * run them in order without requiring any changes to call sites. + */ +const hydrators: StateHydrator[] = [hydrateMetricTrendlineLayer]; + +/** + * Runs all registered hydrators in sequence, each receiving the output of the + * previous one. Call this after `initializeDatasources()` in both the editor + * (`initializeSources`) and embeddable (`persistedStateToExpression`) paths. + */ +export const hydrateState = ( + visualizationType: string | null | undefined, + visualizationState: unknown, + datasourceStates: DatasourceStates, + indexPatterns: IndexPatternMap +): DatasourceStates => + hydrators.reduce( + (states, hydrate) => hydrate(visualizationType, visualizationState, states, indexPatterns), + datasourceStates + );