From 5308ba12013d44f2fe7d50a577f8e3c5145aa1b9 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 12 Aug 2020 08:55:17 +0300 Subject: [PATCH] [Lens] Add styling options for x and y axes on the settings popover (#71829) (#74782) * [Lens] Add styling options for x axis on the settings popover * ts related changes * Changes to the popover's design and y-axis implementatin * fix types and add unit tests * Add extra translations * Fix functional test and change the logic of the yTitle * fixes * fix showTitle settings bug * Fix ticklabels bug on y axes * fix some tests * Change the user flow on x and y titles on settings popover and enable the gridlines by default * disable linter warning * PR Comments * Add a comment to callback to explain the decision to listen only to open changes Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- .../__snapshots__/to_expression.test.ts.snap | 48 +++- .../__snapshots__/xy_expression.test.tsx.snap | 49 +++- .../lens/public/xy_visualization/index.ts | 4 +- .../xy_visualization/to_expression.test.ts | 77 +++++- .../public/xy_visualization/to_expression.ts | 63 +++-- .../lens/public/xy_visualization/types.ts | 85 ++++++ .../xy_visualization/xy_config_panel.test.tsx | 69 ++++- .../xy_visualization/xy_config_panel.tsx | 261 ++++++++++++++++-- .../xy_visualization/xy_expression.test.tsx | 173 +++++++++++- .../public/xy_visualization/xy_expression.tsx | 68 +++-- .../xy_visualization/xy_suggestions.test.ts | 20 ++ .../public/xy_visualization/xy_suggestions.ts | 12 + 12 files changed, 852 insertions(+), 77 deletions(-) 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 b5783803b803c..19ea75239ddb2 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 @@ -8,6 +8,25 @@ Object { "fittingFunction": Array [ "Carry", ], + "gridlinesVisibilitySettings": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "x": Array [ + false, + ], + "y": Array [ + true, + ], + }, + "function": "lens_xy_gridlinesConfig", + "type": "function", + }, + ], + "type": "expression", + }, + ], "layers": Array [ Object { "chain": Array [ @@ -73,11 +92,36 @@ Object { "type": "expression", }, ], + "showXAxisTitle": Array [ + true, + ], + "showYAxisTitle": Array [ + true, + ], + "tickLabelsVisibilitySettings": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "x": Array [ + false, + ], + "y": Array [ + true, + ], + }, + "function": "lens_xy_tickLabelsConfig", + "type": "function", + }, + ], + "type": "expression", + }, + ], "xTitle": Array [ - "col_a", + "", ], "yTitle": Array [ - "col_b", + "", ], }, "function": "lens_xy_chart", 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 c7c173f87ad7c..f0c233b44a285 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 @@ -20,9 +20,14 @@ exports[`xy_expression XYChart component it renders area 1`] = ` } /> @@ -146,9 +151,14 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` } /> @@ -262,9 +272,14 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` } /> @@ -378,9 +393,14 @@ exports[`xy_expression XYChart component it renders line 1`] = ` } /> @@ -504,9 +524,14 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` } /> @@ -628,9 +653,14 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` } /> @@ -752,9 +782,14 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = } /> diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index 77cab1ee21344..fddcad7989b25 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -10,7 +10,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, layerConfig, yAxisConfig } from './types'; +import { legendConfig, layerConfig, yAxisConfig, tickLabelsConfig, gridlinesConfig } from './types'; import { EditorFrameSetup, FormatFactory } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; @@ -39,6 +39,8 @@ export class XyVisualization { ) { expressions.registerFunction(() => legendConfig); expressions.registerFunction(() => yAxisConfig); + expressions.registerFunction(() => tickLabelsConfig); + expressions.registerFunction(() => gridlinesConfig); expressions.registerFunction(() => layerConfig); expressions.registerFunction(() => xyChart); diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index 31b34e41e82db..876d1141740e1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -41,6 +41,8 @@ describe('#toExpression', () => { legend: { position: Position.Bottom, isVisible: true }, preferredSeriesType: 'bar', fittingFunction: 'Carry', + tickLabelsVisibilitySettings: { x: false, y: true }, + gridlinesVisibilitySettings: { x: false, y: true }, layers: [ { layerId: 'first', @@ -77,6 +79,27 @@ describe('#toExpression', () => { ).toEqual('None'); }); + it('should default the showXAxisTitle and showYAxisTitle to true', () => { + const expression = xyVisualization.toExpression( + { + legend: { position: Position.Bottom, isVisible: true }, + preferredSeriesType: 'bar', + layers: [ + { + layerId: 'first', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'a', + accessors: ['b', 'c'], + }, + ], + }, + frame + ) as Ast; + expect(expression.chain[0].arguments.showXAxisTitle[0]).toBe(true); + expect(expression.chain[0].arguments.showYAxisTitle[0]).toBe(true); + }); + it('should not generate an expression when missing x', () => { expect( xyVisualization.toExpression( @@ -140,8 +163,8 @@ describe('#toExpression', () => { expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('b'); expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('c'); expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('d'); - expect(expression.chain[0].arguments.xTitle).toEqual(['col_a']); - expect(expression.chain[0].arguments.yTitle).toEqual(['col_b']); + expect(expression.chain[0].arguments.xTitle).toEqual(['']); + expect(expression.chain[0].arguments.yTitle).toEqual(['']); expect( (expression.chain[0].arguments.layers[0] as Ast).chain[0].arguments.columnToLabel ).toEqual([ @@ -152,4 +175,54 @@ describe('#toExpression', () => { }), ]); }); + + it('should default the tick labels visibility settings to true', () => { + const expression = xyVisualization.toExpression( + { + legend: { position: Position.Bottom, isVisible: true }, + preferredSeriesType: 'bar', + layers: [ + { + layerId: 'first', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'a', + accessors: ['b', 'c'], + }, + ], + }, + frame + ) as Ast; + expect( + (expression.chain[0].arguments.tickLabelsVisibilitySettings[0] as Ast).chain[0].arguments + ).toEqual({ + x: [true], + y: [true], + }); + }); + + it('should default the gridlines visibility settings to true', () => { + const expression = xyVisualization.toExpression( + { + legend: { position: Position.Bottom, isVisible: true }, + preferredSeriesType: 'bar', + layers: [ + { + layerId: 'first', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'a', + accessors: ['b', 'c'], + }, + ], + }, + frame + ) as Ast; + expect( + (expression.chain[0].arguments.gridlinesVisibilitySettings[0] as Ast).chain[0].arguments + ).toEqual({ + x: [true], + y: [true], + }); + }); }); 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 b17704b38cdec..9b9c159af265e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -13,28 +13,6 @@ interface ValidLayer extends LayerConfig { xAccessor: NonNullable; } -function xyTitles(layer: LayerConfig, frame: FramePublicAPI) { - const defaults = { - xTitle: 'x', - yTitle: 'y', - }; - - if (!layer || !layer.accessors.length) { - return defaults; - } - const datasource = frame.datasourceLayers[layer.layerId]; - if (!datasource) { - return defaults; - } - const x = layer.xAccessor ? datasource.getOperationForColumnId(layer.xAccessor) : null; - const y = layer.accessors[0] ? datasource.getOperationForColumnId(layer.accessors[0]) : null; - - return { - xTitle: x ? x.label : defaults.xTitle, - yTitle: y ? y.label : defaults.yTitle, - }; -} - export const toExpression = (state: State, frame: FramePublicAPI): Ast | null => { if (!state || !state.layers.length) { return null; @@ -52,7 +30,7 @@ export const toExpression = (state: State, frame: FramePublicAPI): Ast | null => }); }); - return buildExpression(state, metadata, frame, xyTitles(state.layers[0], frame)); + return buildExpression(state, metadata, frame); }; export function toPreviewExpression(state: State, frame: FramePublicAPI) { @@ -99,8 +77,7 @@ export function getScaleType(metadata: OperationMetadata | null, defaultScale: S export const buildExpression = ( state: State, metadata: Record>, - frame?: FramePublicAPI, - { xTitle, yTitle }: { xTitle: string; yTitle: string } = { xTitle: '', yTitle: '' } + frame?: FramePublicAPI ): Ast | null => { const validLayers = state.layers.filter((layer): layer is ValidLayer => Boolean(layer.xAccessor && layer.accessors.length) @@ -116,8 +93,8 @@ export const buildExpression = ( type: 'function', function: 'lens_xy_chart', arguments: { - xTitle: [xTitle], - yTitle: [yTitle], + xTitle: [state.xTitle || ''], + yTitle: [state.yTitle || ''], legend: [ { type: 'expression', @@ -137,6 +114,38 @@ export const buildExpression = ( }, ], fittingFunction: [state.fittingFunction || 'None'], + showXAxisTitle: [state.showXAxisTitle ?? true], + showYAxisTitle: [state.showYAxisTitle ?? true], + tickLabelsVisibilitySettings: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_tickLabelsConfig', + arguments: { + x: [state?.tickLabelsVisibilitySettings?.x ?? true], + y: [state?.tickLabelsVisibilitySettings?.y ?? true], + }, + }, + ], + }, + ], + gridlinesVisibilitySettings: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_gridlinesConfig', + arguments: { + x: [state?.gridlinesVisibilitySettings?.x ?? true], + y: [state?.gridlinesVisibilitySettings?.y ?? true], + }, + }, + ], + }, + ], layers: validLayers.map((layer) => { const columnToLabel: Record = {}; diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 605119535d1f0..ab689ceb183be 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -75,6 +75,81 @@ export const legendConfig: ExpressionFunctionDefinition< }, }; +export interface AxesSettingsConfig { + x: boolean; + y: boolean; +} + +type TickLabelsConfigResult = AxesSettingsConfig & { type: 'lens_xy_tickLabelsConfig' }; + +export const tickLabelsConfig: ExpressionFunctionDefinition< + 'lens_xy_tickLabelsConfig', + null, + AxesSettingsConfig, + TickLabelsConfigResult +> = { + name: 'lens_xy_tickLabelsConfig', + aliases: [], + type: 'lens_xy_tickLabelsConfig', + help: `Configure the xy chart's tick labels appearance`, + inputTypes: ['null'], + args: { + x: { + types: ['boolean'], + help: i18n.translate('xpack.lens.xyChart.xAxisTickLabels.help', { + defaultMessage: 'Specifies whether or not the tick labels of the x-axis are visible.', + }), + }, + y: { + types: ['boolean'], + help: i18n.translate('xpack.lens.xyChart.yAxisTickLabels.help', { + defaultMessage: 'Specifies whether or not the tick labels of the y-axis are visible.', + }), + }, + }, + fn: function fn(input: unknown, args: AxesSettingsConfig) { + return { + type: 'lens_xy_tickLabelsConfig', + ...args, + }; + }, +}; + +type GridlinesConfigResult = AxesSettingsConfig & { type: 'lens_xy_gridlinesConfig' }; + +export const gridlinesConfig: ExpressionFunctionDefinition< + 'lens_xy_gridlinesConfig', + null, + AxesSettingsConfig, + GridlinesConfigResult +> = { + name: 'lens_xy_gridlinesConfig', + aliases: [], + type: 'lens_xy_gridlinesConfig', + help: `Configure the xy chart's gridlines appearance`, + inputTypes: ['null'], + args: { + x: { + types: ['boolean'], + help: i18n.translate('xpack.lens.xyChart.xAxisGridlines.help', { + defaultMessage: 'Specifies whether or not the gridlines of the x-axis are visible.', + }), + }, + y: { + types: ['boolean'], + help: i18n.translate('xpack.lens.xyChart.yAxisgridlines.help', { + defaultMessage: 'Specifies whether or not the gridlines of the y-axis are visible.', + }), + }, + }, + fn: function fn(input: unknown, args: AxesSettingsConfig) { + return { + type: 'lens_xy_gridlinesConfig', + ...args, + }; + }, +}; + interface AxisConfig { title: string; hide?: boolean; @@ -243,6 +318,10 @@ export interface XYArgs { legend: LegendConfig & { type: 'lens_xy_legendConfig' }; layers: LayerArgs[]; fittingFunction?: FittingFunction; + showXAxisTitle?: boolean; + showYAxisTitle?: boolean; + tickLabelsVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_tickLabelsConfig' }; + gridlinesVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_gridlinesConfig' }; } // Persisted parts of the state @@ -251,6 +330,12 @@ export interface XYState { legend: LegendConfig; fittingFunction?: FittingFunction; layers: LayerConfig[]; + xTitle?: string; + yTitle?: string; + showXAxisTitle?: boolean; + showYAxisTitle?: boolean; + tickLabelsVisibilitySettings?: AxesSettingsConfig; + gridlinesVisibilitySettings?: AxesSettingsConfig; } export type State = XYState; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 375eaf736cc95..31ba1bc83d970 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -109,7 +109,6 @@ describe('XY Config panels', () => { it('should disable the select if there is no unstacked area or line series', () => { const state = testState(); - const component = shallow( { expect(component.find(EuiSuperSelect).prop('disabled')).toEqual(true); }); + + it('should show the values of the X and Y axes titles on the corresponding input text', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find('[data-test-subj="lnsXAxisTitle"]').prop('value')).toBe( + 'My custom X axis title' + ); + expect(component.find('[data-test-subj="lnsYAxisTitle"]').prop('value')).toBe( + 'My custom Y axis title' + ); + }); + + it('should disable the input texts if the switch is off', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find('[data-test-subj="lnsXAxisTitle"]').prop('disabled')).toBe(true); + expect(component.find('[data-test-subj="lnsYAxisTitle"]').prop('disabled')).toBe(true); + }); + + it('has the tick labels buttons enabled', () => { + const state = testState(); + const component = shallow(); + + const options = component + .find('[data-test-subj="lnsTickLabelsSettings"]') + .prop('options') as EuiButtonGroupProps['options']; + + expect(options!.map(({ label }) => label)).toEqual(['X-axis', 'Y-axis']); + + const selections = component + .find('[data-test-subj="lnsTickLabelsSettings"]') + .prop('idToSelectedMap'); + + expect(selections!).toEqual({ x: true, y: true }); + }); + + it('has the gridlines buttons enabled', () => { + const state = testState(); + const component = shallow(); + + const selections = component + .find('[data-test-subj="lnsGridlinesSettings"]') + .prop('idToSelectedMap'); + + expect(selections!).toEqual({ x: true, y: true }); + }); }); }); 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 e4bc6de5cc68a..d64eb9451a50e 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 @@ -5,7 +5,7 @@ */ import './xy_config_panel.scss'; -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { Position } from '@elastic/charts'; import { debounce } from 'lodash'; @@ -24,14 +24,17 @@ import { EuiColorPickerProps, EuiToolTip, EuiIcon, + EuiFieldText, + EuiSwitch, EuiHorizontalRule, + EuiTitle, } from '@elastic/eui'; import { VisualizationLayerWidgetProps, VisualizationDimensionEditorProps, VisualizationToolbarProps, } from '../types'; -import { State, SeriesType, visualizationTypes, YAxisMode } from './types'; +import { State, SeriesType, visualizationTypes, YAxisMode, AxesSettingsConfig } from './types'; import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; import { fittingFunctionDefinitions } from './fitting_functions'; @@ -118,14 +121,117 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { } export function XyToolbar(props: VisualizationToolbarProps) { + const axes = [ + { + id: 'x', + label: 'X-axis', + }, + { + id: 'y', + label: 'Y-axis', + }, + ]; + + const { frame, state, setState } = props; + const [open, setOpen] = useState(false); - const hasNonBarSeries = props.state?.layers.some( + const hasNonBarSeries = state?.layers.some( (layer) => layer.seriesType === 'line' || layer.seriesType === 'area' ); + + const [xAxisTitle, setXAxisTitle] = useState(state?.xTitle); + const [yAxisTitle, setYAxisTitle] = useState(state?.yTitle); + + const xyTitles = useCallback(() => { + const defaults = { + xTitle: xAxisTitle, + yTitle: yAxisTitle, + }; + const layer = state?.layers[0]; + if (!layer || !layer.accessors.length) { + return defaults; + } + const datasource = frame.datasourceLayers[layer.layerId]; + if (!datasource) { + return defaults; + } + const x = layer.xAccessor ? datasource.getOperationForColumnId(layer.xAccessor) : null; + const y = layer.accessors[0] ? datasource.getOperationForColumnId(layer.accessors[0]) : null; + + return { + xTitle: defaults.xTitle || x?.label, + yTitle: defaults.yTitle || y?.label, + }; + /* We want this callback to run only if open changes its state. What we want to accomplish here is to give the user a better UX. + By default these input fields have the axis legends. If the user changes the input text, the axis legends should also change. + BUT if the user cleans up the input text, it should remain empty until the user closes and reopens the panel. + In that case, the default axes legend should appear. */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + const { + xTitle, + yTitle, + }: { xTitle: string | undefined; yTitle: string | undefined } = xyTitles(); + setXAxisTitle(xTitle); + setYAxisTitle(yTitle); + }, [xyTitles]); + + const onXTitleChange = (value: string): void => { + setXAxisTitle(value); + setState({ ...state, xTitle: value }); + }; + + const onYTitleChange = (value: string): void => { + setYAxisTitle(value); + setState({ ...state, yTitle: value }); + }; + + type AxesSettingsConfigKeys = keyof AxesSettingsConfig; + + const tickLabelsVisibilitySettings = { + x: state?.tickLabelsVisibilitySettings?.x ?? true, + y: state?.tickLabelsVisibilitySettings?.y ?? true, + }; + + const onTickLabelsVisibilitySettingsChange = (optionId: string): void => { + const id = optionId as AxesSettingsConfigKeys; + const newTickLabelsVisibilitySettings = { + ...tickLabelsVisibilitySettings, + ...{ + [id]: !tickLabelsVisibilitySettings[id], + }, + }; + setState({ + ...state, + tickLabelsVisibilitySettings: newTickLabelsVisibilitySettings, + }); + }; + + const gridlinesVisibilitySettings = { + x: state?.gridlinesVisibilitySettings?.x ?? true, + y: state?.gridlinesVisibilitySettings?.y ?? true, + }; + + const onGridlinesVisibilitySettingsChange = (optionId: string): void => { + const id = optionId as AxesSettingsConfigKeys; + const newGridlinesVisibilitySettings = { + ...gridlinesVisibilitySettings, + ...{ + [id]: !gridlinesVisibilitySettings[id], + }, + }; + setState({ + ...state, + gridlinesVisibilitySettings: newGridlinesVisibilitySettings, + }); + }; + const legendMode = - props.state?.legend.isVisible && !props.state?.legend.showSingleSeries + state?.legend.isVisible && !state?.legend.showSingleSeries ? 'auto' - : !props.state?.legend.isVisible + : !state?.legend.isVisible ? 'hide' : 'show'; return ( @@ -183,8 +289,8 @@ export function XyToolbar(props: VisualizationToolbarProps) { inputDisplay: title, }; })} - valueOfSelected={props.state?.fittingFunction || 'None'} - onChange={(value) => props.setState({ ...props.state, fittingFunction: value })} + valueOfSelected={state?.fittingFunction || 'None'} + onChange={(value) => setState({ ...state, fittingFunction: value })} itemLayoutAlign="top" hasDividers /> @@ -209,19 +315,19 @@ export function XyToolbar(props: VisualizationToolbarProps) { onChange={(optionId) => { const newMode = legendOptions.find(({ id }) => id === optionId)!.value; if (newMode === 'auto') { - props.setState({ - ...props.state, - legend: { ...props.state.legend, isVisible: true, showSingleSeries: false }, + setState({ + ...state, + legend: { ...state.legend, isVisible: true, showSingleSeries: false }, }); } else if (newMode === 'show') { - props.setState({ - ...props.state, - legend: { ...props.state.legend, isVisible: true, showSingleSeries: true }, + setState({ + ...state, + legend: { ...state.legend, isVisible: true, showSingleSeries: true }, }); } else if (newMode === 'hide') { - props.setState({ - ...props.state, - legend: { ...props.state.legend, isVisible: false, showSingleSeries: false }, + setState({ + ...state, + legend: { ...state.legend, isVisible: false, showSingleSeries: false }, }); } }} @@ -242,15 +348,130 @@ export function XyToolbar(props: VisualizationToolbarProps) { { value: Position.Right, text: 'Right' }, { value: Position.Bottom, text: 'Bottom' }, ]} - value={props.state?.legend.position} + value={state?.legend.position} onChange={(e) => { - props.setState({ - ...props.state, - legend: { ...props.state.legend, position: e.target.value as Position }, + setState({ + ...state, + legend: { ...state.legend, position: e.target.value as Position }, }); }} /> + + + onTickLabelsVisibilitySettingsChange(id)} + buttonSize="compressed" + isFullWidth + type="multi" + /> + + + onGridlinesVisibilitySettingsChange(id)} + buttonSize="compressed" + isFullWidth + type="multi" + /> + + + + + {i18n.translate('xpack.lens.xyChart.axisTitles', { defaultMessage: 'Axis titles' })} + + + + X-axis + + + setState({ ...state, showXAxisTitle: target.checked }) + } + checked={state?.showXAxisTitle ?? true} + /> + + + } + > + onXTitleChange(target.value)} + aria-label={i18n.translate('xpack.lens.xyChart.overwriteXaxis', { + defaultMessage: 'Overwrite X-axis title', + })} + /> + + + Y-axis + + + setState({ ...state, showYAxisTitle: target.checked }) + } + checked={state?.showYAxisTitle ?? true} + /> + + + } + > + onYTitleChange(target.value)} + aria-label={i18n.translate('xpack.lens.xyChart.overwriteYaxis', { + defaultMessage: 'Overwrite Y-axis title', + })} + /> + 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 c880cbb641e5d..ba1ff6a1df030 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 @@ -22,7 +22,16 @@ import { LensMultiTable } from '../types'; import { KibanaDatatable, KibanaDatatableRow } from '../../../../../src/plugins/expressions/public'; import React from 'react'; import { shallow } from 'enzyme'; -import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerArgs } from './types'; +import { + XYArgs, + LegendConfig, + legendConfig, + layerConfig, + LayerArgs, + AxesSettingsConfig, + tickLabelsConfig, + gridlinesConfig, +} from './types'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; @@ -211,6 +220,18 @@ const createArgsWithLayers = (layers: LayerArgs[] = [sampleLayer]): XYArgs => ({ isVisible: false, position: Position.Top, }, + showXAxisTitle: true, + showYAxisTitle: true, + tickLabelsVisibilitySettings: { + type: 'lens_xy_tickLabelsConfig', + x: true, + y: false, + }, + gridlinesVisibilitySettings: { + type: 'lens_xy_gridlinesConfig', + x: true, + y: false, + }, layers, }); @@ -267,6 +288,34 @@ describe('xy_expression', () => { }); }); + test('tickLabelsConfig produces the correct arguments', () => { + const args: AxesSettingsConfig = { + x: true, + y: false, + }; + + const result = tickLabelsConfig.fn(null, args, createMockExecutionContext()); + + expect(result).toEqual({ + type: 'lens_xy_tickLabelsConfig', + ...args, + }); + }); + + test('gridlinesConfig produces the correct arguments', () => { + const args: AxesSettingsConfig = { + x: true, + y: false, + }; + + const result = gridlinesConfig.fn(null, args, createMockExecutionContext()); + + expect(result).toEqual({ + type: 'lens_xy_gridlinesConfig', + ...args, + }); + }); + describe('xyChart', () => { test('it renders with the specified data and args', () => { const { data, args } = sampleArgs(); @@ -1365,6 +1414,35 @@ describe('xy_expression', () => { expect(convertSpy).toHaveBeenCalledWith('I'); }); + test('it should not pass the formatter function to the x axis if the visibility of the tick labels is off', () => { + const { data, args } = sampleArgs(); + + args.tickLabelsVisibilitySettings = { x: false, y: true, type: 'lens_xy_tickLabelsConfig' }; + + const instance = shallow( + + ); + + const tickFormatter = instance.find(Axis).first().prop('tickFormat'); + + if (!tickFormatter) { + throw new Error('tickFormatter prop not found'); + } + + tickFormatter('I'); + + expect(convertSpy).toHaveBeenCalledTimes(0); + }); + test('it should remove invalid rows', () => { const data: LensMultiTable = { type: 'lens_multitable', @@ -1400,6 +1478,16 @@ describe('xy_expression', () => { xTitle: '', yTitle: '', legend: { type: 'lens_xy_legendConfig', isVisible: false, position: Position.Top }, + tickLabelsVisibilitySettings: { + type: 'lens_xy_tickLabelsConfig', + x: true, + y: true, + }, + gridlinesVisibilitySettings: { + type: 'lens_xy_gridlinesConfig', + x: true, + y: false, + }, layers: [ { layerId: 'first', @@ -1469,6 +1557,16 @@ describe('xy_expression', () => { xTitle: '', yTitle: '', legend: { type: 'lens_xy_legendConfig', isVisible: false, position: Position.Top }, + tickLabelsVisibilitySettings: { + type: 'lens_xy_tickLabelsConfig', + x: true, + y: false, + }, + gridlinesVisibilitySettings: { + type: 'lens_xy_gridlinesConfig', + x: true, + y: false, + }, layers: [ { layerId: 'first', @@ -1525,6 +1623,16 @@ describe('xy_expression', () => { xTitle: '', yTitle: '', legend: { type: 'lens_xy_legendConfig', isVisible: true, position: Position.Top }, + tickLabelsVisibilitySettings: { + type: 'lens_xy_tickLabelsConfig', + x: true, + y: false, + }, + gridlinesVisibilitySettings: { + type: 'lens_xy_gridlinesConfig', + x: true, + y: false, + }, layers: [ { layerId: 'first', @@ -1683,5 +1791,68 @@ describe('xy_expression', () => { expect(component.find(LineSeries).prop('fit')).toEqual({ type: Fit.None }); }); + + test('it should apply the xTitle if is specified', () => { + const { data, args } = sampleArgs(); + + args.xTitle = 'My custom x-axis title'; + + const component = shallow( + + ); + + expect(component.find(Axis).at(0).prop('title')).toEqual('My custom x-axis title'); + }); + + test('it should hide the X axis title if the corresponding switch is off', () => { + const { data, args } = sampleArgs(); + + args.showXAxisTitle = false; + + const component = shallow( + + ); + + expect(component.find(Axis).at(0).prop('title')).toEqual(undefined); + }); + + test('it should show the X axis gridlines if the setting is on', () => { + const { data, args } = sampleArgs(); + + args.gridlinesVisibilitySettings = { x: true, y: false, type: 'lens_xy_gridlinesConfig' }; + + const component = shallow( + + ); + + expect(component.find(Axis).at(0).prop('showGridLines')).toBeTruthy(); + }); }); }); 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 a3468e109e75b..2037a3dbe6623 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -102,6 +102,30 @@ export const xyChart: ExpressionFunctionDefinition< defaultMessage: 'Define how missing values are treated', }), }, + tickLabelsVisibilitySettings: { + types: ['lens_xy_tickLabelsConfig'], + help: i18n.translate('xpack.lens.xyChart.tickLabelsSettings.help', { + defaultMessage: 'Show x and y axes tick labels', + }), + }, + gridlinesVisibilitySettings: { + types: ['lens_xy_gridlinesConfig'], + help: i18n.translate('xpack.lens.xyChart.gridlinesSettings.help', { + defaultMessage: 'Show x and y axes gridlines', + }), + }, + showXAxisTitle: { + types: ['boolean'], + help: i18n.translate('xpack.lens.xyChart.showXAxisTitle.help', { + defaultMessage: 'Show x axis title', + }), + }, + showYAxisTitle: { + types: ['boolean'], + help: i18n.translate('xpack.lens.xyChart.showYAxisTitle.help', { + defaultMessage: 'Show y axis title', + }), + }, layers: { // eslint-disable-next-line @typescript-eslint/no-explicit-any types: ['lens_xy_layer'] as any, @@ -199,7 +223,7 @@ export function XYChart({ onClickValue, onSelectRange, }: XYChartRenderProps) { - const { legend, layers, fittingFunction } = args; + const { legend, layers, fittingFunction, gridlinesVisibilitySettings } = args; const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); @@ -237,7 +261,10 @@ export function XYChart({ shouldRotate ); - const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; + const xTitle = args.xTitle || (xAxisColumn && xAxisColumn.name); + const showXAxisTitle = args.showXAxisTitle ?? true; + const showYAxisTitle = args.showYAxisTitle ?? true; + const tickLabelsVisibilitySettings = args.tickLabelsVisibilitySettings || { x: true, y: true }; function calculateMinInterval() { // check all the tables to see if all of the rows have the same timestamp @@ -279,6 +306,22 @@ export function XYChart({ } : undefined; + const getYAxesTitles = ( + axisSeries: Array<{ layer: string; accessor: string }>, + index: number + ) => { + if (index > 0 && args.yTitle) return; + return ( + args.yTitle || + axisSeries + .map( + (series) => + data.tables[series.layer].columns.find((column) => column.id === series.accessor)?.name + ) + .filter((name) => Boolean(name))[0] + ); + }; + return ( xAxisFormatter.convert(d)} + tickFormat={tickLabelsVisibilitySettings?.x ? (d) => xAxisFormatter.convert(d) : () => ''} /> {yAxesConfiguration.map((axis, index) => ( @@ -389,18 +433,10 @@ export function XYChart({ id={axis.groupId} groupId={axis.groupId} position={axis.position} - title={ - axis.series - .map( - (series) => - data.tables[series.layer].columns.find((column) => column.id === series.accessor) - ?.name - ) - .filter((name) => Boolean(name))[0] || args.yTitle - } - showGridLines={false} + title={showYAxisTitle ? getYAxesTitles(axis.series, index) : undefined} + showGridLines={gridlinesVisibilitySettings?.y} hide={filteredLayers[0].hide} - tickFormat={(d) => axis.formatter.convert(d)} + tickFormat={tickLabelsVisibilitySettings?.y ? (d) => axis.formatter.convert(d) : () => ''} /> ))} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index 7b3398658a500..632f6fc8861a4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -445,6 +445,10 @@ describe('xy_suggestions', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, fittingFunction: 'None', + showXAxisTitle: true, + showYAxisTitle: true, + gridlinesVisibilitySettings: { x: true, y: true }, + tickLabelsVisibilitySettings: { x: true, y: false }, preferredSeriesType: 'bar', layers: [ { @@ -483,6 +487,10 @@ describe('xy_suggestions', () => { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', fittingFunction: 'None', + showXAxisTitle: true, + showYAxisTitle: true, + gridlinesVisibilitySettings: { x: true, y: true }, + tickLabelsVisibilitySettings: { x: true, y: false }, layers: [ { accessors: ['price', 'quantity'], @@ -592,6 +600,10 @@ describe('xy_suggestions', () => { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', fittingFunction: 'None', + showXAxisTitle: true, + showYAxisTitle: true, + gridlinesVisibilitySettings: { x: true, y: true }, + tickLabelsVisibilitySettings: { x: true, y: false }, layers: [ { accessors: ['price', 'quantity'], @@ -631,6 +643,10 @@ describe('xy_suggestions', () => { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', fittingFunction: 'None', + showXAxisTitle: true, + showYAxisTitle: true, + gridlinesVisibilitySettings: { x: true, y: true }, + tickLabelsVisibilitySettings: { x: true, y: false }, layers: [ { accessors: ['price'], @@ -671,6 +687,10 @@ describe('xy_suggestions', () => { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', fittingFunction: 'None', + showXAxisTitle: true, + showYAxisTitle: true, + gridlinesVisibilitySettings: { x: true, y: true }, + tickLabelsVisibilitySettings: { x: true, y: false }, layers: [ { accessors: ['price', 'quantity'], 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 1be8d566a8b64..387d56c03e31a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -407,6 +407,18 @@ function buildSuggestion({ const state: State = { legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right }, fittingFunction: currentState?.fittingFunction || 'None', + xTitle: currentState?.xTitle, + yTitle: currentState?.yTitle, + showXAxisTitle: currentState?.showXAxisTitle ?? true, + showYAxisTitle: currentState?.showYAxisTitle ?? true, + tickLabelsVisibilitySettings: currentState?.tickLabelsVisibilitySettings || { + x: true, + y: true, + }, + gridlinesVisibilitySettings: currentState?.gridlinesVisibilitySettings || { + x: true, + y: true, + }, preferredSeriesType: seriesType, layers: Object.keys(existingLayer).length ? keptLayers : [...keptLayers, newLayer], };