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,
};