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 bd501db2b752a4..36d5bfd965e262 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 @@ -186,7 +186,7 @@ export function LayerPanel( }, ]; - if (activeVisualization.renderDimensionEditor) { + if (activeVisualization.renderDimensionEditor && group.enableDimensionEditor) { tabs.push({ id: 'visualization', name: i18n.translate('xpack.lens.editorFrame.formatStyleLabel', { diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 6b68679bfd4ec0..c037aecde558b9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -38,6 +38,7 @@ Object { "xScaleType": Array [ "linear", ], + "yConfig": Array [], "yScaleType": Array [ "linear", ], diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index fc5ed7480dd1f8..48c70e0a4a05b8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -12,6 +12,11 @@ exports[`xy_expression XYChart component it renders area 1`] = ` showLegend={false} showLegendExtra={false} theme={Object {}} + tooltip={ + Object { + "headerFormatter": [Function], + } + } /> + + + + + + + { + const tables: Record = { + first: { + type: 'kibana_datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date_histogram', + indexPatternId: 'indexPatternId', + aggConfigParams: { + field: 'order_date', + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, + }, + }, + formatHint: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'terms', + indexPatternId: 'indexPatternId', + aggConfigParams: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }, + formatHint: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', + }, + }, + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'count', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'number' }, + }, + { + id: 'yAccessorId2', + name: 'Other column', + meta: { + type: 'average', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'bytes' }, + }, + { + id: 'yAccessorId3', + name: 'Other column', + meta: { + type: 'average', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'currency' }, + }, + { + id: 'yAccessorId4', + name: 'Other column', + meta: { + type: 'average', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'currency' }, + }, + ], + }, + }; + + const sampleLayer: LayerArgs = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['yAccessorId'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }; + + it('should map auto series to left axis', () => { + const formatFactory = jest.fn(); + const groups = getAxesConfiguration([sampleLayer], tables, formatFactory, false); + expect(groups.length).toEqual(1); + expect(groups[0].position).toEqual('left'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[0].series[0].layer).toEqual('first'); + }); + + it('should map auto series to right axis if formatters do not match', () => { + const formatFactory = jest.fn(); + const twoSeriesLayer = { ...sampleLayer, accessors: ['yAccessorId', 'yAccessorId2'] }; + const groups = getAxesConfiguration([twoSeriesLayer], tables, formatFactory, false); + expect(groups.length).toEqual(2); + expect(groups[0].position).toEqual('left'); + expect(groups[1].position).toEqual('right'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[1].series[0].accessor).toEqual('yAccessorId2'); + }); + + it('should map auto series to left if left and right are already filled with non-matching series', () => { + const formatFactory = jest.fn(); + const threeSeriesLayer = { + ...sampleLayer, + accessors: ['yAccessorId', 'yAccessorId2', 'yAccessorId3'], + }; + const groups = getAxesConfiguration([threeSeriesLayer], tables, formatFactory, false); + expect(groups.length).toEqual(2); + expect(groups[0].position).toEqual('left'); + expect(groups[1].position).toEqual('right'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[0].series[1].accessor).toEqual('yAccessorId3'); + expect(groups[1].series[0].accessor).toEqual('yAccessorId2'); + }); + + it('should map right series to right axis', () => { + const formatFactory = jest.fn(); + const groups = getAxesConfiguration( + [{ ...sampleLayer, yConfig: [{ forAccessor: 'yAccessorId', axisMode: 'right' }] }], + tables, + formatFactory, + false + ); + expect(groups.length).toEqual(1); + expect(groups[0].position).toEqual('right'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[0].series[0].layer).toEqual('first'); + }); + + it('should map series with matching formatters to same axis', () => { + const formatFactory = jest.fn(); + const groups = getAxesConfiguration( + [ + { + ...sampleLayer, + accessors: ['yAccessorId', 'yAccessorId3', 'yAccessorId4'], + yConfig: [{ forAccessor: 'yAccessorId', axisMode: 'right' }], + }, + ], + tables, + formatFactory, + false + ); + expect(groups.length).toEqual(2); + expect(groups[0].position).toEqual('left'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId3'); + expect(groups[0].series[1].accessor).toEqual('yAccessorId4'); + expect(groups[1].position).toEqual('right'); + expect(groups[1].series[0].accessor).toEqual('yAccessorId'); + expect(formatFactory).toHaveBeenCalledWith({ id: 'number' }); + expect(formatFactory).toHaveBeenCalledWith({ id: 'currency' }); + }); + + it('should create one formatter per series group', () => { + const formatFactory = jest.fn(); + getAxesConfiguration( + [ + { + ...sampleLayer, + accessors: ['yAccessorId', 'yAccessorId3', 'yAccessorId4'], + yConfig: [{ forAccessor: 'yAccessorId', axisMode: 'right' }], + }, + ], + tables, + formatFactory, + false + ); + expect(formatFactory).toHaveBeenCalledTimes(2); + expect(formatFactory).toHaveBeenCalledWith({ id: 'number' }); + expect(formatFactory).toHaveBeenCalledWith({ id: 'currency' }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts new file mode 100644 index 00000000000000..7d1d3389bb916f --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LayerConfig } from './types'; +import { + KibanaDatatable, + SerializedFieldFormat, +} from '../../../../../src/plugins/expressions/public'; +import { IFieldFormat } from '../../../../../src/plugins/data/public'; + +interface FormattedMetric { + layer: string; + accessor: string; + fieldFormat: SerializedFieldFormat; +} + +type GroupsConfiguration = Array<{ + groupId: string; + position: 'left' | 'right' | 'bottom' | 'top'; + formatter: IFieldFormat; + series: Array<{ layer: string; accessor: string }>; +}>; + +export function isFormatterCompatible( + formatter1: SerializedFieldFormat, + formatter2: SerializedFieldFormat +) { + return formatter1.id === formatter2.id; +} + +export function getAxesConfiguration( + layers: LayerConfig[], + tables: Record, + formatFactory: (mapping: SerializedFieldFormat) => IFieldFormat, + shouldRotate: boolean +): GroupsConfiguration { + const series: { auto: FormattedMetric[]; left: FormattedMetric[]; right: FormattedMetric[] } = { + auto: [], + left: [], + right: [], + }; + + layers.forEach((layer) => { + const table = tables[layer.layerId]; + layer.accessors.forEach((accessor) => { + const mode = + layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode || + 'auto'; + const formatter: SerializedFieldFormat = table.columns.find( + (column) => column.id === accessor + )?.formatHint || { id: 'number' }; + series[mode].push({ + layer: layer.layerId, + accessor, + fieldFormat: formatter, + }); + }); + }); + + series.auto.forEach((currentSeries) => { + if ( + series.left.length === 0 || + series.left.every((leftSeries) => + isFormatterCompatible(leftSeries.fieldFormat, currentSeries.fieldFormat) + ) + ) { + series.left.push(currentSeries); + } else if ( + series.right.length === 0 || + series.right.every((rightSeries) => + isFormatterCompatible(rightSeries.fieldFormat, currentSeries.fieldFormat) + ) + ) { + series.right.push(currentSeries); + } else if (series.right.length >= series.left.length) { + series.left.push(currentSeries); + } else { + series.right.push(currentSeries); + } + }); + + const axisGroups: GroupsConfiguration = []; + + if (series.left.length > 0) { + axisGroups.push({ + groupId: 'left', + position: shouldRotate ? 'bottom' : 'left', + formatter: formatFactory(series.left[0].fieldFormat), + series: series.left.map(({ fieldFormat, ...currentSeries }) => currentSeries), + }); + } + + if (series.right.length > 0) { + axisGroups.push({ + groupId: 'right', + position: shouldRotate ? 'top' : 'right', + formatter: formatFactory(series.right[0].fieldFormat), + series: series.right.map(({ fieldFormat, ...currentSeries }) => currentSeries), + }); + } + + return axisGroups; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index cd25cb57295115..88a60089f6a246 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -11,7 +11,7 @@ import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public' import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; import { xyVisualization } from './xy_visualization'; import { xyChart, getXyChartRenderer } from './xy_expression'; -import { legendConfig, xConfig, layerConfig } from './types'; +import { legendConfig, layerConfig, yAxisConfig } from './types'; import { EditorFrameSetup, FormatFactory } from '../types'; export interface XyVisualizationPluginSetupPlugins { @@ -37,7 +37,7 @@ export class XyVisualization { { expressions, formatFactory, editorFrame }: XyVisualizationPluginSetupPlugins ) { expressions.registerFunction(() => legendConfig); - expressions.registerFunction(() => xConfig); + expressions.registerFunction(() => yAxisConfig); expressions.registerFunction(() => layerConfig); expressions.registerFunction(() => xyChart); diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index e02d135d9a4556..6ec22270d8b183 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -179,6 +179,21 @@ export const buildExpression = ( ], isHistogram: [isHistogramDimension], splitAccessor: layer.splitAccessor ? [layer.splitAccessor] : [], + yConfig: layer.yConfig + ? layer.yConfig.map((yConfig) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_yConfig', + arguments: { + forAccessor: [yConfig.forAccessor], + axisMode: [yConfig.axisMode], + }, + }, + ], + })) + : [], seriesType: [layer.seriesType], accessors: layer.accessors, columnToLabel: [JSON.stringify(columnToLabel)], diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 7a5837d382c7bd..e62c5f60a58e16 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -77,37 +77,33 @@ const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = }, }; -export interface YState extends AxisConfig { - accessors: string[]; -} - -export interface XConfig extends AxisConfig { - accessor: string; -} +type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; -type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; - -export const xConfig: ExpressionFunctionDefinition< - 'lens_xy_xConfig', +export const yAxisConfig: ExpressionFunctionDefinition< + 'lens_xy_yConfig', null, - XConfig, - XConfigResult + YConfig, + YConfigResult > = { - name: 'lens_xy_xConfig', + name: 'lens_xy_yConfig', aliases: [], - type: 'lens_xy_xConfig', - help: `Configure the xy chart's x axis`, + type: 'lens_xy_yConfig', + help: `Configure the behavior of a xy chart's y axis metric`, inputTypes: ['null'], args: { - ...axisConfig, - accessor: { + forAccessor: { types: ['string'], - help: 'The column to display on the x axis.', + help: 'The accessor this configuration is for', + }, + axisMode: { + types: ['string'], + options: ['auto', 'left', 'right'], + help: 'The axis mode of the metric', }, }, - fn: function fn(input: unknown, args: XConfig) { + fn: function fn(input: unknown, args: YConfig) { return { - type: 'lens_xy_xConfig', + type: 'lens_xy_yConfig', ...args, }; }, @@ -166,6 +162,12 @@ export const layerConfig: ExpressionFunctionDefinition< help: 'The columns to display on the y axis.', multi: true, }, + yConfig: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + types: ['lens_xy_yConfig' as any], + help: 'Additional configuration for y axes', + multi: true, + }, columnToLabel: { types: ['string'], help: 'JSON key-value pairs of column ID to label', @@ -188,11 +190,19 @@ export type SeriesType = | 'bar_horizontal_stacked' | 'area_stacked'; +export type YAxisMode = 'auto' | 'left' | 'right'; + +export interface YConfig { + forAccessor: string; + axisMode?: YAxisMode; +} + export interface LayerConfig { hide?: boolean; layerId: string; xAccessor?: string; accessors: string[]; + yConfig?: YConfig[]; seriesType: SeriesType; splitAccessor?: string; } diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 0ea44e469f8ddd..3e73cd256bdbf1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; -import { State, SeriesType, visualizationTypes } from './types'; -import { VisualizationLayerWidgetProps } from '../types'; +import { EuiButtonGroup, EuiFormRow, htmlIdGenerator } from '@elastic/eui'; +import { State, SeriesType, visualizationTypes, YAxisMode } from './types'; +import { VisualizationDimensionEditorProps, VisualizationLayerWidgetProps } from '../types'; import { isHorizontalChart, isHorizontalSeries } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; @@ -68,3 +67,73 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { ); } + +const idPrefix = htmlIdGenerator()(); + +export function DimensionEditor({ + state, + setState, + layerId, + accessor, +}: VisualizationDimensionEditorProps) { + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + const axisMode = + (layer.yConfig && + layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || + 'auto'; + return ( + + { + const newMode = id.replace(idPrefix, '') as YAxisMode; + const newYAxisConfigs = [...(layer.yConfig || [])]; + const existingIndex = newYAxisConfigs.findIndex( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); + if (existingIndex !== -1) { + newYAxisConfigs[existingIndex].axisMode = newMode; + } else { + newYAxisConfigs.push({ + forAccessor: accessor, + axisMode: newMode, + }); + } + setState(updateLayer(state, { ...layer, yConfig: newYAxisConfigs }, index)); + }} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx index b2d9f6acfc9f5c..34f2a9111253b7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -280,6 +280,58 @@ describe('xy_expression', () => { let getFormatSpy: jest.Mock; let convertSpy: jest.Mock; + const dataWithoutFormats: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + { id: 'd', name: 'd' }, + ], + rows: [ + { a: 1, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + const dataWithFormats: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + { id: 'd', name: 'd', formatHint: { id: 'custom' } }, + ], + rows: [ + { a: 1, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + + const getRenderedComponent = (data: LensMultiTable, args: XYArgs) => { + return shallow( + + ); + }; + beforeEach(() => { convertSpy = jest.fn((x) => x); getFormatSpy = jest.fn(); @@ -302,7 +354,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(LineSeries)).toHaveLength(1); + expect(component.find(LineSeries)).toHaveLength(2); + expect(component.find(LineSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(LineSeries).at(1).prop('yAccessors')).toEqual(['b']); }); describe('date range', () => { @@ -559,7 +613,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(BarSeries).at(1).prop('yAccessors')).toEqual(['b']); }); test('it renders area', () => { @@ -577,7 +633,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(AreaSeries)).toHaveLength(1); + expect(component.find(AreaSeries)).toHaveLength(2); + expect(component.find(AreaSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(AreaSeries).at(1).prop('yAccessors')).toEqual(['b']); }); test('it renders horizontal bar', () => { @@ -595,7 +653,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(BarSeries).at(1).prop('yAccessors')).toEqual(['b']); expect(component.find(Settings).prop('rotation')).toEqual(90); }); @@ -705,8 +765,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries).at(1).prop('stackAccessors')).toHaveLength(1); }); test('it renders stacked area', () => { @@ -724,8 +785,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(AreaSeries)).toHaveLength(1); - expect(component.find(AreaSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(AreaSeries)).toHaveLength(2); + expect(component.find(AreaSeries).at(0).prop('stackAccessors')).toHaveLength(1); + expect(component.find(AreaSeries).at(1).prop('stackAccessors')).toHaveLength(1); }); test('it renders stacked horizontal bar', () => { @@ -746,8 +808,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries).at(1).prop('stackAccessors')).toHaveLength(1); expect(component.find(Settings).prop('rotation')).toEqual(90); }); @@ -765,7 +828,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(LineSeries).prop('timeZone')).toEqual('CEST'); + expect(component.find(LineSeries).at(0).prop('timeZone')).toEqual('CEST'); + expect(component.find(LineSeries).at(1).prop('timeZone')).toEqual('CEST'); }); test('it applies histogram mode to the series for single series', () => { @@ -784,7 +848,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(0).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(1).prop('enableHistogramMode')).toEqual(true); }); test('it applies histogram mode to the series for stacked series', () => { @@ -810,7 +875,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(0).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(1).prop('enableHistogramMode')).toEqual(true); }); test('it does not apply histogram mode for splitted series', () => { @@ -830,47 +896,104 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); + expect(component.find(BarSeries).at(0).prop('enableHistogramMode')).toEqual(false); + expect(component.find(BarSeries).at(1).prop('enableHistogramMode')).toEqual(false); }); - describe('provides correct series naming', () => { - const dataWithoutFormats: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: { - type: 'kibana_datatable', - columns: [ - { id: 'a', name: 'a' }, - { id: 'b', name: 'b' }, - { id: 'c', name: 'c' }, - { id: 'd', name: 'd' }, - ], - rows: [ - { a: 1, b: 2, c: 'I', d: 'Row 1' }, - { a: 1, b: 5, c: 'J', d: 'Row 2' }, - ], - }, - }, - }; - const dataWithFormats: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: { - type: 'kibana_datatable', - columns: [ - { id: 'a', name: 'a' }, - { id: 'b', name: 'b' }, - { id: 'c', name: 'c' }, - { id: 'd', name: 'd', formatHint: { id: 'custom' } }, - ], - rows: [ - { a: 1, b: 2, c: 'I', d: 'Row 1' }, - { a: 1, b: 5, c: 'J', d: 'Row 2' }, - ], - }, - }, - }; + describe('y axes', () => { + test('single axis if possible', () => { + const args = createArgsWithLayers(); + + const component = getRenderedComponent(dataWithoutFormats, args); + const axes = component.find(Axis); + expect(axes).toHaveLength(2); + }); + + test('multiple axes because of config', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a', 'b'], + yConfig: [ + { + forAccessor: 'a', + axisMode: 'left', + }, + { + forAccessor: 'b', + axisMode: 'right', + }, + ], + }, + ], + } as XYArgs; + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const axes = component.find(Axis); + expect(axes).toHaveLength(3); + expect(component.find(LineSeries).at(0).prop('groupId')).toEqual( + axes.at(1).prop('groupId') + ); + expect(component.find(LineSeries).at(1).prop('groupId')).toEqual( + axes.at(2).prop('groupId') + ); + }); + + test('multiple axes because of incompatible formatters', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['c', 'd'], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithFormats, newArgs); + const axes = component.find(Axis); + expect(axes).toHaveLength(3); + expect(component.find(LineSeries).at(0).prop('groupId')).toEqual( + axes.at(1).prop('groupId') + ); + expect(component.find(LineSeries).at(1).prop('groupId')).toEqual( + axes.at(2).prop('groupId') + ); + }); + + test('single axis despite different formatters if enforced', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['c', 'd'], + yConfig: [ + { + forAccessor: 'c', + axisMode: 'left', + }, + { + forAccessor: 'd', + axisMode: 'left', + }, + ], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const axes = component.find(Axis); + expect(axes).toHaveLength(2); + }); + }); + + describe('provides correct series naming', () => { const nameFnArgs = { seriesKeys: [], key: '', @@ -879,21 +1002,6 @@ describe('xy_expression', () => { splitAccessors: new Map(), }; - const getRenderedComponent = (data: LensMultiTable, args: XYArgs) => { - return shallow( - - ); - }; - test('simplest xy chart without human-readable name', () => { const args = createArgsWithLayers(); const newArgs = { @@ -973,13 +1081,14 @@ describe('xy_expression', () => { }; const component = getRenderedComponent(dataWithoutFormats, newArgs); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + const nameFn1 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; + const nameFn2 = component.find(LineSeries).at(1).prop('name') as SeriesNameFn; // This accessor has a human-readable name - expect(nameFn({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual('Label A'); + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual('Label A'); // This accessor does not - expect(nameFn({ ...nameFnArgs, seriesKeys: ['b'] }, false)).toEqual(''); - expect(nameFn({ ...nameFnArgs, seriesKeys: ['nonsense'] }, false)).toEqual(''); + expect(nameFn2({ ...nameFnArgs, seriesKeys: ['b'] }, false)).toEqual(''); + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['nonsense'] }, false)).toEqual(''); }); test('split series without formatting and single y accessor', () => { @@ -1039,9 +1148,13 @@ describe('xy_expression', () => { }; const component = getRenderedComponent(dataWithoutFormats, newArgs); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + const nameFn1 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; + const nameFn2 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; - expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( + 'split1 - Label A' + ); + expect(nameFn2({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( 'split1 - Label B' ); }); @@ -1061,13 +1174,14 @@ describe('xy_expression', () => { }; const component = getRenderedComponent(dataWithFormats, newArgs); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + const nameFn1 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; + const nameFn2 = component.find(LineSeries).at(1).prop('name') as SeriesNameFn; convertSpy.mockReturnValueOnce('formatted1').mockReturnValueOnce('formatted2'); - expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( 'formatted1 - Label A' ); - expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( + expect(nameFn2({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( 'formatted2 - Label B' ); }); @@ -1088,7 +1202,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); + expect(component.find(LineSeries).at(0).prop('xScaleType')).toEqual(ScaleType.Ordinal); + expect(component.find(LineSeries).at(1).prop('xScaleType')).toEqual(ScaleType.Ordinal); }); test('it set the scale of the y axis according to the args prop', () => { @@ -1106,7 +1221,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(LineSeries).prop('yScaleType')).toEqual(ScaleType.Sqrt); + expect(component.find(LineSeries).at(0).prop('yScaleType')).toEqual(ScaleType.Sqrt); + expect(component.find(LineSeries).at(1).prop('yScaleType')).toEqual(ScaleType.Sqrt); }); test('it gets the formatter for the x axis', () => { @@ -1128,25 +1244,6 @@ describe('xy_expression', () => { expect(getFormatSpy).toHaveBeenCalledWith({ id: 'string' }); }); - test('it gets a default formatter for y if there are multiple y accessors', () => { - const { data, args } = sampleArgs(); - - shallow( - - ); - - expect(getFormatSpy).toHaveBeenCalledWith({ id: 'number' }); - }); - test('it gets the formatter for the y axis if there is only one accessor', () => { const { data, args } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 003036b211f038..17ed04aa0e9c49 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -40,6 +40,7 @@ import { isHorizontalChart } from './state_helpers'; import { parseInterval } from '../../../../../src/plugins/data/common'; import { EmptyPlaceholder } from '../shared_components'; import { desanitizeFilterContext } from '../utils'; +import { getAxesConfiguration } from './axes_configuration'; type InferPropType = T extends React.FunctionComponent ? P : T; type SeriesSpec = InferPropType & @@ -213,23 +214,19 @@ export function XYChart({ ); const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.formatHint); - // use default number formatter for y axis and use formatting hint if there is just a single y column - let yAxisFormatter = formatFactory({ id: 'number' }); - if (filteredLayers.length === 1 && filteredLayers[0].accessors.length === 1) { - const firstYAxisColumn = Object.values(data.tables)[0].columns.find( - ({ id }) => id === filteredLayers[0].accessors[0] - ); - if (firstYAxisColumn && firstYAxisColumn.formatHint) { - yAxisFormatter = formatFactory(firstYAxisColumn.formatHint); - } - } - const chartHasMoreThanOneSeries = filteredLayers.length > 1 || filteredLayers.some((layer) => layer.accessors.length > 1) || filteredLayers.some((layer) => layer.splitAccessor); const shouldRotate = isHorizontalChart(filteredLayers); + const yAxesConfiguration = getAxesConfiguration( + filteredLayers, + data.tables, + formatFactory, + shouldRotate + ); + const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; function calculateMinInterval() { @@ -279,6 +276,9 @@ export function XYChart({ legendPosition={legend.position} showLegendExtra={false} theme={chartTheme} + tooltip={{ + headerFormatter: (d) => xAxisFormatter.convert(d.value), + }} rotation={shouldRotate ? 90 : 0} xDomain={xDomain} onBrushEnd={({ x }) => { @@ -368,18 +368,30 @@ export function XYChart({ tickFormat={(d) => xAxisFormatter.convert(d)} /> - yAxisFormatter.convert(d)} - /> + {yAxesConfiguration.map((axis, index) => ( + + data.tables[series.layer].columns.find((column) => column.id === series.accessor) + ?.name + ) + .filter((name) => Boolean(name))[0] || args.yTitle + } + showGridLines={false} + hide={filteredLayers[0].hide} + tickFormat={(d) => axis.formatter.convert(d)} + /> + ))} - {filteredLayers.map( - ( - { + {filteredLayers.flatMap((layer, layerIndex) => + layer.accessors.map((accessor, accessorIndex) => { + const { splitAccessor, seriesType, accessors, @@ -389,9 +401,7 @@ export function XYChart({ yScaleType, xScaleType, isHistogram, - }, - index - ) => { + } = layer; const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; @@ -407,19 +417,22 @@ export function XYChart({ !( splitAccessor && typeof row[splitAccessor] === 'undefined' && - accessors.every((accessor) => typeof row[accessor] === 'undefined') + typeof row[accessor] === 'undefined' ) ); const seriesProps: SeriesSpec = { splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], stackAccessors: seriesType.includes('stacked') ? [xAccessor as string] : [], - id: splitAccessor || accessors.join(','), + id: `${splitAccessor}-${accessor}`, xAccessor, - yAccessors: accessors, + yAccessors: [accessor], data: rows, xScaleType, yScaleType, + groupId: yAxesConfiguration.find((axisConfiguration) => + axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor) + )?.groupId, enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), timeZone, name(d) { @@ -451,6 +464,8 @@ export function XYChart({ }, }; + const index = `${layerIndex}-${accessorIndex}`; + switch (seriesType) { case 'line': return ; @@ -462,7 +477,7 @@ export function XYChart({ default: return ; } - } + }) )} ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index ffbd3b7e2c1f2e..9d0ebbb389c077 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -14,7 +14,7 @@ import { TableSuggestion, TableChangeType, } from '../types'; -import { State, SeriesType, XYState, visualizationTypes } from './types'; +import { State, SeriesType, XYState, visualizationTypes, LayerConfig } from './types'; import { getIconForSeries } from './state_helpers'; const columnSortOrder = { @@ -379,13 +379,19 @@ function buildSuggestion({ changeType: TableChangeType; keptLayerIds: string[]; }) { + const existingLayer: LayerConfig | {} = getExistingLayer(currentState, layerId) || {}; + const accessors = yValues.map((col) => col.columnId); const newLayer = { - ...(getExistingLayer(currentState, layerId) || {}), + ...existingLayer, layerId, seriesType, xAccessor: xValue.columnId, splitAccessor: splitBy?.columnId, - accessors: yValues.map((col) => col.columnId), + accessors, + yConfig: + 'yConfig' in existingLayer && existingLayer.yConfig + ? existingLayer.yConfig.filter(({ forAccessor }) => accessors.indexOf(forAccessor) !== -1) + : undefined, }; const keptLayers = currentState diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx index ffacfbf8555eb0..474ea5c5b08cdf 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx @@ -11,13 +11,13 @@ import { Position } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getSuggestions } from './xy_suggestions'; -import { LayerContextMenu } from './xy_config_panel'; +import { DimensionEditor, LayerContextMenu } from './xy_config_panel'; import { Visualization, OperationMetadata, VisualizationType } from '../types'; import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types'; -import { toExpression, toPreviewExpression } from './to_expression'; import chartBarStackedSVG from '../assets/chart_bar_stacked.svg'; import chartMixedSVG from '../assets/chart_mixed_xy.svg'; import { isHorizontalChart } from './state_helpers'; +import { toExpression, toPreviewExpression } from './to_expression'; const defaultIcon = chartBarStackedSVG; const defaultSeriesType = 'bar_stacked'; @@ -187,6 +187,7 @@ export const xyVisualization: Visualization = { supportsMoreColumns: true, required: true, dataTestSubj: 'lnsXY_yDimensionPanel', + enableDimensionEditor: true, }, { groupId: 'breakdown', @@ -239,6 +240,10 @@ export const xyVisualization: Visualization = { newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId); } + if (newLayer.yConfig) { + newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); + } + return { ...prevState, layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), @@ -259,6 +264,15 @@ export const xyVisualization: Visualization = { ); }, + renderDimensionEditor(domElement, props) { + render( + + + , + domElement + ); + }, + toExpression, toPreviewExpression, };