diff --git a/src/plugins/chart_expressions/expression_metric/common/constants.ts b/src/plugins/chart_expressions/expression_metric/common/constants.ts index 03e8852f8155a..d37ccd698dd03 100644 --- a/src/plugins/chart_expressions/expression_metric/common/constants.ts +++ b/src/plugins/chart_expressions/expression_metric/common/constants.ts @@ -7,6 +7,7 @@ */ export const EXPRESSION_METRIC_NAME = 'metricVis'; +export const EXPRESSION_METRIC_TRENDLINE_NAME = 'metricTrendline'; export const LabelPosition = { BOTTOM: 'bottom', diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_trendline_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_trendline_function.ts new file mode 100644 index 0000000000000..9fa49cd836639 --- /dev/null +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_trendline_function.ts @@ -0,0 +1,144 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +import { + validateAccessor, + getColumnByAccessor, + prepareLogTable, + Dimension, +} from '@kbn/visualizations-plugin/common/utils'; +import { DatatableRow } from '@kbn/expressions-plugin/common'; +import { MetricWTrend } from '@elastic/charts'; +import type { TrendlineExpressionFunctionDefinition } from '../types'; +import { EXPRESSION_METRIC_TRENDLINE_NAME } from '../constants'; + +export const metricTrendlineFunction = (): TrendlineExpressionFunctionDefinition => ({ + name: EXPRESSION_METRIC_TRENDLINE_NAME, + inputTypes: ['datatable'], + type: EXPRESSION_METRIC_TRENDLINE_NAME, + help: i18n.translate('expressionMetricTrendline.function.help', { + defaultMessage: 'Metric visualization', + }), + args: { + metric: { + types: ['vis_dimension', 'string'], + help: i18n.translate('expressionMetricTrendline.function.metric.help', { + defaultMessage: 'The primary metric.', + }), + required: true, + }, + timeField: { + types: ['vis_dimension', 'string'], + help: i18n.translate('expressionMetricTrendline.function.metric.help', { + defaultMessage: 'The time field for the trend line', + }), + required: true, + }, + breakdownBy: { + types: ['vis_dimension', 'string'], + help: i18n.translate('expressionMetricTrendline.function.breakdownBy.help', { + defaultMessage: 'The dimension containing the labels for sub-categories.', + }), + }, + table: { + types: ['datatable'], + help: i18n.translate('expressionMetricTrendline.function.table.help', { + defaultMessage: 'A data table', + }), + multi: false, + }, + inspectorTableId: { + types: ['string'], + help: i18n.translate('expressionMetricTrendline.function.inspectorTableId.help', { + defaultMessage: 'An ID for the inspector table', + }), + multi: false, + default: 'trendline', + }, + }, + fn(input, args, handlers) { + const table = args.table; + validateAccessor(args.metric, table.columns); + validateAccessor(args.timeField, table.columns); + validateAccessor(args.breakdownBy, table.columns); + + const argsTable: Dimension[] = [ + [ + [args.metric], + i18n.translate('expressionMetricVis.function.dimension.metric', { + defaultMessage: 'Metric', + }), + ], + [ + [args.timeField], + i18n.translate('expressionMetricVis.function.dimension.timeField', { + defaultMessage: 'Time field', + }), + ], + ]; + + if (args.breakdownBy) { + argsTable.push([ + [args.breakdownBy], + i18n.translate('expressionMetricVis.function.dimension.splitGroup', { + defaultMessage: 'Split group', + }), + ]); + } + + const inspectorTable = prepareLogTable(table, argsTable, true); + + const metricColId = getColumnByAccessor(args.metric, table.columns)?.id; + const timeColId = getColumnByAccessor(args.timeField, table.columns)?.id; + + if (!metricColId || !timeColId) { + throw new Error("Metric trendline - couldn't find metric or time column!"); + } + + const trends: Record = {}; + + if (!args.breakdownBy) { + trends.default = table.rows.map((row) => ({ + x: row[timeColId], + y: row[metricColId], + })); // TODO is the table ordered correctly? + } else { + const breakdownByColId = getColumnByAccessor(args.breakdownBy, table.columns)?.id; + + if (!breakdownByColId) { + throw new Error("Metric trendline - couldn't find breakdown column!"); + } + + const rowsByBreakdown: Record = {}; + table.rows.forEach((row) => { + const breakdownTerm = row[breakdownByColId]; + if (!(breakdownTerm in rowsByBreakdown)) { + rowsByBreakdown[breakdownTerm] = []; + } + rowsByBreakdown[breakdownTerm].push(row); + }); + + for (const breakdownTerm in rowsByBreakdown) { + if (!rowsByBreakdown.hasOwnProperty(breakdownTerm)) continue; + trends[breakdownTerm] = rowsByBreakdown[breakdownTerm].map((row) => ({ + x: row[timeColId], + y: row[metricColId], + })); + } + } + + return { + type: EXPRESSION_METRIC_TRENDLINE_NAME, + trends, + inspectorTable, + inspectorTableId: args.inspectorTableId, + }; + }, +}); diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index d01a2400e038a..831beb905bf3f 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts @@ -16,7 +16,7 @@ import { import { LayoutDirection } from '@elastic/charts'; import { visType } from '../types'; import { MetricVisExpressionFunctionDefinition } from '../types'; -import { EXPRESSION_METRIC_NAME } from '../constants'; +import { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from '../constants'; export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ name: EXPRESSION_METRIC_NAME, @@ -51,6 +51,12 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ defaultMessage: 'The dimension containing the labels for sub-categories.', }), }, + trendline: { + types: [EXPRESSION_METRIC_TRENDLINE_NAME], + help: i18n.translate('expressionMetricVis.function.trendline.help', { + defaultMessage: 'An optional trendline configuration', + }), + }, subtitle: { types: ['string'], help: i18n.translate('expressionMetricVis.function.subtitle.help', { @@ -98,6 +104,14 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ 'Specifies the minimum number of tiles in the metric grid regardless of the input data.', }), }, + inspectorTableId: { + types: ['string'], + help: i18n.translate('expressionMetricVis.function.inspectorTableId.help', { + defaultMessage: 'An ID for the inspector table', + }), + multi: false, + default: 'default', + }, }, fn(input, args, handlers) { validateAccessor(args.metric, input.columns); @@ -105,7 +119,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ validateAccessor(args.breakdownBy, input.columns); if (handlers?.inspectorAdapters?.tables) { - handlers.inspectorAdapters.tables.reset(); + // handlers.inspectorAdapters.tables.reset(); handlers.inspectorAdapters.tables.allowCsvExport = true; const argsTable: Dimension[] = [ @@ -145,7 +159,13 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ } const logTable = prepareLogTable(input, argsTable, true); - handlers.inspectorAdapters.tables.logDatatable('default', logTable); + handlers.inspectorAdapters.tables.logDatatable(args.inspectorTableId, logTable); + if (args.trendline) { + handlers.inspectorAdapters.tables.logDatatable( + args.trendline.inspectorTableId, + args.trendline.inspectorTable + ); + } } return { @@ -163,6 +183,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ progressDirection: args.progressDirection, maxCols: args.maxCols, minTiles: args.minTiles, + trends: args.trendline?.trends, }, dimensions: { metric: args.metric, diff --git a/src/plugins/chart_expressions/expression_metric/common/index.ts b/src/plugins/chart_expressions/expression_metric/common/index.ts index ee023dca2f4ff..163c153efa9ee 100755 --- a/src/plugins/chart_expressions/expression_metric/common/index.ts +++ b/src/plugins/chart_expressions/expression_metric/common/index.ts @@ -22,4 +22,4 @@ export type { export { metricVisFunction } from './expression_functions'; -export { EXPRESSION_METRIC_NAME } from './constants'; +export { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from './constants'; diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts index d44b34fa736d1..9aa67b0df2ee5 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_functions.ts @@ -7,22 +7,23 @@ */ import type { PaletteOutput } from '@kbn/coloring'; -import { LayoutDirection } from '@elastic/charts'; +import { LayoutDirection, MetricWTrend } from '@elastic/charts'; import { Datatable, ExpressionFunctionDefinition, ExpressionValueRender, } from '@kbn/expressions-plugin/common'; -import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { ExpressionValueVisDimension, prepareLogTable } from '@kbn/visualizations-plugin/common'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; import { VisParams, visType } from './expression_renderers'; -import { EXPRESSION_METRIC_NAME } from '../constants'; +import { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from '../constants'; export interface MetricArguments { metric: ExpressionValueVisDimension | string; secondaryMetric?: ExpressionValueVisDimension | string; max?: ExpressionValueVisDimension | string; breakdownBy?: ExpressionValueVisDimension | string; + trendline?: TrendlineResult; subtitle?: string; secondaryPrefix?: string; progressDirection: LayoutDirection; @@ -30,6 +31,7 @@ export interface MetricArguments { palette?: PaletteOutput; maxCols: number; minTiles?: number; + inspectorTableId: string; } export type MetricInput = Datatable; @@ -46,3 +48,25 @@ export type MetricVisExpressionFunctionDefinition = ExpressionFunctionDefinition MetricArguments, ExpressionValueRender >; + +export interface TrendlineArguments { + metric: ExpressionValueVisDimension | string; + timeField: ExpressionValueVisDimension | string; + breakdownBy?: ExpressionValueVisDimension | string; + table: Datatable; + inspectorTableId: string; +} + +export interface TrendlineResult { + type: typeof EXPRESSION_METRIC_TRENDLINE_NAME; + trends: Record; + inspectorTable: ReturnType; + inspectorTableId: string; +} + +export type TrendlineExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof EXPRESSION_METRIC_TRENDLINE_NAME, + Datatable, + TrendlineArguments, + TrendlineResult +>; diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts index 75144d1cf5525..48b4b4ce0f524 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts @@ -9,6 +9,7 @@ import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; import { LayoutDirection } from '@elastic/charts'; +import { TrendlineResult } from './expression_functions'; export const visType = 'metric'; @@ -27,6 +28,7 @@ export interface MetricVisParam { progressDirection: LayoutDirection; maxCols: number; minTiles?: number; + trends?: TrendlineResult['trends']; } export interface VisParams { diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx index 94fd86ea43daa..b9f9fe6346261 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx @@ -18,13 +18,14 @@ import { isMetricElementEvent, RenderChangeListener, Settings, + MetricBase, + MetricWTrend, } from '@elastic/charts'; import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import type { Datatable, DatatableColumn, - DatatableRow, IInterpreterRenderHandlers, RenderMode, } from '@kbn/expressions-plugin/common'; @@ -223,17 +224,9 @@ export const MetricVis = ({ .getConverterFor('text'); } - let getProgressBarConfig = (_row: DatatableRow): Partial => ({}); - const maxColId = config.dimensions.max ? getColumnByAccessor(config.dimensions.max, data.columns)?.id : undefined; - if (maxColId) { - getProgressBarConfig = (_row: DatatableRow): Partial => ({ - domainMax: _row[maxColId], - progressBarDirection: config.metric.progressDirection, - }); - } const metricConfigs: MetricSpec['data'][number] = ( breakdownByColumn ? data.rows : data.rows.slice(0, 1) @@ -243,7 +236,7 @@ export const MetricVis = ({ ? formatBreakdownValue(row[breakdownByColumn.id]) : primaryMetricColumn.name; const subtitle = breakdownByColumn ? primaryMetricColumn.name : config.metric.subtitle; - return { + const baseMetric: MetricBase = { value, valueFormatter: formatPrimaryMetric, title, @@ -272,8 +265,29 @@ export const MetricVis = ({ rowIdx ) ?? defaultColor : config.metric.color ?? defaultColor, - ...getProgressBarConfig(row), }; + + const trendId = breakdownByColumn ? row[breakdownByColumn.id] : 'default'; + if (config.metric.trends && config.metric.trends[trendId]) { + const metricWTrend: MetricWTrend = { + ...baseMetric, + trend: config.metric.trends[trendId], + }; + + return metricWTrend; + } + + if (maxColId && config.metric.progressDirection) { + const metricWProgress: MetricWProgress = { + ...baseMetric, + domainMax: row[maxColId], + progressBarDirection: config.metric.progressDirection, + }; + + return metricWProgress; + } + + return baseMetric; }); if (config.metric.minTiles) { diff --git a/src/plugins/chart_expressions/expression_metric/public/index.ts b/src/plugins/chart_expressions/expression_metric/public/index.ts index c8d5d080bd4e6..765c0738924b3 100644 --- a/src/plugins/chart_expressions/expression_metric/public/index.ts +++ b/src/plugins/chart_expressions/expression_metric/public/index.ts @@ -13,3 +13,4 @@ export function plugin() { } export { getDataBoundsForPalette } from './utils'; +export { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from '../common'; diff --git a/src/plugins/chart_expressions/expression_metric/public/plugin.ts b/src/plugins/chart_expressions/expression_metric/public/plugin.ts index f1820ee8d36de..4b13497596754 100644 --- a/src/plugins/chart_expressions/expression_metric/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_metric/public/plugin.ts @@ -17,6 +17,7 @@ import { setFormatService, setPaletteService } from './services'; import { getMetricVisRenderer } from './expression_renderers'; import { setThemeService } from './services/theme_service'; import { setUiSettingsService } from './services/ui_settings'; +import { metricTrendlineFunction } from '../common/expression_functions/metric_trendline_function'; /** @internal */ export interface ExpressionMetricPluginSetup { @@ -45,6 +46,7 @@ export class ExpressionMetricPlugin implements Plugin { }); expressions.registerFunction(metricVisFunction); + expressions.registerFunction(metricTrendlineFunction); expressions.registerRenderer(getMetricVisRenderer({ getStartDeps })); setUiSettingsService(core.uiSettings); diff --git a/src/plugins/chart_expressions/expression_xy/common/constants.ts b/src/plugins/chart_expressions/expression_xy/common/constants.ts index 5d9d7fb70d478..c2c4e0e2e44c4 100644 --- a/src/plugins/chart_expressions/expression_xy/common/constants.ts +++ b/src/plugins/chart_expressions/expression_xy/common/constants.ts @@ -27,6 +27,7 @@ export const LayerTypes = { DATA: 'data', REFERENCELINE: 'referenceLine', ANNOTATIONS: 'annotations', + METRIC_TRENDLINE: 'metricTrendline', } as const; export const FittingFunctions = { diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 65cd5cdc73a9a..a6eaff6a599c5 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -46,6 +46,7 @@ export const layerTypes = { DATA: 'data', REFERENCELINE: 'referenceLine', ANNOTATIONS: 'annotations', + METRIC_TRENDLINE: 'metricTrendline', } as const; // might collide with user-supplied field names, try to make as unique as possible diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx index c38a9a3493708..6947bd7e7a34c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx @@ -44,10 +44,12 @@ export function AddLayerButton({ if (!visualization.appendLayer || !visualizationState) { return null; } - return visualization.getSupportedLayers?.(visualizationState, layersMeta); + return visualization + .getSupportedLayers?.(visualizationState, layersMeta) + ?.filter(({ hideFromMenu }) => !hideFromMenu); }, [visualization, visualizationState, layersMeta]); - if (supportedLayers == null) { + if (supportedLayers == null || !supportedLayers.length) { return null; } if (supportedLayers.length === 1) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index ce4fbbba70236..8dd6c6a5d13f4 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -12,6 +12,8 @@ import { UPDATE_FILTER_REFERENCES_ACTION, UPDATE_FILTER_REFERENCES_TRIGGER, } from '@kbn/unified-search-plugin/public'; +import { LayerType } from '../../../../common'; +import { removeDimension } from '../../../state_management/lens_slice'; import { Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; import { generateId } from '../../../id_generator'; @@ -21,13 +23,14 @@ import { setLayerDefaultDimension, useLensDispatch, removeOrClearLayer, - addLayer, + addLayer as addLayerAction, updateState, updateDatasourceState, updateVisualizationState, setToggleFullscreen, useLensSelector, selectVisualization, + syncLinkedDimensions, } from '../../../state_management'; import { AddLayerButton } from './add_layer'; import { getRemoveOperation } from '../../../utils'; @@ -56,12 +59,12 @@ export function LayerPanels( const dispatchLens = useLensDispatch(); - const layerIds = activeVisualization.getLayerIds(visualization.state); + const layerInfos = activeVisualization.getLayersInUse(visualization.state); const { setNextFocusedId: setNextFocusedLayerId, removeRef: removeLayerRef, registerNewRef: registerNewLayerRef, - } = useFocusUpdate(layerIds); + } = useFocusUpdate(layerInfos.map(({ id }) => id)); const setVisualizationState = useMemo( () => (newState: unknown) => { @@ -134,6 +137,7 @@ export function LayerPanels( }, }) ); + dispatchLens(syncLinkedDimensions()); }, 0); }, [dispatchLens] @@ -146,83 +150,97 @@ export function LayerPanels( [dispatchLens] ); + const addLayer = (layerType: LayerType) => { + const layerId = generateId(); + dispatchLens(addLayerAction({ layerId, layerType })); + setNextFocusedLayerId(layerId); + }; + return ( - {layerIds.map((layerId, layerIndex) => ( - { - // avoid state update if the datasource does not support initializeDimension - if ( - activeDatasourceId != null && - datasourceMap[activeDatasourceId]?.initializeDimension - ) { - dispatchLens( - setLayerDefaultDimension({ + {layerInfos.map( + ({ id: layerId, hidden }, layerIndex) => + !hidden && ( + addLayer(layerType)} + isOnlyLayer={ + getRemoveOperation( + activeVisualization, + visualization.state, layerId, - columnId, - groupId, - }) - ); - } - }} - onRemoveLayer={() => { - const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerId]; - const datasourceId = datasourcePublicAPI?.datasourceId; - const layerDatasource = datasourceMap[datasourceId]; - const layerDatasourceState = datasourceStates?.[datasourceId]?.state; - - const trigger = props.uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER); - const action = props.uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION); - - action?.execute({ - trigger, - fromDataView: layerDatasource.getUsedDataView(layerDatasourceState, layerId), - usedDataViews: layerDatasource - .getLayers(layerDatasourceState) - .map((layer) => layerDatasource.getUsedDataView(layerDatasourceState, layer)), - defaultDataView: layerDatasource.getCurrentIndexPatternId(layerDatasourceState), - } as ActionExecutionContext); - - dispatchLens( - removeOrClearLayer({ - visualizationId: activeVisualization.id, - layerId, - layerIds, - }) - ); - removeLayerRef(layerId); - }} - toggleFullscreen={toggleFullscreen} - /> - ))} + layerInfos.length + ) === 'clear' + } + onEmptyDimensionAdd={(columnId, { groupId }) => { + // avoid state update if the datasource does not support initializeDimension + if ( + activeDatasourceId != null && + datasourceMap[activeDatasourceId]?.initializeDimension + ) { + dispatchLens( + setLayerDefaultDimension({ + layerId, + columnId, + groupId, + }) + ); + } + }} + onRemoveDimension={(dimensionProps) => { + const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerId]; + const datasourceId = datasourcePublicAPI?.datasourceId; + dispatchLens(removeDimension({ ...dimensionProps, datasourceId })); + }} + onRemoveLayer={(layerToRemove: string) => { + const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerToRemove]; + const datasourceId = datasourcePublicAPI?.datasourceId; + const layerDatasource = datasourceMap[datasourceId]; + const layerDatasourceState = datasourceStates?.[datasourceId]?.state; + + const trigger = props.uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER); + const action = props.uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION); + + action?.execute({ + trigger, + fromDataView: layerDatasource.getUsedDataView( + layerDatasourceState, + layerToRemove + ), + usedDataViews: layerDatasource + .getLayers(layerDatasourceState) + .map((layer) => layerDatasource.getUsedDataView(layerDatasourceState, layer)), + defaultDataView: layerDatasource.getCurrentIndexPatternId(layerDatasourceState), + } as ActionExecutionContext); + + dispatchLens( + removeOrClearLayer({ + visualizationId: activeVisualization.id, + layerId: layerToRemove, + layerIds: layerInfos.map(({ id }) => id), + }) + ); + removeLayerRef(layerToRemove); + }} + toggleFullscreen={toggleFullscreen} + /> + ) + )} { - const layerId = generateId(); - dispatchLens(addLayer({ layerId, layerType })); - setNextFocusedLayerId(layerId); - }} + onAddLayerClick={(layerType) => addLayer(layerType)} /> ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 49a2bec8cda7c..edf917439fb77 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -126,7 +126,7 @@ describe('LayerPanel', () => { ], }; - mockVisualization.getLayerIds.mockReturnValue(['first']); + mockVisualization.getLayersInUse.mockReturnValue([{ id: 'first' }]); mockDatasource = createMockDatasource('testDatasource'); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 238111cd8d947..2f9c011cdf611 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -8,16 +8,9 @@ import './layer_panel.scss'; import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { - EuiPanel, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiText, - EuiIconTip, -} from '@elastic/eui'; +import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiText, EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { LayerType } from '../../../../common'; import { NativeRenderer } from '../../../native_renderer'; import { StateSetter, @@ -56,12 +49,14 @@ export function LayerPanel( updateVisualization: StateSetter; updateDatasource: (datasourceId: string, newState: unknown) => void; updateDatasourceAsync: (datasourceId: string, newState: unknown) => void; + addLayer: (layerType: LayerType) => void; updateAll: ( datasourceId: string, newDatasourcestate: unknown, newVisualizationState: unknown ) => void; - onRemoveLayer: () => void; + onRemoveLayer: (layerId: string) => void; + onRemoveDimension: (props: { columnId: string; layerId: string }) => void; registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void; toggleFullscreen: () => void; onEmptyDimensionAdd: (columnId: string, group: { groupId: string }) => void; @@ -246,6 +241,8 @@ export function LayerPanel( // The datasource can indicate that the previously-valid column is no longer // complete, which clears the visualization. This keeps the flyout open and reuses // the previous columnId + + // TODO - handle this in redux and cover the linked dimensions case updateAll( datasourceId, newState, @@ -320,7 +317,6 @@ export function LayerPanel( {layerDatasource && ( <> - {groups.map((group, groupIndex) => { + if (group.hidden) { + return; + } + let isMissing = false; if (!isEmptyLayer) { @@ -455,31 +455,7 @@ export function LayerPanel( }); }} onRemoveClick={(id: string) => { - if (datasourceId && layerDatasource) { - props.updateAll( - datasourceId, - layerDatasource.removeColumn({ - layerId, - columnId: id, - prevState: layerDatasourceState, - }), - activeVisualization.removeDimension({ - layerId, - columnId: id, - prevState: props.visualizationState, - frame: framePublicAPI, - }) - ); - } else { - props.updateVisualization( - activeVisualization.removeDimension({ - layerId, - columnId: id, - prevState: props.visualizationState, - frame: framePublicAPI, - }) - ); - } + props.onRemoveDimension({ columnId: id, layerId }); removeButtonRef(id); }} invalid={ @@ -621,6 +597,8 @@ export function LayerPanel( accessor: activeId, setState: props.updateVisualization, panelRef, + addLayer: props.addLayer, + removeLayer: props.onRemoveLayer, }} /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 047d8b4deffaa..b75dca1d3d181 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -123,8 +123,8 @@ describe('editor_frame', () => { ], }; - mockVisualization.getLayerIds.mockReturnValue(['first']); - mockVisualization2.getLayerIds.mockReturnValue(['second']); + mockVisualization.getLayersInUse.mockReturnValue(['first']); + mockVisualization2.getLayersInUse.mockReturnValue(['second']); mockDatasource = createMockDatasource('testDatasource'); mockDatasource2 = createMockDatasource('testDatasource2'); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts index b2c24cd5bf438..ca82c2e2affc1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -170,34 +170,28 @@ function onMoveCompatible( shouldDeleteSource, }); - if (target.layerId === source.layerId) { - const updatedColumnOrder = reorderByGroups( - dimensionGroups, - getColumnOrder(modifiedLayers[target.layerId]), - target.groupId, - target.columnId - ); - - const newLayer = { - ...modifiedLayers[target.layerId], - columnOrder: updatedColumnOrder, - columns: modifiedLayers[target.layerId].columns, - }; + const updatedColumnOrder = reorderByGroups( + dimensionGroups, + getColumnOrder(modifiedLayers[target.layerId]), + target.groupId, + target.columnId + ); - // Time to replace - setState( - mergeLayer({ - state, - layerId: target.layerId, - newLayer, - }) - ); - return true; - } else { - setState(mergeLayers({ state, newLayers: modifiedLayers })); + const newLayer = { + ...modifiedLayers[target.layerId], + columnOrder: updatedColumnOrder, + columns: modifiedLayers[target.layerId].columns, + }; - return true; - } + // Time to replace + setState( + mergeLayer({ + state, + layerId: target.layerId, + newLayer, + }) + ); + return true; } function onReorder({ @@ -390,6 +384,8 @@ function onSwapCompatible({ dimensionGroups, target, }: DropHandlerProps) { + // TODO - consider passing the target layer's dimension groups + // and remove this gating if (target.layerId === source.layerId) { const layer = state.layers[target.layerId]; const newColumns = { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index b1f02328242db..7649658e355b9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -72,6 +72,7 @@ import { IndexPatternPrivateState, IndexPatternPersistedState, IndexPattern, + IndexPatternLayer, } from './types'; import { mergeLayer } from './state_helpers'; import { Datasource, StateSetter, VisualizeEditorContext } from '../types'; @@ -190,12 +191,12 @@ export function getIndexPatternDatasource({ return state.currentIndexPatternId; }, - insertLayer(state: IndexPatternPrivateState, newLayerId: string) { + insertLayer(state: IndexPatternPrivateState, newLayerId: string, linkToLayerId?: string) { return { ...state, layers: { ...state.layers, - [newLayerId]: blankLayer(state.currentIndexPatternId), + [newLayerId]: blankLayer(state.currentIndexPatternId, linkToLayerId), }, }; }, @@ -237,26 +238,48 @@ export function getIndexPatternDatasource({ }); }, - initializeDimension(state, layerId, { columnId, groupId, staticValue }) { + initializeDimension( + state, + layerId, + { columnId, groupId, staticValue, autoTimeField, visualizationGroups } + ) { const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId]; - if (staticValue == null) { - return state; + let ret = state; + + if (staticValue) { + ret = mergeLayer({ + state, + layerId, + newLayer: insertNewColumn({ + layer: state.layers[layerId], + op: 'static_value', + columnId, + field: undefined, + indexPattern, + visualizationGroups, + initialParams: { params: { value: staticValue } }, + targetGroup: groupId, + }), + }); } - return mergeLayer({ - state, - layerId, - newLayer: insertNewColumn({ - layer: state.layers[layerId], - op: 'static_value', - columnId, - field: undefined, - indexPattern, - visualizationGroups: [], - initialParams: { params: { value: staticValue } }, - targetGroup: groupId, - }), - }); + if (autoTimeField && indexPattern.timeFieldName) { + ret = mergeLayer({ + state, + layerId, + newLayer: insertNewColumn({ + layer: state.layers[layerId], + op: 'date_histogram', + columnId, + field: indexPattern.fields.find((field) => field.name === indexPattern.timeFieldName), + indexPattern, + visualizationGroups, + targetGroup: groupId, + }), + }); + } + + return ret; }, toExpression: (state, layerId) => toExpression(state, layerId, uiSettings), @@ -400,12 +423,18 @@ export function getIndexPatternDatasource({ render( { - changeLayerIndexPattern({ + onChangeIndexPattern={async (indexPatternId) => { + const layersToChange = [ + props.layerId, + ...Object.entries(props.state.layers) + .map(([layerId, layer]) => (layer.linkToLayer === props.layerId ? layerId : '')) + .filter(Boolean), + ]; + await changeLayerIndexPattern({ indexPatternId, setState: props.setState, state: props.state, - layerId: props.layerId, + layerIds: layersToChange, onError: onIndexPatternLoadError, replaceIfPossible: true, storage, @@ -712,9 +741,10 @@ export function getIndexPatternDatasource({ return indexPatternDatasource; } -function blankLayer(indexPatternId: string) { +function blankLayer(indexPatternId: string, linkToLayerId?: string): IndexPatternLayer { return { indexPatternId, + linkToLayer: linkToLayerId, columns: {}, columnOrder: [], }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx index 4fd7b920e124b..3bd21efa5b4bf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { EuiSpacer } from '@elastic/eui'; import { DatasourceLayerPanelProps } from '../types'; import { IndexPatternPrivateState } from './types'; import { ChangeIndexPattern } from './change_indexpattern'; @@ -25,8 +26,9 @@ export function LayerPanel({ state, layerId, onChangeIndexPattern }: IndexPatter const notFoundTitleLabel = i18n.translate('xpack.lens.layerPanel.missingDataView', { defaultMessage: 'Data view not found', }); - return ( + return Boolean(layer.linkToLayer) ? null : ( + ({ - ...s, - layers: { + setState((s) => { + const newLayers = { ...s.layers, - [layerId]: updateLayerIndexPattern(s.layers[layerId], indexPatterns[indexPatternId]), - }, - indexPatterns: { - ...s.indexPatterns, - [indexPatternId]: indexPatterns[indexPatternId], - }, - currentIndexPatternId: replaceIfPossible ? indexPatternId : s.currentIndexPatternId, - })); + }; + + layerIds.forEach((layerId) => { + newLayers[layerId] = updateLayerIndexPattern( + s.layers[layerId], + indexPatterns[indexPatternId] + ); + }); + + return { + ...s, + layers: newLayers, + indexPatterns: { + ...s.indexPatterns, + [indexPatternId]: indexPatterns[indexPatternId], + }, + currentIndexPatternId: replaceIfPossible ? indexPatternId : s.currentIndexPatternId, + }; + }); setLastUsedIndexPatternId(storage, indexPatternId); } catch (err) { onError(err); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 31aa7e3214d73..110f4c557feae 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -73,6 +73,8 @@ export interface IndexPatternLayer { columns: Record; // Each layer is tied to the index pattern that created it indexPatternId: string; + // Layers can be linked to other layers so the their dataviews are kept in sync + linkToLayer?: string; // Partial columns represent the temporary invalid states incompleteColumns?: Record; } diff --git a/x-pack/plugins/lens/public/mocks/visualization_mock.ts b/x-pack/plugins/lens/public/mocks/visualization_mock.ts index f002af43f88c8..dcc279272bf5f 100644 --- a/x-pack/plugins/lens/public/mocks/visualization_mock.ts +++ b/x-pack/plugins/lens/public/mocks/visualization_mock.ts @@ -13,7 +13,7 @@ export function createMockVisualization(id = 'testVis'): jest.Mocked state), removeLayer: jest.fn(), - getLayerIds: jest.fn((_state) => ['layer1']), + getLayersInUse: jest.fn((_state) => [{ id: 'layer1' }]), getSupportedLayers: jest.fn(() => [{ type: layerTypes.DATA, label: 'Data Layer' }]), getLayerType: jest.fn((_state, _layerId) => layerTypes.DATA), visualizationTypes: [ diff --git a/x-pack/plugins/lens/public/state_management/index.ts b/x-pack/plugins/lens/public/state_management/index.ts index 7b9c345ff89f6..7bd9c33807a64 100644 --- a/x-pack/plugins/lens/public/state_management/index.ts +++ b/x-pack/plugins/lens/public/state_management/index.ts @@ -40,6 +40,8 @@ export const { removeOrClearLayer, addLayer, setLayerDefaultDimension, + syncLinkedDimensions, + removeDimension, } = lensActions; export const makeConfigureStore = ( diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 2a71cd9aaab48..222925cd3fa0a 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -13,7 +13,7 @@ import { History } from 'history'; import { LensEmbeddableInput } from '..'; import { getDatasourceLayers } from '../editor_frame_service/editor_frame'; import { TableInspectorAdapter } from '../editor_frame_service/types'; -import type { VisualizeEditorContext, Suggestion } from '../types'; +import type { VisualizeEditorContext, Suggestion, VisualizationMap, DatasourceMap } from '../types'; import { getInitialDatasourceId, getResolvedDateRange, getRemoveOperation } from '../utils'; import { LensAppState, LensStoreDeps, VisualizationState } from './types'; import { Datasource, Visualization } from '../types'; @@ -22,6 +22,8 @@ import type { LayerType } from '../../common/types'; import { getLayerType } from '../editor_frame_service/editor_frame/config_panel/add_layer'; import { getVisualizeFieldSuggestions } from '../editor_frame_service/editor_frame/suggestion_helpers'; import { FramePublicAPI, LensEditContextMapping, LensEditEvent } from '../types'; +import { selectFramePublicAPI } from './selectors'; +import { onDropForVisualization } from '../editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils'; export const initialState: LensAppState = { persistedDoc: undefined, @@ -163,6 +165,14 @@ export const setLayerDefaultDimension = createAction<{ groupId: string; }>('lens/setLayerDefaultDimension'); +export const syncLinkedDimensions = createAction('lens/syncLinkedDimensions'); + +export const removeDimension = createAction<{ + layerId: string; + columnId: string; + datasourceId?: string; +}>('lens/removeDimension'); + export const lensActions = { setState, onActiveDataChange, @@ -188,6 +198,8 @@ export const lensActions = { removeOrClearLayer, addLayer, setLayerDefaultDimension, + syncLinkedDimensions, + removeDimension, }; export const makeLensReducer = (storeDeps: LensStoreDeps) => { @@ -294,7 +306,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { }; } ) => { - return { + const newAppState = { ...state, datasourceStates: { ...state.datasourceStates, @@ -308,6 +320,31 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { }, stagedPreview: payload.clearStagedPreview ? undefined : state.stagedPreview, }; + + const { + datasourceState: syncedDatasourceState, + visualizationState: syncedVisualizationState, + } = syncLinkedDimensionsFunction( + newAppState, + payload.datasourceId, + visualizationMap, + datasourceMap + ); + + return { + ...newAppState, + visualization: { + ...newAppState.visualization, + state: syncedVisualizationState, + }, + datasourceStates: { + ...newAppState.datasourceStates, + [payload.datasourceId]: { + state: syncedDatasourceState, + isLoading: false, + }, + }, + }; }, [updateVisualizationState.type]: ( state, @@ -329,13 +366,26 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { if (state.visualization.activeId !== payload.visualizationId) { return state; } - return { - ...state, - visualization: { - ...state.visualization, - state: payload.newState, - }, - }; + + state.visualization.state = payload.newState; + + if (!state.activeDatasourceId) { + return; + } + + // TODO - consolidate into applySyncLinkedDimensions + const { + datasourceState: syncedDatasourceState, + visualizationState: syncedVisualizationState, + } = syncLinkedDimensionsFunction( + current(state), + state.activeDatasourceId, + visualizationMap, + datasourceMap + ); + + state.datasourceStates[state.activeDatasourceId].state = syncedDatasourceState; + state.visualization.state = syncedVisualizationState; }, [switchVisualization.type]: ( @@ -628,6 +678,8 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { layerType ); + const linkedLayers = activeVisualization.getLinkedLayers?.(visualizationState, layerId) ?? []; + const framePublicAPI = { // any better idea to avoid `as`? activeData: state.activeData @@ -647,7 +699,8 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { !noDatasource && activeDatasource ? activeDatasource.insertLayer( state.datasourceStates[state.activeDatasourceId].state, - layerId + layerId, + linkedLayers[0] // TODO - support multiple? ) : state.datasourceStates[state.activeDatasourceId].state; @@ -661,9 +714,22 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { layerType, }); - state.visualization.state = activeVisualizationState; state.datasourceStates[state.activeDatasourceId].state = activeDatasourceState; + state.visualization.state = activeVisualizationState; state.stagedPreview = undefined; + + const { + datasourceState: syncedDatasourceState, + visualizationState: syncedVisualizationState, + } = syncLinkedDimensionsFunction( + current(state), + state.activeDatasourceId, + visualizationMap, + datasourceMap + ); + + state.datasourceStates[state.activeDatasourceId].state = syncedDatasourceState; + state.visualization.state = syncedVisualizationState; }, [setLayerDefaultDimension.type]: ( state, @@ -706,6 +772,75 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { state.visualization.state = activeVisualizationState; state.datasourceStates[state.activeDatasourceId].state = activeDatasourceState; }, + [syncLinkedDimensions.type]: (state) => { + if (!state.activeDatasourceId || !state.visualization.activeId) { + return state; + } + const { + datasourceState: syncedDatasourceState, + visualizationState: syncedVisualizationState, + } = syncLinkedDimensionsFunction( + current(state), + state.activeDatasourceId, + visualizationMap, + datasourceMap + ); + + state.datasourceStates[state.activeDatasourceId].state = syncedDatasourceState; + state.visualization.state = syncedVisualizationState; + }, + [removeDimension.type]: ( + state, + { + payload: { layerId, columnId, datasourceId }, + }: { + payload: { + layerId: string; + columnId: string; + datasourceId?: string; + }; + } + ) => { + if (!state.visualization.activeId) { + return state; + } + + const activeVisualization = visualizationMap[state.visualization.activeId]; + + // TODO - should this logic be part of syncLinkedDimensionsFunction? + const links = activeVisualization.getLinkedDimensions?.(state.visualization.state); + + const linkedDimension = links?.find( + ({ from: { columnId: fromId } }) => columnId === fromId + )?.to; + + const datasource = datasourceId ? datasourceMap[datasourceId] : undefined; + + const frame = selectFramePublicAPI({ lens: state }, datasourceMap); + + const remove = (dimensionProps: { layerId: string; columnId: string }) => { + if (datasource && datasourceId) { + state.datasourceStates[datasourceId].state = datasource?.removeColumn({ + layerId: dimensionProps.layerId, + columnId: dimensionProps.columnId, + prevState: state.datasourceStates[datasourceId].state, + }); + } + + state.visualization.state = activeVisualization.removeDimension({ + layerId: dimensionProps.layerId, + columnId: dimensionProps.columnId, + prevState: state.visualization.state, + frame, + }); + }; + + remove({ layerId, columnId }); + + if (linkedDimension && linkedDimension.columnId) { + remove({ columnId: linkedDimension.columnId, layerId: linkedDimension.layerId }); + } + }, }); }; @@ -754,6 +889,11 @@ function addInitialValueIfAvailable({ activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { ...info, columnId: columnId || info.columnId, + visualizationGroups: activeVisualization.getConfiguration({ + layerId, + frame: framePublicAPI, + state: activeVisualizationState, + }).groups, }), activeVisualizationState, }; @@ -771,3 +911,69 @@ function addInitialValueIfAvailable({ activeVisualizationState: visualizationState, }; } + +function syncLinkedDimensionsFunction( + state: LensAppState, + activeDatasourceId: string, + visualizationMap: VisualizationMap, + datasourceMap: DatasourceMap +) { + let datasourceState: unknown = state.datasourceStates[activeDatasourceId].state; + let visualizationState: unknown = state.visualization.state; + + const activeVisualization = visualizationMap[state.visualization.activeId!]; // TODO - double check the safety of this coercion + const linkedDimensions = activeVisualization.getLinkedDimensions?.(visualizationState); + const frame = selectFramePublicAPI({ lens: state }, datasourceMap); + + linkedDimensions?.forEach(({ from, to }) => { + const columnId = to.columnId ?? generateId(); + + const dropSource = { + ...from, + id: from.columnId, + // don't need to worry about accessibility here + humanData: { label: '' }, + }; + + const dropTarget = { + ...to, + columnId, + filterOperations: () => true, + }; + + const dropType = 'duplicate_compatible'; + + // TODO - always call onDrop with the TARGET's dimension groups throughout Lens + const getDimensionGroups = () => + activeVisualization.getConfiguration({ + state: visualizationState, + layerId: to.layerId, + frame, + }).groups; + + visualizationState = (activeVisualization.onDrop || onDropForVisualization)?.( + { + prevState: visualizationState, + frame, + target: dropTarget, + source: dropSource, + dropType, + group: getDimensionGroups().find(({ groupId }) => groupId === dropTarget.groupId), + }, + activeVisualization + ); + + datasourceMap[activeDatasourceId].onDrop({ + source: dropSource, + target: dropTarget, + state: datasourceState, + setState: (s) => { + datasourceState = s; + }, + dimensionGroups: getDimensionGroups(), + dropType, + }); + }); + + return { datasourceState, visualizationState }; +} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 78f104ce943fb..8ef9b2f194747 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -228,7 +228,7 @@ export interface Datasource { getPersistableState: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] }; getCurrentIndexPatternId: (state: T) => string; - insertLayer: (state: T, newLayerId: string) => T; + insertLayer: (state: T, newLayerId: string, linkToLayerId?: string) => T; removeLayer: (state: T, layerId: string) => T; clearLayer: (state: T, layerId: string) => T; getLayers: (state: T) => string[]; @@ -240,6 +240,8 @@ export interface Datasource { columnId: string; groupId: string; staticValue?: unknown; + autoTimeField?: boolean; + visualizationGroups: VisualizationDimensionGroupConfig[]; } ) => T; @@ -576,6 +578,8 @@ export type VisualizationDimensionEditorProps = VisualizationConfig accessor: string; setState(newState: T | ((currState: T) => T)): void; panelRef: MutableRefObject; + addLayer: (layerType: LayerType) => void; + removeLayer: (layerId: string) => void; }; export interface AccessorConfig { @@ -618,6 +622,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { supportStaticValue?: boolean; paramEditorCustomProps?: ParamEditorCustomProps; supportFieldFormat?: boolean; + hidden?: boolean; labels?: { buttonAriaLabel: string; buttonLabel: string }; }; @@ -812,13 +817,24 @@ export interface Visualization { getDescription: (state: T) => { icon?: IconType; label: string }; /** Frame needs to know which layers the visualization is currently using */ - getLayerIds: (state: T) => string[]; + getLayersInUse: (state: T) => Array<{ id: string; hidden?: boolean }>; /** Reset button on each layer triggers this */ clearLayer: (state: T, layerId: string) => T; /** Optional, if the visualization supports multiple layers */ removeLayer?: (state: T, layerId: string) => T; /** Track added layers in internal state */ appendLayer?: (state: T, layerId: string, type: LayerType) => T; + /** Returns a list of layers the new layer ID should be linked to */ + getLinkedLayers?: (state: T, newLayerId: string) => string[]; + /** Returns a set of dimensions that should be kept in sync */ + getLinkedDimensions?: (state: T) => Array<{ + from: { columnId: string; groupId: string; layerId: string }; + to: { + columnId?: string; + groupId: string; + layerId: string; + }; + }>; /** Retrieve a list of supported layer types with initialization data */ getSupportedLayers: ( @@ -830,11 +846,12 @@ export interface Visualization { icon?: IconType; noDatasource?: boolean; disabled?: boolean; - toolTipContent?: string; + hideFromMenu?: boolean; initialDimensions?: Array<{ columnId: string; groupId: string; staticValue?: unknown; + autoTimeField?: boolean; }>; }>; getLayerType: (layerId: string, state?: T) => LayerType | undefined; diff --git a/x-pack/plugins/lens/public/visualizations/datatable/visualization.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/visualization.test.tsx index 494be445670ae..ee11f850c05be 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/visualization.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/visualization.test.tsx @@ -59,7 +59,7 @@ describe('Datatable Visualization', () => { layerType: layerTypes.DATA, columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], }; - expect(datatableVisualization.getLayerIds(state)).toEqual(['baz']); + expect(datatableVisualization.getLayersInUse(state)).toEqual(['baz']); }); }); diff --git a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx index 2ccef5a89b1ba..62d16a1b40d18 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx @@ -69,8 +69,8 @@ export const getDatatableVisualization = ({ return 'lnsDatatable'; }, - getLayerIds(state) { - return [state.layerId]; + getLayersInUse(state) { + return [{ id: state.layerId }]; }, clearLayer(state) { diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index 95512e535790b..afae4adc68415 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -181,8 +181,8 @@ export const getGaugeVisualization = ({ getVisualizationTypeId(state) { return state.shape; }, - getLayerIds(state) { - return [state.layerId]; + getLayersInUse(state) { + return [{ id: state.layerId }]; }, clearLayer(state) { const newState = { ...state }; diff --git a/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx b/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx index 9c97aa4dab605..4441fbeb063b7 100644 --- a/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/heatmap/visualization.tsx @@ -119,8 +119,8 @@ export const getHeatmapVisualization = ({ return state.shape; }, - getLayerIds(state) { - return [state.layerId]; + getLayersInUse(state) { + return [{ id: state.layerId }]; }, clearLayer(state) { diff --git a/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.test.ts index 399c29797ff83..1d6673a931f6a 100644 --- a/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.test.ts +++ b/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.test.ts @@ -63,7 +63,7 @@ describe('metric_visualization', () => { describe('#getLayerIds', () => { it('returns the layer id', () => { - expect(metricVisualization.getLayerIds(exampleState())).toEqual(['l1']); + expect(metricVisualization.getLayersInUse(exampleState())).toEqual(['l1']); }); }); diff --git a/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx b/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx index 5550341a81e8b..cf817a699f529 100644 --- a/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/legacy_metric/visualization.tsx @@ -202,8 +202,8 @@ export const getLegacyMetricVisualization = ({ }; }, - getLayerIds(state) { - return [state.layerId]; + getLayersInUse(state) { + return [{ id: state.layerId }]; }, getDescription() { diff --git a/x-pack/plugins/lens/public/visualizations/metric/constants.ts b/x-pack/plugins/lens/public/visualizations/metric/constants.ts index 4a3830f10ec91..ef5a690bc8e48 100644 --- a/x-pack/plugins/lens/public/visualizations/metric/constants.ts +++ b/x-pack/plugins/lens/public/visualizations/metric/constants.ts @@ -12,4 +12,7 @@ export const GROUP_ID = { SECONDARY_METRIC: 'secondaryMetric', MAX: 'max', BREAKDOWN_BY: 'breakdownBy', + TREND_METRIC: 'trendMetric', + TREND_TIME: 'trendTime', + TREND_BREAKDOWN_BY: 'trendBreakdownBy', } as const; diff --git a/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.tsx index e8a72b91f4570..ec6ec1a56ef7d 100644 --- a/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/visualizations/metric/dimension_editor.tsx @@ -17,6 +17,7 @@ import { htmlIdGenerator, EuiColorPicker, euiPaletteColorBlind, + EuiSwitch, } from '@elastic/eui'; import { LayoutDirection } from '@elastic/charts'; import React, { useCallback, useState } from 'react'; @@ -241,7 +242,34 @@ function PrimaryMetricEditor(props: Props) { + {/* // TODO - find a way to disable this if the dataview doesn't have a time field! */} + { + if (!state.trendlineLayerId) { + props.addLayer('metricTrendline'); + } else { + props.removeLayer(state.trendlineLayerId); + } + }} + /> + + color)} type={FIXED_PROGRESSION} onClick={togglePalette} @@ -322,7 +350,7 @@ function PrimaryMetricEditor(props: Props) { { }); test('getLayerIds returns the single layer ID', () => { - expect(visualization.getLayerIds(fullState)).toEqual([fullState.layerId]); + expect(visualization.getLayersInUse(fullState)).toEqual([fullState.layerId]); }); it('gives a description', () => { diff --git a/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx b/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx index 127d3da0e2c9c..d6d7c064fc5e6 100644 --- a/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx @@ -16,15 +16,26 @@ import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public'; import { LayoutDirection } from '@elastic/charts'; import { euiLightVars } from '@kbn/ui-theme'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { + EXPRESSION_METRIC_NAME, + EXPRESSION_METRIC_TRENDLINE_NAME, +} from '@kbn/expression-metric-vis-plugin/public'; import { LayerType } from '../../../common'; import { getSuggestions } from './suggestions'; import { LensIconChartMetric } from '../../assets/chart_metric'; -import { Visualization, OperationMetadata, DatasourceLayers } from '../../types'; +import { + Visualization, + OperationMetadata, + DatasourceLayers, + VisualizationConfigProps, + VisualizationDimensionGroupConfig, +} from '../../types'; import { layerTypes } from '../../../common'; import { GROUP_ID, LENS_METRIC_ID } from './constants'; import { DimensionEditor } from './dimension_editor'; import { Toolbar } from './toolbar'; import { generateId } from '../../id_generator'; +import { StaticHeader } from '../../shared_components'; export const DEFAULT_MAX_COLUMNS = 3; @@ -44,6 +55,12 @@ export interface MetricVisualizationState { color?: string; palette?: PaletteOutput; maxCols?: number; + + trendlineLayerId?: string; + trendlineLayerType?: LayerType; + trendlineTimeAccessor?: string; + trendlineMetricAccessor?: string; + trendlineBreakdownByAccessor?: string; } export const supportedDataTypes = new Set(['number']); @@ -59,6 +76,47 @@ function computePaletteParams(params: CustomPaletteParams) { }; } +const getTrendlineExpression = ( + state: MetricVisualizationState, + datasourceExpression?: Ast, + collapseExpression?: AstFunction +): Ast | undefined => { + if (!state.trendlineMetricAccessor || !state.trendlineTimeAccessor) { + return; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: EXPRESSION_METRIC_TRENDLINE_NAME, + arguments: { + metric: [state.trendlineMetricAccessor], + timeField: [state.trendlineTimeAccessor], + breakdownBy: state.trendlineBreakdownByAccessor + ? [state.trendlineBreakdownByAccessor] + : [], + inspectorTableId: [state.trendlineLayerId], + ...(datasourceExpression + ? { + table: [ + { + ...datasourceExpression, + chain: [ + ...datasourceExpression.chain, + ...(collapseExpression ? [collapseExpression] : []), + ], + }, + ], + } + : {}), + }, + }, + ], + }; +}; + const toExpression = ( paletteService: PaletteRegistry, state: MetricVisualizationState, @@ -103,22 +161,30 @@ const toExpression = ( }; }; + const collapseExpressionFunction = state.collapseFn + ? ({ + type: 'function', + function: 'lens_collapse', + arguments: getCollapseFnArguments(), + } as AstFunction) + : undefined; + + const trendlineExpression = state.trendlineLayerId + ? getTrendlineExpression( + state, + datasourceExpressionsByLayers[state.trendlineLayerId], + collapseExpressionFunction + ) + : undefined; + return { type: 'expression', chain: [ ...(datasourceExpression?.chain ?? []), - ...(state.collapseFn - ? [ - { - type: 'function', - function: 'lens_collapse', - arguments: getCollapseFnArguments(), - } as AstFunction, - ] - : []), + ...(collapseExpressionFunction ? [collapseExpressionFunction] : []), { type: 'function', - function: 'metricVis', // TODO import from plugin + function: EXPRESSION_METRIC_NAME, arguments: { metric: state.metricAccessor ? [state.metricAccessor] : [], secondaryMetric: state.secondaryMetricAccessor ? [state.secondaryMetricAccessor] : [], @@ -126,6 +192,7 @@ const toExpression = ( max: state.maxAccessor ? [state.maxAccessor] : [], breakdownBy: state.breakdownByAccessor && !state.collapseFn ? [state.breakdownByAccessor] : [], + trendline: trendlineExpression ? [trendlineExpression] : [], subtitle: state.subtitle ? [state.subtitle] : [], progressDirection: state.progressDirection ? [state.progressDirection] : [], color: state.color @@ -142,7 +209,206 @@ const toExpression = ( : [], maxCols: [state.maxCols ?? DEFAULT_MAX_COLUMNS], minTiles: maxPossibleTiles ? [maxPossibleTiles] : [], + inspectorTableId: [state.layerId], + }, + }, + ], + }; +}; + +const getMetricLayerConfiguration = ( + props: VisualizationConfigProps +): { + groups: VisualizationDimensionGroupConfig[]; +} => { + const hasColoring = props.state.palette != null; + const stops = props.state.palette?.params?.stops || []; + const isSupportedMetric = (op: OperationMetadata) => + !op.isBucketed && supportedDataTypes.has(op.dataType); + + const isSupportedDynamicMetric = (op: OperationMetadata) => + !op.isBucketed && supportedDataTypes.has(op.dataType) && !op.isStaticValue; + + const isBucketed = (op: OperationMetadata) => op.isBucketed; + return { + groups: [ + { + groupId: GROUP_ID.METRIC, + groupLabel: i18n.translate('xpack.lens.primaryMetric.label', { + defaultMessage: 'Primary metric', + }), + paramEditorCustomProps: { + headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', { + defaultMessage: 'Value', + }), + }, + accessors: props.state.metricAccessor + ? [ + { + columnId: props.state.metricAccessor, + triggerIcon: hasColoring ? 'colorBy' : undefined, + palette: hasColoring ? stops.map(({ color }) => color) : undefined, + }, + ] + : [], + supportsMoreColumns: !props.state.metricAccessor, + filterOperations: isSupportedDynamicMetric, + enableDimensionEditor: true, + supportFieldFormat: false, + required: true, + }, + { + groupId: GROUP_ID.SECONDARY_METRIC, + groupLabel: i18n.translate('xpack.lens.metric.secondaryMetric', { + defaultMessage: 'Secondary metric', + }), + paramEditorCustomProps: { + headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', { + defaultMessage: 'Value', + }), + }, + accessors: props.state.secondaryMetricAccessor + ? [ + { + columnId: props.state.secondaryMetricAccessor, + }, + ] + : [], + supportsMoreColumns: !props.state.secondaryMetricAccessor, + filterOperations: isSupportedDynamicMetric, + enableDimensionEditor: true, + supportFieldFormat: false, + required: false, + }, + { + groupId: GROUP_ID.MAX, + groupLabel: i18n.translate('xpack.lens.metric.max', { defaultMessage: 'Maximum value' }), + paramEditorCustomProps: { + headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', { + defaultMessage: 'Value', + }), + }, + accessors: props.state.maxAccessor + ? [ + { + columnId: props.state.maxAccessor, + }, + ] + : [], + supportsMoreColumns: !props.state.maxAccessor, + filterOperations: isSupportedMetric, + enableDimensionEditor: true, + supportFieldFormat: false, + supportStaticValue: true, + required: false, + groupTooltip: i18n.translate('xpack.lens.metric.maxTooltip', { + defaultMessage: 'If the maximum value is specified, the minimum value is fixed at zero.', + }), + }, + { + groupId: GROUP_ID.BREAKDOWN_BY, + groupLabel: i18n.translate('xpack.lens.metric.breakdownBy', { + defaultMessage: 'Break down by', + }), + accessors: props.state.breakdownByAccessor + ? [ + { + columnId: props.state.breakdownByAccessor, + triggerIcon: props.state.collapseFn ? ('aggregate' as const) : undefined, + }, + ] + : [], + supportsMoreColumns: !props.state.breakdownByAccessor, + filterOperations: isBucketed, + enableDimensionEditor: true, + supportFieldFormat: false, + }, + ], + }; +}; + +const getTrendlineLayerConfiguration = ( + props: VisualizationConfigProps +): { + groups: VisualizationDimensionGroupConfig[]; +} => { + return { + groups: [ + { + groupId: GROUP_ID.TREND_METRIC, + groupLabel: i18n.translate('xpack.lens.primaryMetric.label', { + defaultMessage: 'Primary metric', + }), + paramEditorCustomProps: { + headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', { + defaultMessage: 'Value', + }), + }, + accessors: props.state.trendlineMetricAccessor + ? [ + { + columnId: props.state.trendlineMetricAccessor, + }, + ] + : [], + supportsMoreColumns: !props.state.trendlineMetricAccessor, + filterOperations: () => true, + enableDimensionEditor: true, + supportFieldFormat: false, + required: true, + hideGrouping: true, + nestingOrder: 2, + hidden: true, + }, + { + groupId: GROUP_ID.TREND_TIME, + groupLabel: i18n.translate('xpack.lens.metric.timeField', { defaultMessage: 'Time field' }), + paramEditorCustomProps: { + headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', { + defaultMessage: 'Value', + }), + }, + accessors: props.state.trendlineTimeAccessor + ? [ + { + columnId: props.state.trendlineTimeAccessor, + }, + ] + : [], + supportsMoreColumns: !props.state.trendlineTimeAccessor, + filterOperations: (op) => op.isBucketed && op.dataType === 'date', + enableDimensionEditor: true, + required: true, + groupTooltip: i18n.translate('xpack.lens.metric.timeFieldTooltip', { + defaultMessage: 'This is the time axis for the trend line', + }), + hideGrouping: true, + nestingOrder: 1, + }, + { + groupId: GROUP_ID.TREND_BREAKDOWN_BY, + groupLabel: i18n.translate('xpack.lens.metric.breakdownBy', { + defaultMessage: 'Break down by', + }), + paramEditorCustomProps: { + headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', { + defaultMessage: 'Value', + }), }, + accessors: props.state.trendlineBreakdownByAccessor + ? [ + { + columnId: props.state.trendlineBreakdownByAccessor, + }, + ] + : [], + supportsMoreColumns: !props.state.trendlineBreakdownByAccessor, + filterOperations: () => false, + enableDimensionEditor: true, + required: false, + hideGrouping: true, + nestingOrder: 0, + hidden: true, }, ], }; @@ -192,8 +458,13 @@ export const getMetricVisualization = ({ return newState; }, - getLayerIds(state) { - return [state.layerId]; + // TODO - can we just hide by layer type in the supportedLayers array instead of by layer? + // I.e. do we anticipate a scenario where some layers of the same type will be hidden while others be shown? + getLayersInUse(state) { + return [ + { id: state.layerId }, + ...(state.trendlineLayerId ? [{ id: state.trendlineLayerId, hidden: true }] : []), + ]; }, getDescription() { @@ -217,116 +488,9 @@ export const getMetricVisualization = ({ triggers: [VIS_EVENT_TO_TRIGGER.filter], getConfiguration(props) { - const hasColoring = props.state.palette != null; - const stops = props.state.palette?.params?.stops || []; - const isSupportedMetric = (op: OperationMetadata) => - !op.isBucketed && supportedDataTypes.has(op.dataType); - - const isSupportedDynamicMetric = (op: OperationMetadata) => - !op.isBucketed && supportedDataTypes.has(op.dataType) && !op.isStaticValue; - - const isBucketed = (op: OperationMetadata) => op.isBucketed; - return { - groups: [ - { - groupId: GROUP_ID.METRIC, - groupLabel: i18n.translate('xpack.lens.primaryMetric.label', { - defaultMessage: 'Primary metric', - }), - paramEditorCustomProps: { - headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', { - defaultMessage: 'Value', - }), - }, - layerId: props.state.layerId, - accessors: props.state.metricAccessor - ? [ - { - columnId: props.state.metricAccessor, - triggerIcon: hasColoring ? 'colorBy' : undefined, - palette: hasColoring ? stops.map(({ color }) => color) : undefined, - }, - ] - : [], - supportsMoreColumns: !props.state.metricAccessor, - filterOperations: isSupportedDynamicMetric, - enableDimensionEditor: true, - supportFieldFormat: false, - required: true, - }, - { - groupId: GROUP_ID.SECONDARY_METRIC, - groupLabel: i18n.translate('xpack.lens.metric.secondaryMetric', { - defaultMessage: 'Secondary metric', - }), - paramEditorCustomProps: { - headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', { - defaultMessage: 'Value', - }), - }, - layerId: props.state.layerId, - accessors: props.state.secondaryMetricAccessor - ? [ - { - columnId: props.state.secondaryMetricAccessor, - }, - ] - : [], - supportsMoreColumns: !props.state.secondaryMetricAccessor, - filterOperations: isSupportedDynamicMetric, - enableDimensionEditor: true, - supportFieldFormat: false, - required: false, - }, - { - groupId: GROUP_ID.MAX, - groupLabel: i18n.translate('xpack.lens.metric.max', { defaultMessage: 'Maximum value' }), - paramEditorCustomProps: { - headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', { - defaultMessage: 'Value', - }), - }, - layerId: props.state.layerId, - accessors: props.state.maxAccessor - ? [ - { - columnId: props.state.maxAccessor, - }, - ] - : [], - supportsMoreColumns: !props.state.maxAccessor, - filterOperations: isSupportedMetric, - enableDimensionEditor: true, - supportFieldFormat: false, - supportStaticValue: true, - required: false, - groupTooltip: i18n.translate('xpack.lens.metric.maxTooltip', { - defaultMessage: - 'If the maximum value is specified, the minimum value is fixed at zero.', - }), - }, - { - groupId: GROUP_ID.BREAKDOWN_BY, - groupLabel: i18n.translate('xpack.lens.metric.breakdownBy', { - defaultMessage: 'Break down by', - }), - layerId: props.state.layerId, - accessors: props.state.breakdownByAccessor - ? [ - { - columnId: props.state.breakdownByAccessor, - triggerIcon: props.state.collapseFn ? ('aggregate' as const) : undefined, - }, - ] - : [], - supportsMoreColumns: !props.state.breakdownByAccessor, - filterOperations: isBucketed, - enableDimensionEditor: true, - supportFieldFormat: false, - required: false, - }, - ], - }; + return props.layerId === props.state.layerId + ? getMetricLayerConfiguration(props) + : getTrendlineLayerConfiguration(props); }, getSupportedLayers(state) { @@ -345,14 +509,99 @@ export const getMetricVisualization = ({ }, ] : undefined, + disabled: true, + hideFromMenu: true, + }, + { + type: layerTypes.METRIC_TRENDLINE, + label: i18n.translate('xpack.lens.metric.layerType.trendLine', { + defaultMessage: 'Trendline', + }), + initialDimensions: [ + { groupId: GROUP_ID.TREND_TIME, columnId: generateId(), autoTimeField: true }, + ], + disabled: Boolean(state?.trendlineLayerId), + hideFromMenu: true, }, ]; }, + getLinkedLayers(state, newLayerId: string): string[] { + return newLayerId === state.trendlineLayerId ? [state.layerId] : []; + }, + + getLinkedDimensions(state) { + if (!state.trendlineLayerId || !state.metricAccessor) { + return []; + } + + const links: Array<{ + from: { columnId: string; groupId: string; layerId: string }; + to: { + columnId?: string; + groupId: string; + layerId: string; + }; + }> = [ + { + from: { + columnId: state.metricAccessor, + groupId: GROUP_ID.METRIC, + layerId: state.layerId, + }, + to: { + columnId: state.trendlineMetricAccessor, + groupId: GROUP_ID.TREND_METRIC, + layerId: state.trendlineLayerId, + }, + }, + ]; + + if (state.breakdownByAccessor) { + links.push({ + from: { + columnId: state.breakdownByAccessor, + groupId: GROUP_ID.BREAKDOWN_BY, + layerId: state.layerId, + }, + to: { + columnId: state.trendlineBreakdownByAccessor, + groupId: GROUP_ID.TREND_BREAKDOWN_BY, + layerId: state.trendlineLayerId, + }, + }); + } + + return links; + }, + + appendLayer(state, layerId, layerType) { + if (layerType !== layerTypes.METRIC_TRENDLINE) { + throw new Error(`Metric vis only supports layers of type ${layerTypes.METRIC_TRENDLINE}!`); + } + + return { ...state, trendlineLayerId: layerId, trendlineLayerType: layerType }; + }, + + removeLayer(state) { + return { + ...state, + trendlineLayerId: undefined, + trendlineLayerType: undefined, + trendlineMetricAccessor: undefined, + trendlineTimeAccessor: undefined, + trendlineBreakdownByAccessor: undefined, + }; + }, + getLayerType(layerId, state) { if (state?.layerId === layerId) { return state.layerType; } + + if (state?.trendlineLayerId === layerId) { + return state.trendlineLayerType; + } }, toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers) => @@ -374,6 +623,15 @@ export const getMetricVisualization = ({ case GROUP_ID.BREAKDOWN_BY: updated.breakdownByAccessor = columnId; break; + case GROUP_ID.TREND_TIME: + updated.trendlineTimeAccessor = columnId; + break; + case GROUP_ID.TREND_METRIC: + updated.trendlineMetricAccessor = columnId; + break; + case GROUP_ID.TREND_BREAKDOWN_BY: + updated.trendlineBreakdownByAccessor = columnId; + break; } return updated; @@ -397,10 +655,38 @@ export const getMetricVisualization = ({ delete updated.breakdownByAccessor; delete updated.collapseFn; } + if (prevState.trendlineTimeAccessor === columnId) { + delete updated.trendlineTimeAccessor; + } + if (prevState.trendlineMetricAccessor === columnId) { + delete updated.trendlineMetricAccessor; + } + if (prevState.trendlineBreakdownByAccessor === columnId) { + delete updated.trendlineBreakdownByAccessor; + } return updated; }, + renderLayerHeader(domElement, props) { + render( + + + {props.layerId === props.state.layerId ? ( + + ) : ( + + )} + + , + domElement + ); + }, + renderToolbar(domElement, props) { render( diff --git a/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx b/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx index 5e453838aef51..ef55be0792022 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/partition/visualization.tsx @@ -79,8 +79,8 @@ export const getPieVisualization = ({ return state.shape; }, - getLayerIds(state) { - return state.layers.map((l) => l.layerId); + getLayersInUse(state) { + return state.layers.map((l) => ({ id: l.layerId })); }, clearLayer(state) { diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts index a9848561ecded..f59f15f363a1f 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts @@ -247,7 +247,7 @@ describe('xy_visualization', () => { describe('#getLayerIds', () => { it('returns layerids', () => { - expect(xyVisualization.getLayerIds(exampleState())).toEqual(['first']); + expect(xyVisualization.getLayersInUse(exampleState())).toEqual(['first']); }); }); diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx index f5d241738dd4e..832aca75e74e4 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx @@ -100,8 +100,8 @@ export const getXyVisualization = ({ return type === 'mixed' ? type : type.id; }, - getLayerIds(state) { - return getLayersByType(state).map((l) => l.layerId); + getLayersInUse(state) { + return getLayersByType(state).map((l) => ({ id: l.layerId })); }, getRemoveOperation(state, layerId) {