diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 31fecbc283765..597fbc6cd6944 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -481,7 +481,7 @@ src/plugins/kibana_usage_collection @elastic/kibana-core src/plugins/kibana_utils @elastic/appex-sharedux x-pack/plugins/kubernetes_security @elastic/kibana-cloud-security-posture packages/kbn-language-documentation-popover @elastic/kibana-visualizations -packages/kbn-lens-embeddable-utils @elastic/obs-ux-infra_services-team +packages/kbn-lens-embeddable-utils @elastic/obs-ux-infra_services-team @elastic/kibana-visualizations x-pack/plugins/lens @elastic/kibana-visualizations x-pack/plugins/license_api_guard @elastic/platform-deployment-management x-pack/plugins/license_management @elastic/platform-deployment-management diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/gauge.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/gauge.test.ts new file mode 100644 index 0000000000000..5e0e5dc1de2a7 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/gauge.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView, DataViewField, DataViewsContract } from '@kbn/data-views-plugin/common'; +import { buildGauge } from './gauge'; + +const dataViews: Record = { + test: { + id: 'test', + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return { + type: 'datetime', + } as unknown as DataViewField; + case 'category': + return { + type: 'string', + } as unknown as DataViewField; + case 'price': + return { + type: 'number', + } as unknown as DataViewField; + default: + return undefined; + } + }, + } as any, + } as unknown as DataView, +}; + +function mockDataViewsService() { + return { + get: jest.fn(async (id: '1' | '2') => { + const result = { + ...dataViews[id], + metaFields: [], + isPersisted: () => true, + toSpec: () => ({}), + }; + return result; + }), + create: jest.fn(), + } as unknown as Pick; +} + +test('generates gauge chart config', async () => { + const result = await buildGauge( + { + chartType: 'gauge', + title: 'test', + dataset: { + esql: 'from test | count=count()', + }, + value: 'count', + }, + { + dataViewsAPI: mockDataViewsService() as any, + formulaAPI: {} as any, + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "test", + "name": "indexpattern-datasource-layer-layer_0", + "type": "index-pattern", + }, + ], + "state": Object { + "adHocDataViews": Object { + "test": Object {}, + }, + "datasourceStates": Object { + "formBased": Object { + "layers": Object {}, + }, + "textBased": Object { + "layers": Object { + "layer_0": Object { + "allColumns": Array [ + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "columns": Array [ + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "index": "test", + "query": Object { + "esql": "from test | count=count()", + }, + }, + }, + }, + }, + "filters": Array [], + "internalReferences": Array [], + "query": Object { + "language": "kuery", + "query": "", + }, + "visualization": Object { + "labelMajorMode": "auto", + "layerId": "layer_0", + "layerType": "data", + "metricAccessor": "metric_formula_accessor", + "shape": "horizontalBullet", + "ticksPosition": "auto", + }, + }, + "title": "test", + "visualizationType": "lnsGauge", + } + `); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/gauge.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/gauge.ts new file mode 100644 index 0000000000000..def80346f6f5d --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/gauge.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + FormBasedPersistedState, + FormulaPublicApi, + GaugeVisualizationState, +} from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { BuildDependencies, DEFAULT_LAYER_ID, LensAttributes, LensGaugeConfig } from '../types'; +import { + addLayerFormulaColumns, + buildDatasourceStates, + buildReferences, + getAdhocDataviews, +} from '../utils'; +import { getFormulaColumn, getValueColumn } from '../columns'; + +const ACCESSOR = 'metric_formula_accessor'; + +function getAccessorName(type: 'goal' | 'max' | 'min' | 'secondary') { + return `${ACCESSOR}_${type}`; +} + +function buildVisualizationState(config: LensGaugeConfig): GaugeVisualizationState { + const layer = config; + + return { + layerId: DEFAULT_LAYER_ID, + layerType: 'data', + ticksPosition: 'auto', + shape: layer.shape || 'horizontalBullet', + labelMajorMode: 'auto', + metricAccessor: ACCESSOR, + ...(layer.queryGoalValue + ? { + goalAccessor: getAccessorName('goal'), + } + : {}), + + ...(layer.queryMaxValue + ? { + maxAccessor: getAccessorName('max'), + showBar: true, + } + : {}), + + ...(layer.queryMinValue + ? { + minAccessor: getAccessorName('min'), + } + : {}), + }; +} + +function buildFormulaLayer( + layer: LensGaugeConfig, + i: number, + dataView: DataView, + formulaAPI: FormulaPublicApi +): FormBasedPersistedState['layers'][0] { + const layers = { + [DEFAULT_LAYER_ID]: { + ...getFormulaColumn( + ACCESSOR, + { + value: layer.value, + }, + dataView, + formulaAPI + ), + }, + }; + + const defaultLayer = layers[DEFAULT_LAYER_ID]; + + if (layer.queryGoalValue) { + const columnName = getAccessorName('goal'); + const formulaColumn = getFormulaColumn( + columnName, + { + value: layer.queryGoalValue, + }, + dataView, + formulaAPI + ); + + addLayerFormulaColumns(defaultLayer, formulaColumn); + } + + if (layer.queryMinValue) { + const columnName = getAccessorName('min'); + const formulaColumn = getFormulaColumn( + columnName, + { + value: layer.queryMinValue, + }, + dataView, + formulaAPI + ); + + addLayerFormulaColumns(defaultLayer, formulaColumn); + } + + if (layer.queryMaxValue) { + const columnName = getAccessorName('max'); + const formulaColumn = getFormulaColumn( + columnName, + { + value: layer.queryMaxValue, + }, + dataView, + formulaAPI + ); + + addLayerFormulaColumns(defaultLayer, formulaColumn); + } + + return defaultLayer; +} + +function getValueColumns(layer: LensGaugeConfig) { + return [ + getValueColumn(ACCESSOR, layer.value), + ...(layer.queryMaxValue ? [getValueColumn(getAccessorName('max'), layer.queryMaxValue)] : []), + ...(layer.queryMinValue + ? [getValueColumn(getAccessorName('secondary'), layer.queryMinValue)] + : []), + ...(layer.queryGoalValue + ? [getValueColumn(getAccessorName('secondary'), layer.queryGoalValue)] + : []), + ]; +} + +export async function buildGauge( + config: LensGaugeConfig, + { dataViewsAPI, formulaAPI }: BuildDependencies +): Promise { + const dataviews: Record = {}; + const _buildFormulaLayer = (cfg: unknown, i: number, dataView: DataView) => + buildFormulaLayer(cfg as LensGaugeConfig, i, dataView, formulaAPI); + const datasourceStates = await buildDatasourceStates( + config, + dataviews, + _buildFormulaLayer, + getValueColumns, + dataViewsAPI + ); + return { + title: config.title, + visualizationType: 'lnsGauge', + references: buildReferences(dataviews), + state: { + datasourceStates, + internalReferences: [], + filters: [], + query: { language: 'kuery', query: '' }, + visualization: buildVisualizationState(config), + // Getting the spec from a data view is a heavy operation, that's why the result is cached. + adHocDataViews: getAdhocDataviews(dataviews), + }, + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/heatmap.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/heatmap.test.ts new file mode 100644 index 0000000000000..cfcabb131b451 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/heatmap.test.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView, DataViewField, DataViewsContract } from '@kbn/data-views-plugin/common'; +import { buildHeatmap } from './heatmap'; + +const dataViews: Record = { + test: { + id: 'test', + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return { + type: 'datetime', + } as unknown as DataViewField; + case 'category': + return { + type: 'string', + } as unknown as DataViewField; + case 'price': + return { + type: 'number', + } as unknown as DataViewField; + default: + return undefined; + } + }, + } as any, + } as unknown as DataView, +}; + +function mockDataViewsService() { + return { + get: jest.fn(async (id: '1' | '2') => { + const result = { + ...dataViews[id], + metaFields: [], + isPersisted: () => true, + toSpec: () => ({}), + }; + return result; + }), + create: jest.fn(), + } as unknown as Pick; +} + +test('generates metric chart config', async () => { + const result = await buildHeatmap( + { + chartType: 'heatmap', + title: 'test', + dataset: { + esql: 'from test | count=count() by @timestamp, category', + }, + breakdown: 'category', + xAxis: '@timestamp', + value: 'count', + }, + { + dataViewsAPI: mockDataViewsService() as any, + formulaAPI: {} as any, + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "test", + "name": "indexpattern-datasource-layer-layer_0", + "type": "index-pattern", + }, + ], + "state": Object { + "adHocDataViews": Object { + "test": Object {}, + }, + "datasourceStates": Object { + "formBased": Object { + "layers": Object {}, + }, + "textBased": Object { + "layers": Object { + "layer_0": Object { + "allColumns": Array [ + Object { + "columnId": "metric_formula_accessor_y", + "fieldName": "category", + }, + Object { + "columnId": "metric_formula_accessor_x", + "fieldName": "@timestamp", + }, + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "columns": Array [ + Object { + "columnId": "metric_formula_accessor_y", + "fieldName": "category", + }, + Object { + "columnId": "metric_formula_accessor_x", + "fieldName": "@timestamp", + }, + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "index": "test", + "query": Object { + "esql": "from test | count=count() by @timestamp, category", + }, + }, + }, + }, + }, + "filters": Array [], + "internalReferences": Array [], + "query": Object { + "language": "kuery", + "query": "", + }, + "visualization": Object { + "gridConfig": Object { + "isCellLabelVisible": false, + "isXAxisLabelVisible": false, + "isXAxisTitleVisible": false, + "isYAxisLabelVisible": false, + "isYAxisTitleVisible": false, + "type": "heatmap_grid", + }, + "layerId": "layer_0", + "layerType": "data", + "legend": Object { + "isVisible": true, + "position": "left", + "type": "heatmap_legend", + }, + "shape": "heatmap", + "valueAccessor": "metric_formula_accessor", + "xAccessor": "metric_formula_accessor_x", + "yAccessor": "metric_formula_accessor_y", + }, + }, + "title": "test", + "visualizationType": "lnsHeatmap", + } + `); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/heatmap.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/heatmap.ts new file mode 100644 index 0000000000000..b06fecd6c37f6 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/heatmap.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + FormBasedPersistedState, + FormulaPublicApi, + HeatmapVisualizationState, +} from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { BuildDependencies, DEFAULT_LAYER_ID, LensAttributes, LensHeatmapConfig } from '../types'; +import { + addLayerColumn, + buildDatasourceStates, + buildReferences, + getAdhocDataviews, +} from '../utils'; +import { getBreakdownColumn, getFormulaColumn, getValueColumn } from '../columns'; + +const ACCESSOR = 'metric_formula_accessor'; + +function getAccessorName(type: 'x' | 'y') { + return `${ACCESSOR}_${type}`; +} + +function buildVisualizationState(config: LensHeatmapConfig): HeatmapVisualizationState { + const layer = config; + + return { + layerId: DEFAULT_LAYER_ID, + layerType: 'data', + shape: 'heatmap', + valueAccessor: ACCESSOR, + ...(layer.xAxis + ? { + xAccessor: getAccessorName('x'), + } + : {}), + + ...(layer.breakdown + ? { + yAccessor: getAccessorName('y'), + } + : {}), + gridConfig: { + type: 'heatmap_grid', + isCellLabelVisible: false, + isXAxisLabelVisible: false, + isXAxisTitleVisible: false, + isYAxisLabelVisible: false, + isYAxisTitleVisible: false, + }, + legend: { + isVisible: config.legend?.show || true, + position: config.legend?.position || 'left', + type: 'heatmap_legend', + }, + }; +} + +function buildFormulaLayer( + layer: LensHeatmapConfig, + i: number, + dataView: DataView, + formulaAPI: FormulaPublicApi +): FormBasedPersistedState['layers'][0] { + const defaultLayer = { + ...getFormulaColumn( + ACCESSOR, + { + value: layer.value, + }, + dataView, + formulaAPI + ), + }; + + if (layer.xAxis) { + const columnName = getAccessorName('x'); + const breakdownColumn = getBreakdownColumn({ + options: layer.xAxis, + dataView, + }); + addLayerColumn(defaultLayer, columnName, breakdownColumn, true); + } + + if (layer.breakdown) { + const columnName = getAccessorName('y'); + const breakdownColumn = getBreakdownColumn({ + options: layer.breakdown, + dataView, + }); + addLayerColumn(defaultLayer, columnName, breakdownColumn, true); + } + + return defaultLayer; +} + +function getValueColumns(layer: LensHeatmapConfig) { + if (layer.breakdown && typeof layer.breakdown !== 'string') { + throw new Error('breakdown must be a field name when not using index source'); + } + if (typeof layer.xAxis !== 'string') { + throw new Error('xAxis must be a field name when not using index source'); + } + return [ + ...(layer.breakdown ? [getValueColumn(getAccessorName('y'), layer.breakdown as string)] : []), + getValueColumn(getAccessorName('x'), layer.xAxis as string), + getValueColumn(ACCESSOR, layer.value), + ]; +} + +export async function buildHeatmap( + config: LensHeatmapConfig, + { dataViewsAPI, formulaAPI }: BuildDependencies +): Promise { + const dataviews: Record = {}; + const _buildFormulaLayer = (cfg: unknown, i: number, dataView: DataView) => + buildFormulaLayer(cfg as LensHeatmapConfig, i, dataView, formulaAPI); + const datasourceStates = await buildDatasourceStates( + config, + dataviews, + _buildFormulaLayer, + getValueColumns, + dataViewsAPI + ); + + return { + title: config.title, + visualizationType: 'lnsHeatmap', + references: buildReferences(dataviews), + state: { + datasourceStates, + internalReferences: [], + filters: [], + query: { language: 'kuery', query: '' }, + visualization: buildVisualizationState(config), + // Getting the spec from a data view is a heavy operation, that's why the result is cached. + adHocDataViews: getAdhocDataviews(dataviews), + }, + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/index.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/index.ts new file mode 100644 index 0000000000000..95f8e3086fd67 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './tag_cloud'; +export * from './metric'; +export * from './partition'; +export * from './gauge'; +export * from './heatmap'; +export * from './region_map'; +export * from './table'; +export * from './xy'; diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/metric.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/metric.test.ts new file mode 100644 index 0000000000000..cf82d3a2f5f4d --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/metric.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView, DataViewField, DataViewsContract } from '@kbn/data-views-plugin/common'; +import { buildMetric } from './metric'; + +const dataViews: Record = { + test: { + id: 'test', + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return { + type: 'datetime', + } as unknown as DataViewField; + case 'category': + return { + type: 'string', + } as unknown as DataViewField; + case 'price': + return { + type: 'number', + } as unknown as DataViewField; + default: + return undefined; + } + }, + } as any, + } as unknown as DataView, +}; + +function mockDataViewsService() { + return { + get: jest.fn(async (id: '1' | '2') => { + const result = { + ...dataViews[id], + metaFields: [], + isPersisted: () => true, + toSpec: () => ({}), + }; + return result; + }), + create: jest.fn(), + } as unknown as Pick; +} + +test('generates metric chart config', async () => { + const result = await buildMetric( + { + chartType: 'metric', + title: 'test', + dataset: { + esql: 'from test | count=count()', + }, + value: 'count', + }, + { + dataViewsAPI: mockDataViewsService() as any, + formulaAPI: {} as any, + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "test", + "name": "indexpattern-datasource-layer-layer_0", + "type": "index-pattern", + }, + ], + "state": Object { + "adHocDataViews": Object { + "test": Object {}, + }, + "datasourceStates": Object { + "formBased": Object { + "layers": Object {}, + }, + "textBased": Object { + "layers": Object { + "layer_0": Object { + "allColumns": Array [ + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "columns": Array [ + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "index": "test", + "query": Object { + "esql": "from test | count=count()", + }, + }, + }, + }, + }, + "filters": Array [], + "internalReferences": Array [], + "query": Object { + "language": "kuery", + "query": "", + }, + "visualization": Object { + "color": undefined, + "layerId": "layer_0", + "layerType": "data", + "metricAccessor": "metric_formula_accessor", + "showBar": false, + }, + }, + "title": "test", + "visualizationType": "lnsMetric", + } + `); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/metric.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/metric.ts new file mode 100644 index 0000000000000..ab2bc68e6fafe --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/metric.ts @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + FormBasedPersistedState, + FormulaPublicApi, + MetricVisualizationState, + PersistedIndexPatternLayer, +} from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { BuildDependencies, DEFAULT_LAYER_ID, LensAttributes, LensMetricConfig } from '../types'; +import { + addLayerColumn, + addLayerFormulaColumns, + buildDatasourceStates, + buildReferences, + getAdhocDataviews, +} from '../utils'; +import { + getBreakdownColumn, + getFormulaColumn, + getHistogramColumn, + getValueColumn, +} from '../columns'; + +const ACCESSOR = 'metric_formula_accessor'; +const HISTOGRAM_COLUMN_NAME = 'x_date_histogram'; +const TRENDLINE_LAYER_ID = `layer_trendline`; + +function getAccessorName(type: 'max' | 'breakdown' | 'secondary') { + return `${ACCESSOR}_${type}`; +} +function buildVisualizationState(config: LensMetricConfig): MetricVisualizationState { + const layer = config; + + return { + layerId: DEFAULT_LAYER_ID, + layerType: 'data', + metricAccessor: ACCESSOR, + color: layer.seriesColor, + // subtitle: layer.subtitle, + showBar: false, + + ...(layer.querySecondaryMetric + ? { + secondaryMetricAccessor: getAccessorName('secondary'), + } + : {}), + + ...(layer.queryMaxValue + ? { + maxAccessor: getAccessorName('max'), + showBar: true, + } + : {}), + + ...(layer.breakdown + ? { + breakdownByAccessor: getAccessorName('breakdown'), + } + : {}), + + ...(layer.trendLine + ? { + trendlineLayerId: `${DEFAULT_LAYER_ID}_trendline`, + trendlineLayerType: 'metricTrendline', + trendlineMetricAccessor: `${ACCESSOR}_trendline`, + trendlineTimeAccessor: HISTOGRAM_COLUMN_NAME, + ...(layer.querySecondaryMetric + ? { + trendlineSecondaryMetricAccessor: `${ACCESSOR}_secondary_trendline`, + } + : {}), + + ...(layer.queryMaxValue + ? { + trendlineMaxAccessor: `${ACCESSOR}_max_trendline`, + } + : {}), + + ...(layer.breakdown + ? { + trendlineBreakdownByAccessor: `${ACCESSOR}_breakdown_trendline`, + } + : {}), + } + : {}), + }; +} + +function buildFormulaLayer( + layer: LensMetricConfig, + i: number, + dataView: DataView, + formulaAPI: FormulaPublicApi +): FormBasedPersistedState['layers'][0] { + const baseLayer: PersistedIndexPatternLayer = { + columnOrder: [ACCESSOR, HISTOGRAM_COLUMN_NAME], + columns: { + [HISTOGRAM_COLUMN_NAME]: getHistogramColumn({ + options: { + sourceField: dataView.timeFieldName, + params: { + interval: 'auto', + includeEmptyRows: true, + }, + }, + }), + }, + sampling: 1, + }; + + const layers: { + layer_0: PersistedIndexPatternLayer; + layer_trendline?: PersistedIndexPatternLayer; + } = { + [DEFAULT_LAYER_ID]: { + ...getFormulaColumn( + ACCESSOR, + { + value: layer.value, + }, + dataView, + formulaAPI + ), + }, + ...(layer.trendLine + ? { + [TRENDLINE_LAYER_ID]: { + linkToLayers: [DEFAULT_LAYER_ID], + ...getFormulaColumn( + `${ACCESSOR}_trendline`, + { value: layer.value }, + dataView, + formulaAPI, + baseLayer + ), + }, + } + : {}), + }; + + const defaultLayer = layers[DEFAULT_LAYER_ID]; + const trendLineLayer = layers[TRENDLINE_LAYER_ID]; + + if (layer.breakdown) { + const columnName = getAccessorName('breakdown'); + const breakdownColumn = getBreakdownColumn({ + options: layer.breakdown, + dataView, + }); + addLayerColumn(defaultLayer, columnName, breakdownColumn, true); + + if (trendLineLayer) { + addLayerColumn(trendLineLayer, `${columnName}_trendline`, breakdownColumn, true); + } + } + + if (layer.querySecondaryMetric) { + const columnName = getAccessorName('secondary'); + const formulaColumn = getFormulaColumn( + columnName, + { + value: layer.querySecondaryMetric, + }, + dataView, + formulaAPI + ); + + addLayerFormulaColumns(defaultLayer, formulaColumn); + if (trendLineLayer) { + addLayerFormulaColumns(trendLineLayer, formulaColumn, 'X0'); + } + } + + if (layer.queryMaxValue) { + const columnName = getAccessorName('max'); + const formulaColumn = getFormulaColumn( + columnName, + { + value: layer.queryMaxValue, + }, + dataView, + formulaAPI + ); + + addLayerFormulaColumns(defaultLayer, formulaColumn); + if (trendLineLayer) { + addLayerFormulaColumns(trendLineLayer, formulaColumn, 'X0'); + } + } + + return layers[DEFAULT_LAYER_ID]; +} + +function getValueColumns(layer: LensMetricConfig) { + if (layer.breakdown && typeof layer.breakdown !== 'string') { + throw new Error('breakdown must be a field name when not using index source'); + } + return [ + ...(layer.breakdown + ? [getValueColumn(getAccessorName('breakdown'), layer.breakdown as string)] + : []), + getValueColumn(ACCESSOR, layer.value), + ...(layer.queryMaxValue ? [getValueColumn(getAccessorName('max'), layer.queryMaxValue)] : []), + ...(layer.querySecondaryMetric + ? [getValueColumn(getAccessorName('secondary'), layer.querySecondaryMetric)] + : []), + ]; +} + +export async function buildMetric( + config: LensMetricConfig, + { dataViewsAPI, formulaAPI }: BuildDependencies +): Promise { + const dataviews: Record = {}; + const _buildFormulaLayer = (cfg: unknown, i: number, dataView: DataView) => + buildFormulaLayer(cfg as LensMetricConfig, i, dataView, formulaAPI); + const datasourceStates = await buildDatasourceStates( + config, + dataviews, + _buildFormulaLayer, + getValueColumns, + dataViewsAPI + ); + return { + title: config.title, + visualizationType: 'lnsMetric', + references: buildReferences(dataviews), + state: { + datasourceStates, + internalReferences: [], + filters: [], + query: { language: 'kuery', query: '' }, + visualization: buildVisualizationState(config), + // Getting the spec from a data view is a heavy operation, that's why the result is cached. + adHocDataViews: getAdhocDataviews(dataviews), + }, + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/partition.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/partition.test.ts new file mode 100644 index 0000000000000..22ee18f3e5f3e --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/partition.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView, DataViewField, DataViewsContract } from '@kbn/data-views-plugin/common'; +import { buildPartitionChart } from './partition'; + +const dataViews: Record = { + test: { + id: 'test', + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return { + type: 'datetime', + } as unknown as DataViewField; + case 'category': + return { + type: 'string', + } as unknown as DataViewField; + case 'price': + return { + type: 'number', + } as unknown as DataViewField; + default: + return undefined; + } + }, + } as any, + } as unknown as DataView, +}; + +function mockDataViewsService() { + return { + get: jest.fn(async (id: '1' | '2') => { + const result = { + ...dataViews[id], + metaFields: [], + isPersisted: () => true, + toSpec: () => ({}), + }; + return result; + }), + create: jest.fn(), + } as unknown as Pick; +} + +test('generates metric chart config', async () => { + const result = await buildPartitionChart( + { + chartType: 'treemap', + title: 'test', + dataset: { + esql: 'from test | count=count() by @timestamp, category', + }, + value: 'count', + breakdown: ['@timestamp', 'category'], + }, + { + dataViewsAPI: mockDataViewsService() as any, + formulaAPI: {} as any, + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "test", + "name": "indexpattern-datasource-layer-layer_0", + "type": "index-pattern", + }, + ], + "state": Object { + "adHocDataViews": Object { + "test": Object {}, + }, + "datasourceStates": Object { + "formBased": Object { + "layers": Object {}, + }, + "textBased": Object { + "layers": Object { + "layer_0": Object { + "allColumns": Array [ + Object { + "columnId": "metric_formula_accessor_breakdown_0", + "fieldName": "@timestamp", + }, + Object { + "columnId": "metric_formula_accessor_breakdown_1", + "fieldName": "category", + }, + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "columns": Array [ + Object { + "columnId": "metric_formula_accessor_breakdown_0", + "fieldName": "@timestamp", + }, + Object { + "columnId": "metric_formula_accessor_breakdown_1", + "fieldName": "category", + }, + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "index": "test", + "query": Object { + "esql": "from test | count=count() by @timestamp, category", + }, + }, + }, + }, + }, + "filters": Array [], + "internalReferences": Array [], + "query": Object { + "language": "kuery", + "query": "", + }, + "visualization": Object { + "layers": Array [ + Object { + "allowMultipleMetrics": false, + "categoryDisplay": "default", + "layerId": "layer_0", + "layerType": "data", + "legendDisplay": "default", + "legendPosition": "right", + "metrics": Array [ + "metric_formula_accessor", + ], + "numberDisplay": "percent", + "primaryGroups": Array [ + "metric_formula_accessor_breakdown_0", + "metric_formula_accessor_breakdown_1", + ], + }, + ], + "shape": "treemap", + }, + }, + "title": "test", + "visualizationType": "lnsPie", + } + `); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/partition.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/partition.ts new file mode 100644 index 0000000000000..a6c32db059f9b --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/partition.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + FormBasedPersistedState, + FormulaPublicApi, + PieVisualizationState, +} from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { + BuildDependencies, + DEFAULT_LAYER_ID, + LensAttributes, + LensPieConfig, + LensTreeMapConfig, + LensMosaicConfig, + LensLegendConfig, +} from '../types'; +import { + addLayerColumn, + buildDatasourceStates, + buildReferences, + getAdhocDataviews, +} from '../utils'; +import { getBreakdownColumn, getFormulaColumn, getValueColumn } from '../columns'; + +const ACCESSOR = 'metric_formula_accessor'; + +function buildVisualizationState( + config: LensTreeMapConfig | LensPieConfig | LensMosaicConfig +): PieVisualizationState { + const layer = config; + + const layerBreakdown = Array.isArray(layer.breakdown) ? layer.breakdown : [layer.breakdown]; + + let legendDisplay: 'default' | 'hide' | 'show' = 'default'; + let legendPosition: LensLegendConfig['position'] = 'right'; + + if ('legend' in config && config.legend) { + if ('show' in config.legend) { + legendDisplay = config.legend ? 'show' : 'hide'; + } + legendPosition = config.legend.position || 'right'; + } + return { + shape: config.chartType, + layers: [ + { + layerId: DEFAULT_LAYER_ID, + layerType: 'data', + metrics: [ACCESSOR], + allowMultipleMetrics: false, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay, + legendPosition, + primaryGroups: layerBreakdown.map((breakdown, i) => `${ACCESSOR}_breakdown_${i}`), + }, + ], + }; +} + +function buildFormulaLayer( + layer: LensTreeMapConfig | LensPieConfig | LensMosaicConfig, + layerNr: number, + dataView: DataView, + formulaAPI: FormulaPublicApi +): FormBasedPersistedState['layers'][0] { + const layers = { + [DEFAULT_LAYER_ID]: { + ...getFormulaColumn( + ACCESSOR, + { + value: layer.value, + }, + dataView, + formulaAPI + ), + }, + }; + + const defaultLayer = layers[DEFAULT_LAYER_ID]; + + if (layer.breakdown) { + const layerBreakdown = Array.isArray(layer.breakdown) ? layer.breakdown : [layer.breakdown]; + layerBreakdown.reverse().forEach((breakdown, i) => { + const columnName = `${ACCESSOR}_breakdown_${i}`; + const breakdownColumn = getBreakdownColumn({ + options: breakdown, + dataView, + }); + addLayerColumn(defaultLayer, columnName, breakdownColumn, true); + }); + } else { + throw new Error('breakdown must be defined!'); + } + + return defaultLayer; +} + +function getValueColumns(layer: LensTreeMapConfig) { + if (layer.breakdown && layer.breakdown.filter((b) => typeof b !== 'string').length) { + throw new Error('breakdown must be a field name when not using index source'); + } + + return [ + ...(layer.breakdown + ? layer.breakdown.map((b, i) => { + return getValueColumn(`${ACCESSOR}_breakdown_${i}`, b as string); + }) + : []), + getValueColumn(ACCESSOR, layer.value), + ]; +} + +export async function buildPartitionChart( + config: LensTreeMapConfig | LensPieConfig, + { dataViewsAPI, formulaAPI }: BuildDependencies +): Promise { + const dataviews: Record = {}; + const _buildFormulaLayer = (cfg: any, i: number, dataView: DataView) => + buildFormulaLayer(cfg, i, dataView, formulaAPI); + const datasourceStates = await buildDatasourceStates( + config, + dataviews, + _buildFormulaLayer, + getValueColumns, + dataViewsAPI + ); + return { + title: config.title, + visualizationType: 'lnsPie', + references: buildReferences(dataviews), + state: { + datasourceStates, + internalReferences: [], + filters: [], + query: { language: 'kuery', query: '' }, + visualization: buildVisualizationState(config), + // Getting the spec from a data view is a heavy operation, that's why the result is cached. + adHocDataViews: getAdhocDataviews(dataviews), + }, + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/region_map.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/region_map.test.ts new file mode 100644 index 0000000000000..0093b03e33bc3 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/region_map.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView, DataViewField, DataViewsContract } from '@kbn/data-views-plugin/common'; +import { buildRegionMap } from './region_map'; + +const dataViews: Record = { + test: { + id: 'test', + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return { + type: 'datetime', + } as unknown as DataViewField; + case 'category': + return { + type: 'string', + } as unknown as DataViewField; + case 'price': + return { + type: 'number', + } as unknown as DataViewField; + default: + return undefined; + } + }, + } as any, + } as unknown as DataView, +}; + +function mockDataViewsService() { + return { + get: jest.fn(async (id: '1' | '2') => { + const result = { + ...dataViews[id], + metaFields: [], + isPersisted: () => true, + toSpec: () => ({}), + }; + return result; + }), + create: jest.fn(), + } as unknown as Pick; +} + +test('generates metric chart config', async () => { + const result = await buildRegionMap( + { + chartType: 'regionmap', + title: 'test', + dataset: { + esql: 'from test | count=count() by category', + }, + value: 'count', + breakdown: 'category', + }, + { + dataViewsAPI: mockDataViewsService() as any, + formulaAPI: {} as any, + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "test", + "name": "indexpattern-datasource-layer-layer_0", + "type": "index-pattern", + }, + ], + "state": Object { + "adHocDataViews": Object { + "test": Object {}, + }, + "datasourceStates": Object { + "formBased": Object { + "layers": Object {}, + }, + "textBased": Object { + "layers": Object { + "layer_0": Object { + "allColumns": Array [ + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + Object { + "columnId": "metric_formula_accessor_breakdown", + "fieldName": "category", + }, + ], + "columns": Array [ + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + Object { + "columnId": "metric_formula_accessor_breakdown", + "fieldName": "category", + }, + ], + "index": "test", + "query": Object { + "esql": "from test | count=count() by category", + }, + }, + }, + }, + }, + "filters": Array [], + "internalReferences": Array [], + "query": Object { + "language": "kuery", + "query": "", + }, + "visualization": Object { + "layerId": "layer_0", + "layerType": "data", + "regionAccessor": "metric_formula_accessor_breakdown", + "valueAccessor": "metric_formula_accessor", + }, + }, + "title": "test", + "visualizationType": "lnsChoropleth", + } + `); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/region_map.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/region_map.ts new file mode 100644 index 0000000000000..2ed9e0dac9d8a --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/region_map.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FormBasedPersistedState, FormulaPublicApi } from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import type { ChoroplethChartState } from '@kbn/maps-plugin/public/lens/choropleth_chart/types'; +import { + BuildDependencies, + DEFAULT_LAYER_ID, + LensAttributes, + LensRegionMapConfig, + LensTagCloudConfig, +} from '../types'; +import { + addLayerColumn, + buildDatasourceStates, + buildReferences, + getAdhocDataviews, +} from '../utils'; +import { getBreakdownColumn, getFormulaColumn, getValueColumn } from '../columns'; + +const ACCESSOR = 'metric_formula_accessor'; + +function getAccessorName(type: 'breakdown') { + return `${ACCESSOR}_${type}`; +} +function buildVisualizationState( + config: LensRegionMapConfig +): ChoroplethChartState & { layerType: 'data' } { + const layer = config; + + return { + layerId: DEFAULT_LAYER_ID, + layerType: 'data', + valueAccessor: ACCESSOR, + ...(layer.breakdown + ? { + regionAccessor: getAccessorName('breakdown'), + } + : {}), + }; +} + +function buildFormulaLayer( + layer: LensRegionMapConfig, + i: number, + dataView: DataView, + formulaAPI: FormulaPublicApi +): FormBasedPersistedState['layers'][0] { + const layers = { + [DEFAULT_LAYER_ID]: { + ...getFormulaColumn( + ACCESSOR, + { + value: layer.value, + }, + dataView, + formulaAPI + ), + }, + }; + + const defaultLayer = layers[DEFAULT_LAYER_ID]; + + if (layer.breakdown) { + const columnName = getAccessorName('breakdown'); + const breakdownColumn = getBreakdownColumn({ + options: layer.breakdown, + dataView, + }); + addLayerColumn(defaultLayer, columnName, breakdownColumn, true); + } else { + throw new Error('breakdown must be defined for regionmap!'); + } + + return defaultLayer; +} + +function getValueColumns(layer: LensTagCloudConfig) { + if (typeof layer.breakdown !== 'string') { + throw new Error('breakdown must be a field name when not using index source'); + } + return [ + getValueColumn(ACCESSOR, layer.value), + getValueColumn(getAccessorName('breakdown'), layer.breakdown as string), + ]; +} + +export async function buildRegionMap( + config: LensRegionMapConfig, + { dataViewsAPI, formulaAPI }: BuildDependencies +): Promise { + const dataviews: Record = {}; + const _buildFormulaLayer = (cfg: unknown, i: number, dataView: DataView) => + buildFormulaLayer(cfg as LensRegionMapConfig, i, dataView, formulaAPI); + const datasourceStates = await buildDatasourceStates( + config, + dataviews, + _buildFormulaLayer, + getValueColumns, + dataViewsAPI + ); + + return { + title: config.title, + visualizationType: 'lnsChoropleth', + references: buildReferences(dataviews), + state: { + datasourceStates, + internalReferences: [], + filters: [], + query: { language: 'kuery', query: '' }, + visualization: buildVisualizationState(config), + // Getting the spec from a data view is a heavy operation, that's why the result is cached. + adHocDataViews: getAdhocDataviews(dataviews), + }, + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/table.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/table.test.ts new file mode 100644 index 0000000000000..909f3e737ac99 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/table.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView, DataViewField, DataViewsContract } from '@kbn/data-views-plugin/common'; +import { buildTable } from './table'; + +const dataViews: Record = { + test: { + id: 'test', + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return { + type: 'datetime', + } as unknown as DataViewField; + case 'category': + return { + type: 'string', + } as unknown as DataViewField; + case 'price': + return { + type: 'number', + } as unknown as DataViewField; + default: + return undefined; + } + }, + } as any, + } as unknown as DataView, +}; + +function mockDataViewsService() { + return { + get: jest.fn(async (id: '1' | '2') => { + const result = { + ...dataViews[id], + metaFields: [], + isPersisted: () => true, + toSpec: () => ({}), + }; + return result; + }), + create: jest.fn(), + } as unknown as Pick; +} + +test('generates metric chart config', async () => { + const result = await buildTable( + { + chartType: 'table', + title: 'test', + dataset: { + esql: 'from test | count=count() by category', + }, + value: 'count', + breakdown: ['category'], + }, + { + dataViewsAPI: mockDataViewsService() as any, + formulaAPI: {} as any, + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "test", + "name": "indexpattern-datasource-layer-layer_0", + "type": "index-pattern", + }, + ], + "state": Object { + "adHocDataViews": Object { + "test": Object {}, + }, + "datasourceStates": Object { + "formBased": Object { + "layers": Object {}, + }, + "textBased": Object { + "layers": Object { + "layer_0": Object { + "allColumns": Array [ + Object { + "columnId": "metric_formula_accessor_breakdown_0", + "fieldName": "category", + }, + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "columns": Array [ + Object { + "columnId": "metric_formula_accessor_breakdown_0", + "fieldName": "category", + }, + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + ], + "index": "test", + "query": Object { + "esql": "from test | count=count() by category", + }, + }, + }, + }, + }, + "filters": Array [], + "internalReferences": Array [], + "query": Object { + "language": "kuery", + "query": "", + }, + "visualization": Object { + "columns": Array [ + Object { + "columnId": "metric_formula_accessor", + }, + Object { + "columnId": "metric_formula_accessor_breakdown_0", + }, + ], + "layerId": "layer_0", + "layerType": "data", + }, + }, + "title": "test", + "visualizationType": "lnsDatatable", + } + `); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/table.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/table.ts new file mode 100644 index 0000000000000..a14934dca57a0 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/table.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + FormBasedPersistedState, + FormulaPublicApi, + DatatableVisualizationState, +} from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { BuildDependencies, DEFAULT_LAYER_ID, LensAttributes, LensTableConfig } from '../types'; +import { + addLayerColumn, + buildDatasourceStates, + buildReferences, + getAdhocDataviews, +} from '../utils'; +import { getBreakdownColumn, getFormulaColumn, getValueColumn } from '../columns'; + +const ACCESSOR = 'metric_formula_accessor'; +function buildVisualizationState(config: LensTableConfig): DatatableVisualizationState { + const layer = config; + + return { + layerId: DEFAULT_LAYER_ID, + layerType: 'data', + columns: [ + { columnId: ACCESSOR }, + ...(layer.breakdown || []).map((breakdown, i) => ({ + columnId: `${ACCESSOR}_breakdown_${i}`, + })), + ...(layer.splitBy || []).map((breakdown, i) => ({ columnId: `${ACCESSOR}_splitby_${i}` })), + ], + }; +} +function buildFormulaLayer( + layer: LensTableConfig, + i: number, + dataView: DataView, + formulaAPI: FormulaPublicApi +): FormBasedPersistedState['layers'][0] { + const layers = { + [DEFAULT_LAYER_ID]: { + ...getFormulaColumn( + ACCESSOR, + { + value: layer.value, + }, + dataView, + formulaAPI + ), + }, + }; + + const defaultLayer = layers[DEFAULT_LAYER_ID]; + + if (layer.breakdown) { + layer.breakdown.reverse().forEach((breakdown, x) => { + const columnName = `${ACCESSOR}_breakdown_${x}`; + const breakdownColumn = getBreakdownColumn({ + options: breakdown, + dataView, + }); + addLayerColumn(defaultLayer, columnName, breakdownColumn, true); + }); + } else { + throw new Error('breakdown must be defined for table!'); + } + + if (layer.splitBy) { + layer.splitBy.forEach((breakdown, x) => { + const columnName = `${ACCESSOR}_splitby_${x}`; + const breakdownColumn = getBreakdownColumn({ + options: breakdown, + dataView, + }); + addLayerColumn(defaultLayer, columnName, breakdownColumn, true); + }); + } + + return defaultLayer; +} + +function getValueColumns(layer: LensTableConfig) { + if (layer.breakdown && layer.breakdown.filter((b) => typeof b !== 'string').length) { + throw new Error('breakdown must be a field name when not using index source'); + } + if (layer.splitBy && layer.splitBy.filter((s) => typeof s !== 'string').length) { + throw new Error('xAxis must be a field name when not using index source'); + } + return [ + ...(layer.breakdown + ? layer.breakdown.map((b, i) => { + return getValueColumn(`${ACCESSOR}_breakdown_${i}`, b as string); + }) + : []), + ...(layer.splitBy + ? layer.splitBy.map((b, i) => { + return getValueColumn(`${ACCESSOR}_splitby_${i}`, b as string); + }) + : []), + getValueColumn(ACCESSOR, layer.value), + ]; +} + +export async function buildTable( + config: LensTableConfig, + { dataViewsAPI, formulaAPI }: BuildDependencies +): Promise { + const dataviews: Record = {}; + const _buildFormulaLayer = (cfg: unknown, i: number, dataView: DataView) => + buildFormulaLayer(cfg as LensTableConfig, i, dataView, formulaAPI); + const datasourceStates = await buildDatasourceStates( + config, + dataviews, + _buildFormulaLayer, + getValueColumns, + dataViewsAPI + ); + return { + title: config.title, + visualizationType: 'lnsDatatable', + references: buildReferences(dataviews), + state: { + datasourceStates, + internalReferences: [], + filters: [], + query: { language: 'kuery', query: '' }, + visualization: buildVisualizationState(config), + // Getting the spec from a data view is a heavy operation, that's why the result is cached. + adHocDataViews: getAdhocDataviews(dataviews), + }, + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/tag_cloud.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/tag_cloud.test.ts new file mode 100644 index 0000000000000..08885ba57ba83 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/tag_cloud.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView, DataViewField, DataViewsContract } from '@kbn/data-views-plugin/common'; +import { buildTagCloud } from './tag_cloud'; + +const dataViews: Record = { + test: { + id: 'test', + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return { + type: 'datetime', + } as unknown as DataViewField; + case 'category': + return { + type: 'string', + } as unknown as DataViewField; + case 'price': + return { + type: 'number', + } as unknown as DataViewField; + default: + return undefined; + } + }, + } as any, + } as unknown as DataView, +}; + +function mockDataViewsService() { + return { + get: jest.fn(async (id: '1' | '2') => { + const result = { + ...dataViews[id], + metaFields: [], + isPersisted: () => true, + toSpec: () => ({}), + }; + return result; + }), + create: jest.fn(), + } as unknown as Pick; +} + +test('generates metric chart config', async () => { + const result = await buildTagCloud( + { + chartType: 'tagcloud', + title: 'test', + dataset: { + esql: 'from test | count=count() by category', + }, + value: 'count', + breakdown: 'category', + }, + { + dataViewsAPI: mockDataViewsService() as any, + formulaAPI: {} as any, + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "test", + "name": "indexpattern-datasource-layer-layer_0", + "type": "index-pattern", + }, + ], + "state": Object { + "adHocDataViews": Object { + "test": Object {}, + }, + "datasourceStates": Object { + "formBased": Object { + "layers": Object {}, + }, + "textBased": Object { + "layers": Object { + "layer_0": Object { + "allColumns": Array [ + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + Object { + "columnId": "metric_formula_accessor_breakdown", + "fieldName": "category", + }, + ], + "columns": Array [ + Object { + "columnId": "metric_formula_accessor", + "fieldName": "count", + }, + Object { + "columnId": "metric_formula_accessor_breakdown", + "fieldName": "category", + }, + ], + "index": "test", + "query": Object { + "esql": "from test | count=count() by category", + }, + }, + }, + }, + }, + "filters": Array [], + "internalReferences": Array [], + "query": Object { + "language": "kuery", + "query": "", + }, + "visualization": Object { + "layerId": "layer_0", + "maxFontSize": 72, + "minFontSize": 12, + "orientation": "single", + "showLabel": true, + "tagAccessor": "category", + "valueAccessor": "count", + }, + }, + "title": "test", + "visualizationType": "lnsTagcloud", + } + `); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/tag_cloud.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/tag_cloud.ts new file mode 100644 index 0000000000000..31ea43af1dd6b --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/tag_cloud.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + FormBasedPersistedState, + FormulaPublicApi, + TagcloudState, +} from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { BuildDependencies, DEFAULT_LAYER_ID, LensAttributes, LensTagCloudConfig } from '../types'; +import { + addLayerColumn, + buildDatasourceStates, + buildReferences, + getAdhocDataviews, + isFormulaDataset, +} from '../utils'; +import { getBreakdownColumn, getFormulaColumn, getValueColumn } from '../columns'; + +const ACCESSOR = 'metric_formula_accessor'; + +function getAccessorName(type: 'breakdown') { + return `${ACCESSOR}_${type}`; +} + +function buildVisualizationState(config: LensTagCloudConfig): TagcloudState { + const layer = config; + const isFormula = isFormulaDataset(config.dataset) || isFormulaDataset(layer.dataset); + return { + layerId: DEFAULT_LAYER_ID, + valueAccessor: !isFormula ? layer.value : ACCESSOR, + maxFontSize: 72, + minFontSize: 12, + orientation: 'single', + showLabel: true, + ...(layer.breakdown + ? { + tagAccessor: !isFormula ? (layer.breakdown as string) : getAccessorName('breakdown'), + } + : {}), + }; +} + +function buildFormulaLayer( + layer: LensTagCloudConfig, + i: number, + dataView: DataView, + formulaAPI: FormulaPublicApi +): FormBasedPersistedState['layers'][0] { + const layers = { + [DEFAULT_LAYER_ID]: { + ...getFormulaColumn( + ACCESSOR, + { + value: layer.value, + }, + dataView, + formulaAPI + ), + }, + }; + + const defaultLayer = layers[DEFAULT_LAYER_ID]; + + if (layer.breakdown) { + const columnName = getAccessorName('breakdown'); + const breakdownColumn = getBreakdownColumn({ + options: layer.breakdown, + dataView, + }); + addLayerColumn(defaultLayer, columnName, breakdownColumn, true); + } else { + throw new Error('breakdown must be defined on tagcloud!'); + } + + return defaultLayer; +} + +function getValueColumns(layer: LensTagCloudConfig) { + if (layer.breakdown && typeof layer.breakdown !== 'string') { + throw new Error('breakdown must be a field name when not using index source'); + } + + return [ + getValueColumn(ACCESSOR, layer.value), + getValueColumn(getAccessorName('breakdown'), layer.breakdown as string), + ]; +} + +export async function buildTagCloud( + config: LensTagCloudConfig, + { dataViewsAPI, formulaAPI }: BuildDependencies +): Promise { + const dataviews: Record = {}; + const _buildFormulaLayer = (cfg: unknown, i: number, dataView: DataView) => + buildFormulaLayer(cfg as LensTagCloudConfig, i, dataView, formulaAPI); + const datasourceStates = await buildDatasourceStates( + config, + dataviews, + _buildFormulaLayer, + getValueColumns, + dataViewsAPI + ); + return { + title: config.title, + visualizationType: 'lnsTagcloud', + references: buildReferences(dataviews), + state: { + datasourceStates, + internalReferences: [], + filters: [], + query: { language: 'kuery', query: '' }, + visualization: buildVisualizationState(config), + // Getting the spec from a data view is a heavy operation, that's why the result is cached. + adHocDataViews: getAdhocDataviews(dataviews), + }, + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/xy.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/xy.test.ts new file mode 100644 index 0000000000000..d1646b4c0ec7d --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/xy.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView, DataViewField, DataViewsContract } from '@kbn/data-views-plugin/common'; +import { buildXY } from './xy'; + +const dataViews: Record = { + test: { + id: 'test', + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return { + type: 'datetime', + } as unknown as DataViewField; + case 'category': + return { + type: 'string', + } as unknown as DataViewField; + case 'price': + return { + type: 'number', + } as unknown as DataViewField; + default: + return undefined; + } + }, + } as any, + } as unknown as DataView, +}; + +function mockDataViewsService() { + return { + get: jest.fn(async (id: '1' | '2') => { + const result = { + ...dataViews[id], + metaFields: [], + isPersisted: () => true, + toSpec: () => ({}), + }; + return result; + }), + create: jest.fn(), + } as unknown as Pick; +} + +test('generates metric chart config', async () => { + const result = await buildXY( + { + chartType: 'xy', + title: 'test', + dataset: { + esql: 'from test | count=count() by @timestamp', + }, + layers: [ + { + type: 'series', + seriesType: 'bar', + label: 'test', + value: 'count', + xAxis: '@timestamp', + }, + ], + }, + { + dataViewsAPI: mockDataViewsService() as any, + formulaAPI: {} as any, + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "references": Array [ + Object { + "id": "test", + "name": "indexpattern-datasource-layer-layer_0", + "type": "index-pattern", + }, + ], + "state": Object { + "adHocDataViews": Object { + "test": Object {}, + }, + "datasourceStates": Object { + "formBased": Object { + "layers": Object {}, + }, + "textBased": Object { + "layers": Object { + "layer_0": Object { + "allColumns": Array [ + Object { + "columnId": "metric_formula_accessor0_x", + "fieldName": "@timestamp", + }, + Object { + "columnId": "metric_formula_accessor0", + "fieldName": "count", + "meta": Object { + "type": "number", + }, + }, + ], + "columns": Array [ + Object { + "columnId": "metric_formula_accessor0_x", + "fieldName": "@timestamp", + }, + Object { + "columnId": "metric_formula_accessor0", + "fieldName": "count", + "meta": Object { + "type": "number", + }, + }, + ], + "index": "test", + "query": Object { + "esql": "from test | count=count() by @timestamp", + }, + }, + }, + }, + }, + "filters": Array [], + "internalReferences": Array [], + "query": Object { + "language": "kuery", + "query": "", + }, + "visualization": Object { + "axisTitlesVisibilitySettings": Object { + "x": true, + "yLeft": true, + "yRight": true, + }, + "fittingFunction": "None", + "gridlinesVisibilitySettings": Object { + "x": true, + "yLeft": true, + "yRight": true, + }, + "labelsOrientation": Object { + "x": 0, + "yLeft": 0, + "yRight": 0, + }, + "layers": Array [ + Object { + "accessors": Array [ + "metric_formula_accessor0", + ], + "layerId": "layer_0", + "layerType": "data", + "seriesType": "bar", + "xAccessor": "metric_formula_accessor0_x", + }, + ], + "legend": Object { + "isVisible": true, + "position": "left", + }, + "preferredSeriesType": "line", + "tickLabelsVisibilitySettings": Object { + "x": true, + "yLeft": true, + "yRight": true, + }, + "valueLabels": "hide", + }, + }, + "title": "test", + "visualizationType": "lnsXY", + } + `); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/charts/xy.ts b/packages/kbn-lens-embeddable-utils/config_builder/charts/xy.ts new file mode 100644 index 0000000000000..1b9171d0f6d25 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/charts/xy.ts @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + FormBasedPersistedState, + FormulaPublicApi, + XYState, + XYReferenceLineLayerConfig, + XYDataLayerConfig, +} from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import type { XYByValueAnnotationLayerConfig } from '@kbn/lens-plugin/public/visualizations/xy/types'; +import type { QueryPointEventAnnotationConfig } from '@kbn/event-annotation-common'; +import { getBreakdownColumn, getFormulaColumn, getValueColumn } from '../columns'; +import { + addLayerColumn, + buildDatasourceStates, + buildReferences, + getAdhocDataviews, +} from '../utils'; +import { + BuildDependencies, + LensAnnotationLayer, + LensAttributes, + LensReferenceLineLayer, + LensSeriesLayer, + LensXYConfig, +} from '../types'; + +const ACCESSOR = 'metric_formula_accessor'; + +function buildVisualizationState(config: LensXYConfig): XYState { + return { + legend: { + isVisible: config.legend?.show || true, + position: config.legend?.position || 'left', + }, + preferredSeriesType: 'line', + valueLabels: 'hide', + fittingFunction: 'None', + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + layers: config.layers.map((layer, i) => { + switch (layer.type) { + case 'annotation': + return { + layerId: `layer_${i}`, + layerType: 'annotations', + annotations: layer.events.map((e, eventNr) => { + if ('datetime' in e) { + return { + type: 'manual', + id: `annotation_${eventNr}`, + icon: e.icon || 'triangle', + color: e.color || 'blue', + label: e.name, + key: { + type: 'point_in_time', + timestamp: e.datetime, + }, + }; + } else { + return { + id: `event${eventNr}`, + type: 'query', + icon: e.icon || 'triangle', + color: e.color || 'blue', + label: e.name, + key: { + type: 'point_in_time', + }, + filter: { + type: 'kibana_query', + query: e.filter, + language: 'kuery', + }, + ...(e.field ? { timeField: e.field } : {}), + } as QueryPointEventAnnotationConfig; + } + }), + ignoreGlobalFilters: true, + } as XYByValueAnnotationLayerConfig; + case 'reference': + return { + layerId: `layer_${i}`, + layerType: 'referenceLine', + accessors: [`${ACCESSOR}${i}`], + yConfig: [ + { + forAccessor: `${ACCESSOR}${i}`, + axisMode: 'left', + }, + ], + } as XYReferenceLineLayerConfig; + case 'series': + return { + layerId: `layer_${i}`, + layerType: 'data', + xAccessor: `${ACCESSOR}${i}_x`, + ...(layer.breakdown + ? { + splitAccessor: `${ACCESSOR}${i}_y}`, + } + : {}), + accessors: [`${ACCESSOR}${i}`], + seriesType: layer.seriesType || 'line', + } as XYDataLayerConfig; + } + }), + }; +} + +function getValueColumns(layer: LensSeriesLayer, i: number) { + if (layer.breakdown && typeof layer.breakdown !== 'string') { + throw new Error('breakdown must be a field name when not using index source'); + } + if (typeof layer.xAxis !== 'string') { + throw new Error('xAxis must be a field name when not using index source'); + } + return [ + ...(layer.breakdown + ? [getValueColumn(`${ACCESSOR}${i}_breakdown`, layer.breakdown as string)] + : []), + getValueColumn(`${ACCESSOR}${i}_x`, layer.xAxis as string), + getValueColumn(`${ACCESSOR}${i}`, layer.value, 'number'), + ]; +} + +function buildFormulaLayer( + layer: LensSeriesLayer | LensAnnotationLayer | LensReferenceLineLayer, + i: number, + dataView: DataView, + formulaAPI: FormulaPublicApi +): FormBasedPersistedState['layers'][0] { + if (layer.type === 'series') { + const resultLayer = { + ...getFormulaColumn( + `${ACCESSOR}${i}`, + { + value: layer.value, + }, + dataView, + formulaAPI + ), + }; + + if (layer.xAxis) { + const columnName = `${ACCESSOR}${i}_x`; + const breakdownColumn = getBreakdownColumn({ + options: layer.xAxis, + dataView, + }); + addLayerColumn(resultLayer, columnName, breakdownColumn, true); + } + + if (layer.breakdown) { + const columnName = `${ACCESSOR}${i}_y`; + const breakdownColumn = getBreakdownColumn({ + options: layer.breakdown, + dataView, + }); + addLayerColumn(resultLayer, columnName, breakdownColumn, true); + } + + return resultLayer; + } else if (layer.type === 'annotation') { + // nothing ? + } else if (layer.type === 'reference') { + return { + ...getFormulaColumn( + `${ACCESSOR}${i}`, + { + value: layer.value, + }, + dataView, + formulaAPI + ), + }; + } + + return { + columns: {}, + columnOrder: [], + }; +} + +export async function buildXY( + config: LensXYConfig, + { dataViewsAPI, formulaAPI }: BuildDependencies +): Promise { + const dataviews: Record = {}; + const _buildFormulaLayer = (cfg: any, i: number, dataView: DataView) => + buildFormulaLayer(cfg, i, dataView, formulaAPI); + const datasourceStates = await buildDatasourceStates( + config, + dataviews, + _buildFormulaLayer, + getValueColumns, + dataViewsAPI + ); + const references = buildReferences(dataviews); + + return { + title: config.title, + visualizationType: 'lnsXY', + references, + state: { + datasourceStates, + internalReferences: [], + filters: [], + query: { language: 'kuery', query: '' }, + visualization: buildVisualizationState(config), + // Getting the spec from a data view is a heavy operation, that's why the result is cached. + adHocDataViews: getAdhocDataviews(dataviews), + }, + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/breakdown.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/breakdown.test.ts new file mode 100644 index 0000000000000..edeec5ed2de51 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/breakdown.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getBreakdownColumn } from './breakdown'; +import type { DataView } from '@kbn/data-views-plugin/common'; + +const dataView = { + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return { type: 'date' }; + case 'category': + return { type: 'string' }; + case 'price': + return { type: 'number' }; + default: + return { type: 'string' }; + } + }, + }, +}; + +test('uses terms when field is a string', () => { + const column = getBreakdownColumn({ + options: 'category', + dataView: dataView as unknown as DataView, + }); + expect(column.operationType).toEqual('terms'); +}); + +test('uses date histogram when field is a date', () => { + const column = getBreakdownColumn({ + options: '@timestamp', + dataView: dataView as unknown as DataView, + }); + expect(column.operationType).toEqual('date_histogram'); +}); + +test('uses intervals when field is a number', () => { + const column = getBreakdownColumn({ + options: 'price', + dataView: dataView as unknown as DataView, + }); + expect(column.operationType).toEqual('range'); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/breakdown.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/breakdown.ts new file mode 100644 index 0000000000000..6b22bd4bde98a --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/breakdown.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DataView } from '@kbn/data-views-plugin/public'; +import type { GenericIndexPatternColumn } from '@kbn/lens-plugin/public'; +import { + LensBreakdownConfig, + LensBreakdownDateHistogramConfig, + LensBreakdownFiltersConfig, + LensBreakdownIntervalsConfig, + LensBreakdownTopValuesConfig, +} from '../types'; +import { getHistogramColumn } from './date_histogram'; +import { getTopValuesColumn } from './top_values'; +import { getIntervalsColumn } from './intervals'; +import { getFiltersColumn } from './filters'; + +const DEFAULT_BREAKDOWN_SIZE = 5; + +function getBreakdownType(field: string, dataview: DataView) { + if (!dataview.fields.getByName(field)) { + throw new Error( + `field ${field} does not exist on dataview ${dataview.id ? dataview.id : dataview.title}` + ); + } + + switch (dataview.fields.getByName(field)!.type) { + case 'string': + return 'topValues'; + case 'number': + return 'intervals'; + case 'date': + return 'dateHistogram'; + default: + return 'topValues'; + } +} +export const getBreakdownColumn = ({ + options, + dataView, +}: { + options: LensBreakdownConfig; + dataView: DataView; +}): GenericIndexPatternColumn => { + const breakdownType = + typeof options === 'string' ? getBreakdownType(options, dataView) : options.type; + const field: string = + typeof options === 'string' ? options : 'field' in options ? options.field : ''; + const config = typeof options !== 'string' ? options : {}; + + switch (breakdownType) { + case 'dateHistogram': + return getHistogramColumn({ + options: { + sourceField: field, + params: + typeof options !== 'string' + ? { + interval: (options as LensBreakdownDateHistogramConfig).minimumInterval || 'auto', + } + : { + interval: 'auto', + }, + }, + }); + case 'topValues': + const topValuesOptions = config as LensBreakdownTopValuesConfig; + return getTopValuesColumn({ + field, + options: { + size: topValuesOptions.size || DEFAULT_BREAKDOWN_SIZE, + }, + }); + case 'intervals': + const intervalOptions = config as LensBreakdownIntervalsConfig; + return getIntervalsColumn({ + field, + options: { + type: 'range', + ranges: [ + { + from: 0, + to: 1000, + label: '', + }, + ], + maxBars: intervalOptions.granularity || 'auto', + }, + }); + case 'filters': + const filterOptions = config as LensBreakdownFiltersConfig; + return getFiltersColumn({ + options: { + filters: filterOptions.filters.map((f) => ({ + label: f.label || '', + input: { + language: 'kuery', + query: f.filter, + }, + })), + }, + }); + } +}; diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/date_histogram.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/date_histogram.ts new file mode 100644 index 0000000000000..59098f7e18f16 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/date_histogram.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { DateHistogramIndexPatternColumn } from '@kbn/lens-plugin/public'; + +export type DateHistogramColumnParams = DateHistogramIndexPatternColumn['params']; +export const getHistogramColumn = ({ + options, +}: { + options?: Partial< + Pick & { + params: DateHistogramColumnParams; + } + >; +}): DateHistogramIndexPatternColumn => { + const { interval = 'auto', ...rest } = options?.params ?? {}; + + return { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + scale: 'interval', + sourceField: '@timestamp', + ...options, + params: { interval, ...rest }, + }; +}; diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/filters.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/filters.ts new file mode 100644 index 0000000000000..e536930ef4f77 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/filters.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FiltersIndexPatternColumn } from '@kbn/lens-plugin/public'; + +export const getFiltersColumn = ({ + options, +}: { + options?: FiltersIndexPatternColumn['params']; +}): FiltersIndexPatternColumn => { + const { filters = [], ...params } = options ?? {}; + return { + label: `Filters`, + dataType: 'number', + operationType: 'filters', + scale: 'ordinal', + isBucketed: true, + params: { + filters, + ...params, + }, + }; +}; diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/formula.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/formula.ts new file mode 100644 index 0000000000000..1a28bc917cb83 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/formula.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FormulaPublicApi, PersistedIndexPatternLayer } from '@kbn/lens-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; + +type LensFormula = Parameters[1]; + +export type FormulaValueConfig = Omit & { + color?: string; + value: string; +}; +export function getFormulaColumn( + id: string, + config: FormulaValueConfig, + dataView: DataView, + formulaAPI: FormulaPublicApi, + baseLayer?: PersistedIndexPatternLayer +): PersistedIndexPatternLayer { + const { value, ...rest } = config; + const formulaLayer = formulaAPI.insertOrReplaceFormulaColumn( + id, + { formula: value, ...rest }, + baseLayer || { columnOrder: [], columns: {} }, + dataView + ); + + if (!formulaLayer) { + throw new Error('Error generating the data layer for the chart'); + } + + return formulaLayer; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/index.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/index.ts new file mode 100644 index 0000000000000..97a369b8fbd9c --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './date_histogram'; +export * from './formula'; +export * from './static'; +export * from './top_values'; +export * from './filters'; +export * from './intervals'; +export * from './breakdown'; +export * from './value'; diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/intervals.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/intervals.ts new file mode 100644 index 0000000000000..af171c4697454 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/intervals.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { RangeIndexPatternColumn } from '@kbn/lens-plugin/public'; + +export const getIntervalsColumn = ({ + field, + options, +}: { + field: string; + options: RangeIndexPatternColumn['params']; +}): RangeIndexPatternColumn => { + const { ranges = [], ...params } = options ?? {}; + return { + label: `Intervals of ${field}`, + dataType: 'number', + operationType: 'range', + scale: 'ordinal', + sourceField: field, + isBucketed: true, + params: { + ranges, + ...params, + }, + }; +}; diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/static.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/static.ts new file mode 100644 index 0000000000000..00d195f571e95 --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/static.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { PersistedIndexPatternLayer, FormulaPublicApi } from '@kbn/lens-plugin/public'; +import type { ReferenceBasedIndexPatternColumn } from '@kbn/lens-plugin/public/datasources/form_based/operations/definitions/column_types'; + +export type LensFormula = Parameters[1]; + +export type StaticValueConfig = Omit & { + color?: string; + value: string; +}; +export function getStaticColumn( + id: string, + baseLayer: PersistedIndexPatternLayer, + config: StaticValueConfig +): PersistedIndexPatternLayer { + const { label, ...params } = config; + return { + linkToLayers: [], + columnOrder: [...baseLayer.columnOrder, id], + columns: { + [id]: { + label: label ?? 'Reference', + dataType: 'number', + operationType: 'static_value', + isStaticValue: true, + isBucketed: false, + scale: 'ratio', + params, + references: [], + customLabel: true, + } as ReferenceBasedIndexPatternColumn, + }, + sampling: 1, + incompleteColumns: {}, + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/top_values.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/top_values.ts new file mode 100644 index 0000000000000..a39e045679d9e --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/top_values.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { TermsIndexPatternColumn } from '@kbn/lens-plugin/public'; +import { TopValuesColumnParams } from '../../attribute_builder/utils'; + +const DEFAULT_BREAKDOWN_SIZE = 10; + +export const getTopValuesColumn = ({ + field, + options, +}: { + field: string; + options?: Partial; +}): TermsIndexPatternColumn => { + const { size = DEFAULT_BREAKDOWN_SIZE, ...params } = options ?? {}; + return { + label: `Top ${size} values of ${field}`, + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: field, + isBucketed: true, + params: { + size, + orderBy: { + type: 'alphabetical', + fallback: false, + }, + orderDirection: 'asc', + otherBucket: false, + missingBucket: false, + parentFormat: { + id: 'terms', + }, + include: [], + exclude: [], + includeIsRegex: false, + excludeIsRegex: false, + ...params, + }, + }; +}; diff --git a/packages/kbn-lens-embeddable-utils/config_builder/columns/value.ts b/packages/kbn-lens-embeddable-utils/config_builder/columns/value.ts new file mode 100644 index 0000000000000..698d3a7714e6e --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/columns/value.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { TextBasedLayerColumn } from '@kbn/lens-plugin/public/datasources/text_based/types'; +import type { DatatableColumnType } from '@kbn/expressions-plugin/common'; + +export function getValueColumn( + id: string, + fieldName?: string, + type?: DatatableColumnType +): TextBasedLayerColumn { + return { + columnId: id, + fieldName: fieldName || id, + ...(type ? { meta: { type } } : {}), + }; +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/config_builder.ts b/packages/kbn-lens-embeddable-utils/config_builder/config_builder.ts new file mode 100644 index 0000000000000..d27d2b08abc1e --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/config_builder.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FormulaPublicApi, LensEmbeddableInput } from '@kbn/lens-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { v4 as uuidv4 } from 'uuid'; +import { LensAttributes, LensConfig, LensConfigOptions } from './types'; +import { + buildGauge, + buildHeatmap, + buildMetric, + buildRegionMap, + buildTagCloud, + buildTable, + buildXY, + buildPartitionChart, +} from './charts'; + +export class LensConfigBuilder { + private charts = { + metric: buildMetric, + tagcloud: buildTagCloud, + treemap: buildPartitionChart, + pie: buildPartitionChart, + donut: buildPartitionChart, + gauge: buildGauge, + heatmap: buildHeatmap, + mosaic: buildPartitionChart, + regionmap: buildRegionMap, + xy: buildXY, + table: buildTable, + }; + private formulaAPI: FormulaPublicApi; + private dataViewsAPI: DataViewsPublicPluginStart; + + constructor(formulaAPI: FormulaPublicApi, dataViewsAPI: DataViewsPublicPluginStart) { + this.formulaAPI = formulaAPI; + this.dataViewsAPI = dataViewsAPI; + } + + async build( + config: LensConfig, + options: LensConfigOptions = {} + ): Promise { + const { chartType } = config; + const chartConfig = await this.charts[chartType](config as any, { + formulaAPI: this.formulaAPI, + dataViewsAPI: this.dataViewsAPI, + }); + + const chartState = { + ...chartConfig, + state: { + ...chartConfig.state, + filters: options.filters || [], + query: options.query || { language: 'kuery', query: '' }, + }, + }; + + if (options.embeddable) { + return { + id: uuidv4(), + attributes: chartState, + timeRange: options.timeRange, + references: chartState.references, + } as LensEmbeddableInput; + } + + return chartState; + } +} diff --git a/packages/kbn-lens-embeddable-utils/config_builder/index.ts b/packages/kbn-lens-embeddable-utils/config_builder/index.ts new file mode 100644 index 0000000000000..420b9a649a5be --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './config_builder'; +export * from './types'; diff --git a/packages/kbn-lens-embeddable-utils/config_builder/types.ts b/packages/kbn-lens-embeddable-utils/config_builder/types.ts new file mode 100644 index 0000000000000..c537ee5bfe23f --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/types.ts @@ -0,0 +1,274 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { FormulaPublicApi, TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import type { Filter, Query } from '@kbn/es-query'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { Datatable } from '@kbn/expressions-plugin/common'; + +export type LensAttributes = TypedLensByValueInput['attributes']; +export const DEFAULT_LAYER_ID = 'layer_0'; + +type Identity = T extends object + ? { + [P in keyof T]: T[P]; + } + : T; + +export type ChartType = + | 'xy' + | 'pie' + | 'heatmap' + | 'metric' + | 'gauge' + | 'donut' + | 'mosaic' + | 'regionmap' + | 'table' + | 'tagcloud' + | 'treemap'; + +export interface TimeRange { + from: string; + to: string; + type: 'relative' | 'absolute'; +} + +export type LensLayerQuery = string; +export interface LensDataviewDataset { + index: string; + timeFieldName?: string; +} + +export type LensDatatableDataset = Datatable; + +export interface LensESQLDataset { + esql: string; +} + +export type LensDataset = LensDataviewDataset | LensDatatableDataset | LensESQLDataset; + +export interface LensBaseConfig { + title: string; + /** default data view id or index pattern to use, it can be overriden on each query */ + dataset?: LensDataset; +} + +export interface LensBaseLayer { + label?: string; + filter?: string; + format?: 'bytes' | 'currency' | 'duration' | 'number' | 'percent' | 'string'; + randomSampling?: number; + useGlobalFilter?: boolean; + seriesColor?: string; + dataset?: LensDataset; + value: LensLayerQuery; +} + +export type LensConfig = + | LensMetricConfig + | LensGaugeConfig + | LensPieConfig + | LensHeatmapConfig + | LensMosaicConfig + | LensRegionMapConfig + | LensTableConfig + | LensTagCloudConfig + | LensTreeMapConfig + | LensXYConfig; + +export interface LensConfigOptions { + /** if true the output will be embeddable input, else lens attributes */ + embeddable?: boolean; + /** optional time range override */ + timeRange?: TimeRange; + filters?: Filter[]; + query?: Query; +} + +export interface LensLegendConfig { + show?: boolean; + position?: 'top' | 'left' | 'bottom' | 'right'; +} + +export interface LensBreakdownDateHistogramConfig { + type: 'dateHistogram'; + field: string; + minimumInterval?: string; +} + +export interface LensBreakdownFiltersConfig { + type: 'filters'; + filters: Array<{ + label?: string; + filter: string; + }>; +} + +export interface LensBreakdownIntervalsConfig { + type: 'intervals'; + field: string; + granularity?: number; +} + +export interface LensBreakdownTopValuesConfig { + type: 'topValues'; + field: string; + size?: number; +} + +export type LensBreakdownConfig = + | string + | Identity< + ( + | LensBreakdownTopValuesConfig + | LensBreakdownIntervalsConfig + | LensBreakdownFiltersConfig + | LensBreakdownDateHistogramConfig + ) & { colorPalette?: string } + >; + +export interface LensMetricConfigBase { + chartType: 'metric'; + querySecondaryMetric?: LensLayerQuery; + queryMaxValue?: LensLayerQuery; + /** field name to apply breakdown based on field type or full breakdown configuration */ + breakdown?: LensBreakdownConfig; + trendLine?: boolean; +} + +export type LensMetricConfig = Identity; + +export interface LensGaugeConfigBase { + chartType: 'gauge'; + queryMinValue?: LensLayerQuery; + queryMaxValue?: LensLayerQuery; + queryGoalValue?: LensLayerQuery; + shape?: 'arc' | 'circle' | 'horizontalBullet' | 'verticalBullet'; +} + +export type LensGaugeConfig = Identity; + +export interface LensPieConfigBase { + chartType: 'pie' | 'donut'; + breakdown: LensBreakdownConfig[]; + legend?: Identity; +} + +export type LensPieConfig = Identity; + +export interface LensTreeMapConfigBase { + chartType: 'treemap'; + /** field name to apply breakdown based on field type or full breakdown configuration */ + breakdown: LensBreakdownConfig[]; +} + +export type LensTreeMapConfig = Identity; + +export interface LensTagCloudConfigBase { + chartType: 'tagcloud'; + /** field name to apply breakdown based on field type or full breakdown configuration */ + breakdown: LensBreakdownConfig; +} + +export type LensTagCloudConfig = Identity; +export interface LensRegionMapConfigBase { + chartType: 'regionmap'; + /** field name to apply breakdown based on field type or full breakdown configuration */ + breakdown: LensBreakdownConfig; +} + +export type LensRegionMapConfig = Identity< + LensBaseConfig & LensBaseLayer & LensRegionMapConfigBase +>; + +export interface LensMosaicConfigBase { + chartType: 'mosaic'; + /** field name to apply breakdown based on field type or full breakdown configuration */ + breakdown: LensBreakdownConfig; + /** field name to apply breakdown based on field type or full breakdown configuration */ + xAxis: LensBreakdownConfig; +} + +export type LensMosaicConfig = Identity; + +export interface LensTableConfigBase { + chartType: 'table'; + /** field name to breakdown based on field type or full breakdown configuration */ + splitBy?: LensBreakdownConfig[]; + /** field name to breakdown based on field type or full breakdown configuration */ + breakdown?: LensBreakdownConfig[]; +} + +export type LensTableConfig = Identity; + +export interface LensHeatmapConfigBase { + chartType: 'heatmap'; + /** field name to apply breakdown based on field type or full breakdown configuration */ + breakdown: LensBreakdownConfig; + xAxis: LensBreakdownConfig; + legend?: Identity; +} + +export type LensHeatmapConfig = Identity; + +export interface LensReferenceLineLayerBase { + type: 'reference'; + lineThickness?: number; + color?: string; + fill?: 'none' | 'above' | 'below'; + value?: number; +} + +export type LensReferenceLineLayer = LensReferenceLineLayerBase & LensBaseLayer; + +export interface LensAnnotationLayerBaseProps { + name: string; + color?: string; + icon?: string; +} + +export type LensAnnotationLayer = Identity< + LensBaseLayer & { + type: 'annotation'; + events: Array< + | Identity< + LensAnnotationLayerBaseProps & { + datetime: string; + } + > + | Identity< + LensAnnotationLayerBaseProps & { + field: string; + filter: string; + } + > + >; + } +>; + +export type LensSeriesLayer = Identity< + LensBaseLayer & { + type: 'series'; + breakdown?: LensBreakdownConfig; + xAxis: LensBreakdownConfig; + seriesType: 'line' | 'bar' | 'area'; + } +>; + +export interface LensXYConfigBase { + chartType: 'xy'; + layers: Array; + legend?: Identity; +} +export interface BuildDependencies { + dataViewsAPI: DataViewsPublicPluginStart; + formulaAPI: FormulaPublicApi; +} + +export type LensXYConfig = Identity; diff --git a/packages/kbn-lens-embeddable-utils/config_builder/utils.test.ts b/packages/kbn-lens-embeddable-utils/config_builder/utils.test.ts new file mode 100644 index 0000000000000..7709c6969b29f --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/utils.test.ts @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + getAdhocDataviews, + buildDatasourceStates, + buildReferences, + getDatasetIndex, + addLayerColumn, + isFormulaDataset, +} from './utils'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { + GenericIndexPatternColumn, + PersistedIndexPatternLayer, +} from '@kbn/lens-plugin/public'; + +const dataView = { + id: 'test-dataview', + fields: { + getByName: (name: string) => { + switch (name) { + case '@timestamp': + return 'datetime'; + case 'category': + return 'string'; + case 'price': + return 'number'; + default: + return 'string'; + } + }, + }, + toSpec: () => ({}), +}; + +describe('isFormulaDataset', () => { + test('isFormulaDataset returns true when dataset is based on index and timefield', () => { + const result = isFormulaDataset({ + index: 'test', + timeFieldName: 'test', + }); + expect(result).toEqual(true); + }); + + test('isFormulaDataset returns false when dataset is not based on index and timefield', () => { + const result = isFormulaDataset({ + esql: 'test', + }); + expect(result).toEqual(false); + + const result2 = isFormulaDataset({ + type: 'datatable', + columns: [], + rows: [], + }); + expect(result2).toEqual(false); + }); +}); + +test('build references correctly builds references', () => { + const results = buildReferences({ + layer1: dataView as unknown as DataView, + layer2: dataView as unknown as DataView, + }); + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "id": "test-dataview", + "name": "indexpattern-datasource-layer-layer1", + "type": "index-pattern", + }, + Object { + "id": "test-dataview", + "name": "indexpattern-datasource-layer-layer2", + "type": "index-pattern", + }, + ] + `); +}); + +test('getAdhocDataviews', () => { + const results = getAdhocDataviews({ + layer1: dataView as unknown as DataView, + layer2: dataView as unknown as DataView, + }); + expect(results).toMatchInlineSnapshot(` + Object { + "test-dataview": Object {}, + } + `); +}); + +describe('getDatasetIndex', () => { + test('returns index if provided', () => { + const result = getDatasetIndex({ + index: 'test', + timeFieldName: '@timestamp', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "index": "test", + "timeFieldName": "@timestamp", + } + `); + }); + + test('extracts index from esql query', () => { + const result = getDatasetIndex({ + esql: 'from test_index | limit 10', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "index": "test_index", + "timeFieldName": "@timestamp", + } + `); + }); + + test('returns undefined if no query or iundex provided', () => { + const result = getDatasetIndex({ + type: 'datatable', + columns: [], + rows: [], + }); + expect(result).toMatchInlineSnapshot(`undefined`); + }); +}); + +describe('addLayerColumn', () => { + test('adds column to the end', () => { + const layer = { + columns: [], + columnOrder: [], + } as unknown as PersistedIndexPatternLayer; + addLayerColumn(layer, 'first', { + test: 'test', + } as unknown as GenericIndexPatternColumn); + addLayerColumn(layer, 'second', { + test: 'test', + } as unknown as GenericIndexPatternColumn); + addLayerColumn( + layer, + 'before_first', + { + test: 'test', + } as unknown as GenericIndexPatternColumn, + true + ); + expect(layer).toMatchInlineSnapshot(` + Object { + "columnOrder": Array [ + "before_first", + "first", + "second", + ], + "columns": Object { + "before_first": Object { + "test": "test", + }, + "first": Object { + "test": "test", + }, + "second": Object { + "test": "test", + }, + }, + } + `); + }); +}); + +describe('buildDatasourceStates', () => { + test('correctly builds esql layer', async () => { + const results = await buildDatasourceStates( + { + title: 'test', + layers: [ + { + dataset: { + esql: 'from test | limit 10', + }, + label: 'test', + value: 'test', + }, + ], + }, + {}, + () => undefined, + () => [], + { + get: async () => ({ id: 'test' }), + } as any + ); + expect(results).toMatchInlineSnapshot(` + Object { + "formBased": Object { + "layers": Object {}, + }, + "textBased": Object { + "layers": Object { + "layer_0": Object { + "allColumns": Array [], + "columns": Array [], + "index": "test", + "query": Object { + "esql": "from test | limit 10", + }, + }, + }, + }, + } + `); + }); +}); diff --git a/packages/kbn-lens-embeddable-utils/config_builder/utils.ts b/packages/kbn-lens-embeddable-utils/config_builder/utils.ts new file mode 100644 index 0000000000000..21c48ca76a52b --- /dev/null +++ b/packages/kbn-lens-embeddable-utils/config_builder/utils.ts @@ -0,0 +1,270 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { SavedObjectReference } from '@kbn/core-saved-objects-common/src/server_types'; +import type { + DataViewSpec, + DataView, + DataViewsPublicPluginStart, +} from '@kbn/data-views-plugin/public'; +import type { + GenericIndexPatternColumn, + PersistedIndexPatternLayer, +} from '@kbn/lens-plugin/public'; +import type { + TextBasedLayerColumn, + TextBasedPersistedState, +} from '@kbn/lens-plugin/public/datasources/text_based/types'; +import { AggregateQuery, getIndexPatternFromESQLQuery } from '@kbn/es-query'; +import { + LensAnnotationLayer, + LensAttributes, + LensBaseConfig, + LensBaseLayer, + LensDataset, + LensDatatableDataset, + LensESQLDataset, +} from './types'; + +export const getDefaultReferences = ( + index: string, + dataLayerId: string +): SavedObjectReference[] => { + return [ + { + type: 'index-pattern', + id: index, + name: `indexpattern-datasource-layer-${dataLayerId}`, + }, + ]; +}; + +export function buildReferences(dataviews: Record) { + const references = []; + for (const layerid in dataviews) { + if (dataviews[layerid]) { + references.push(...getDefaultReferences(dataviews[layerid].id!, layerid)); + } + } + return references.flat(); +} + +const getAdhocDataView = (dataView: DataView): Record => { + return { + [dataView.id ?? uuidv4()]: { + ...dataView.toSpec(), + }, + }; +}; + +export const getAdhocDataviews = (dataviews: Record) => { + let adHocDataViews = {}; + [...new Set(Object.values(dataviews))].forEach((d) => { + adHocDataViews = { + ...adHocDataViews, + ...getAdhocDataView(d), + }; + }); + + return adHocDataViews; +}; + +export function isFormulaDataset(dataset?: LensDataset) { + if (dataset && 'index' in dataset) { + return true; + } + return false; +} + +/** + * it loads dataview by id or creates an ad-hoc dataview if index pattern is provided + * @param index + * @param dataViewsAPI + * @param timeField + */ +export async function getDataView( + index: string, + dataViewsAPI: DataViewsPublicPluginStart, + timeField?: string +) { + let dataView: DataView; + + try { + dataView = await dataViewsAPI.get(index, false); + } catch { + dataView = await dataViewsAPI.create({ + title: index, + timeFieldName: timeField || '@timestamp', + }); + } + + return dataView; +} + +export function getDatasetIndex(dataset?: LensDataset) { + if (!dataset) return undefined; + + let index: string; + let timeFieldName: string = '@timestamp'; + + if ('index' in dataset) { + index = dataset.index; + timeFieldName = dataset.timeFieldName || '@timestamp'; + } else if ('esql' in dataset) { + index = getIndexPatternFromESQLQuery(dataset.esql); // parseIndexFromQuery(config.dataset.query); + } else { + return undefined; + } + + return { index, timeFieldName }; +} + +function buildDatasourceStatesLayer( + layer: LensBaseLayer, + i: number, + dataset: LensDataset, + dataView: DataView | undefined, + buildFormulaLayers: ( + config: unknown, + i: number, + dataView: DataView + ) => PersistedIndexPatternLayer | undefined, + getValueColumns: (config: unknown, i: number) => TextBasedLayerColumn[] // ValueBasedLayerColumn[] +): [ + 'textBased' | 'formBased', + PersistedIndexPatternLayer | TextBasedPersistedState['layers'][0] | undefined +] { + function buildValueLayer(config: LensBaseLayer): TextBasedPersistedState['layers'][0] { + const table = dataset as LensDatatableDataset; + const newLayer = { + table, + columns: getValueColumns(layer, i), + allColumns: table.columns.map( + (column) => + ({ + fieldName: column.name, + columnId: column.id, + meta: column.meta, + } as TextBasedLayerColumn) + ), + index: '', + query: undefined, + }; + + return newLayer; + } + + function buildESQLLayer(config: LensBaseLayer): TextBasedPersistedState['layers'][0] { + const columns = getValueColumns(layer, i); + + const newLayer = { + index: dataView!.id!, + query: { esql: (dataset as LensESQLDataset).esql } as AggregateQuery, + columns, + allColumns: columns, + }; + + return newLayer; + } + + if ('esql' in dataset) { + return ['textBased', buildESQLLayer(layer)]; + } else if ('type' in dataset) { + return ['textBased', buildValueLayer(layer)]; + } + return ['formBased', buildFormulaLayers(layer, i, dataView!)]; +} +export const buildDatasourceStates = async ( + config: (LensBaseConfig & { layers: LensBaseLayer[] }) | (LensBaseLayer & LensBaseConfig), + dataviews: Record, + buildFormulaLayers: ( + config: unknown, + i: number, + dataView: DataView + ) => PersistedIndexPatternLayer | undefined, + getValueColumns: (config: any, i: number) => TextBasedLayerColumn[], + dataViewsAPI: DataViewsPublicPluginStart +) => { + const layers: LensAttributes['state']['datasourceStates'] = { + textBased: { layers: {} }, + formBased: { layers: {} }, + }; + + const mainDataset = config.dataset; + const configLayers = 'layers' in config ? config.layers : [config]; + for (let i = 0; i < configLayers.length; i++) { + const layer = configLayers[i]; + const layerId = `layer_${i}`; + const dataset = layer.dataset || mainDataset; + + if (!dataset && 'type' in layer && (layer as LensAnnotationLayer).type !== 'annotation') { + throw Error('dataset must be defined'); + } + + const index = getDatasetIndex(dataset); + const dataView = index + ? await getDataView(index.index, dataViewsAPI, index.timeFieldName) + : undefined; + + if (dataView) { + dataviews[layerId] = dataView; + } + + if (dataset) { + const [type, layerConfig] = buildDatasourceStatesLayer( + layer, + i, + dataset, + dataView, + buildFormulaLayers, + getValueColumns + ); + if (layerConfig) { + layers[type]!.layers[layerId] = layerConfig; + } + } + } + + return layers; +}; +export const addLayerColumn = ( + layer: PersistedIndexPatternLayer, + columnName: string, + config: GenericIndexPatternColumn, + first = false +) => { + layer.columns = { + ...layer.columns, + [columnName]: config, + }; + if (first) { + layer.columnOrder.unshift(columnName); + } else { + layer.columnOrder.push(columnName); + } +}; + +export const addLayerFormulaColumns = ( + layer: PersistedIndexPatternLayer, + columns: PersistedIndexPatternLayer, + postfix = '' +) => { + const altObj = Object.fromEntries( + Object.entries(columns.columns).map(([key, value]) => + // Modify key here + [`${key}${postfix}`, value] + ) + ); + + layer.columns = { + ...layer.columns, + ...altObj, + }; + layer.columnOrder.push(...columns.columnOrder.map((c) => `${c}${postfix}`)); +}; diff --git a/packages/kbn-lens-embeddable-utils/kibana.jsonc b/packages/kbn-lens-embeddable-utils/kibana.jsonc index 4f8fd44b1ea65..6889324eefad3 100644 --- a/packages/kbn-lens-embeddable-utils/kibana.jsonc +++ b/packages/kbn-lens-embeddable-utils/kibana.jsonc @@ -1,5 +1,5 @@ { "type": "shared-common", "id": "@kbn/lens-embeddable-utils", - "owner": "@elastic/obs-ux-infra_services-team" + "owner": ["@elastic/obs-ux-infra_services-team", "@elastic/kibana-visualizations"] } diff --git a/packages/kbn-lens-embeddable-utils/tsconfig.json b/packages/kbn-lens-embeddable-utils/tsconfig.json index 6238ba09d0374..9569d336b7766 100644 --- a/packages/kbn-lens-embeddable-utils/tsconfig.json +++ b/packages/kbn-lens-embeddable-utils/tsconfig.json @@ -11,6 +11,11 @@ "@kbn/data-plugin", "@kbn/data-views-plugin", "@kbn/lens-plugin", + "@kbn/maps-plugin", + "@kbn/event-annotation-common", + "@kbn/es-query", + "@kbn/expressions-plugin", "@kbn/visualizations-plugin", + "@kbn/core-saved-objects-common", ] } diff --git a/x-pack/plugins/lens/public/datasources/text_based/types.ts b/x-pack/plugins/lens/public/datasources/text_based/types.ts index 4d1f9dfea510f..67c652450366c 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/types.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/types.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { DatatableColumn } from '@kbn/expressions-plugin/public'; +import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/public'; import type { AggregateQuery } from '@kbn/es-query'; import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; import type { VisualizeEditorContext } from '../../types'; @@ -24,6 +24,7 @@ export interface TextBasedField { export interface TextBasedLayer { index: string; query: AggregateQuery | undefined; + table?: Datatable; columns: TextBasedLayerColumn[]; allColumns: TextBasedLayerColumn[]; timeField?: string; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index 9d2b409a678d3..c611952f44f0a 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -46,6 +46,7 @@ export type { DatatableVisualizationState } from './visualizations/datatable/vis export type { HeatmapVisualizationState } from './visualizations/heatmap/types'; export type { GaugeVisualizationState } from './visualizations/gauge/constants'; export type { MetricVisualizationState } from './visualizations/metric/types'; +export type { TagcloudState } from './visualizations/tagcloud/types'; export type { FormBasedPersistedState, PersistedIndexPatternLayer,