[Lens] Add styling options for x and y axes on the settings popover (#71829)

* [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 <elasticmachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2020-08-11 20:10:35 +03:00 committed by GitHub
parent 75b8a3cb71
commit 0cfc7b464c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 852 additions and 77 deletions

View file

@ -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",

View file

@ -20,9 +20,14 @@ exports[`xy_expression XYChart component it renders area 1`] = `
}
/>
<Connect(SpecInstance)
gridLineStyle={
Object {
"strokeWidth": 2,
}
}
id="x"
position="bottom"
showGridLines={false}
showGridLines={true}
tickFormat={[Function]}
title="c"
/>
@ -146,9 +151,14 @@ exports[`xy_expression XYChart component it renders bar 1`] = `
}
/>
<Connect(SpecInstance)
gridLineStyle={
Object {
"strokeWidth": 2,
}
}
id="x"
position="bottom"
showGridLines={false}
showGridLines={true}
tickFormat={[Function]}
title="c"
/>
@ -262,9 +272,14 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = `
}
/>
<Connect(SpecInstance)
gridLineStyle={
Object {
"strokeWidth": 2,
}
}
id="x"
position="left"
showGridLines={false}
showGridLines={true}
tickFormat={[Function]}
title="c"
/>
@ -378,9 +393,14 @@ exports[`xy_expression XYChart component it renders line 1`] = `
}
/>
<Connect(SpecInstance)
gridLineStyle={
Object {
"strokeWidth": 2,
}
}
id="x"
position="bottom"
showGridLines={false}
showGridLines={true}
tickFormat={[Function]}
title="c"
/>
@ -504,9 +524,14 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = `
}
/>
<Connect(SpecInstance)
gridLineStyle={
Object {
"strokeWidth": 2,
}
}
id="x"
position="bottom"
showGridLines={false}
showGridLines={true}
tickFormat={[Function]}
title="c"
/>
@ -628,9 +653,14 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = `
}
/>
<Connect(SpecInstance)
gridLineStyle={
Object {
"strokeWidth": 2,
}
}
id="x"
position="bottom"
showGridLines={false}
showGridLines={true}
tickFormat={[Function]}
title="c"
/>
@ -752,9 +782,14 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] =
}
/>
<Connect(SpecInstance)
gridLineStyle={
Object {
"strokeWidth": 2,
}
}
id="x"
position="left"
showGridLines={false}
showGridLines={true}
tickFormat={[Function]}
title="c"
/>

View file

@ -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);

View file

@ -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],
});
});
});

View file

@ -13,28 +13,6 @@ interface ValidLayer extends LayerConfig {
xAccessor: NonNullable<LayerConfig['xAccessor']>;
}
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<string, Record<string, OperationMetadata | null>>,
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<string, string> = {};

View file

@ -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;

View file

@ -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(
<XyToolbar
frame={frame}
@ -127,5 +126,73 @@ describe('XY Config panels', () => {
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(
<XyToolbar
frame={frame}
setState={jest.fn()}
state={{
...state,
xTitle: 'My custom X axis title',
yTitle: 'My custom Y axis title',
}}
/>
);
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(
<XyToolbar
frame={frame}
setState={jest.fn()}
state={{
...state,
showXAxisTitle: false,
showYAxisTitle: false,
}}
/>
);
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(<XyToolbar frame={frame} setState={jest.fn()} state={state} />);
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(<XyToolbar frame={frame} setState={jest.fn()} state={state} />);
const selections = component
.find('[data-test-subj="lnsGridlinesSettings"]')
.prop('idToSelectedMap');
expect(selections!).toEqual({ x: true, y: true });
});
});
});

View file

@ -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<State>) {
}
export function XyToolbar(props: VisualizationToolbarProps<State>) {
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<State>) {
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<State>) {
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<State>) {
{ 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 },
});
}}
/>
</EuiFormRow>
<EuiHorizontalRule margin="s" />
<EuiFormRow
display="columnCompressed"
label={i18n.translate('xpack.lens.xyChart.TickLabels', {
defaultMessage: 'Tick Labels',
})}
>
<EuiButtonGroup
name="lnsTickLabels"
data-test-subj="lnsTickLabelsSettings"
legend="Group of Tick Labels Visibility Settings"
options={axes}
idToSelectedMap={tickLabelsVisibilitySettings}
onChange={(id) => onTickLabelsVisibilitySettingsChange(id)}
buttonSize="compressed"
isFullWidth
type="multi"
/>
</EuiFormRow>
<EuiFormRow
display="columnCompressed"
label={i18n.translate('xpack.lens.xyChart.Gridlines', {
defaultMessage: 'Gridlines',
})}
>
<EuiButtonGroup
name="lnsGridlines"
data-test-subj="lnsGridlinesSettings"
legend="Group of Gridlines Visibility Settings"
options={axes}
idToSelectedMap={gridlinesVisibilitySettings}
onChange={(id) => onGridlinesVisibilitySettingsChange(id)}
buttonSize="compressed"
isFullWidth
type="multi"
/>
</EuiFormRow>
<EuiHorizontalRule margin="s" />
<EuiTitle size="xxs">
<span>
{i18n.translate('xpack.lens.xyChart.axisTitles', { defaultMessage: 'Axis titles' })}
</span>
</EuiTitle>
<EuiFormRow
display="columnCompressed"
label={
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>X-axis</EuiFlexItem>
<EuiFlexItem>
<EuiSwitch
compressed
data-test-subj="lnsshowXAxisTitleSwitch"
showLabel={false}
label={i18n.translate('xpack.lens.xyChart.showXAxisTitleLabel', {
defaultMessage: 'show X-axis Title',
})}
onChange={({ target }) =>
setState({ ...state, showXAxisTitle: target.checked })
}
checked={state?.showXAxisTitle ?? true}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
>
<EuiFieldText
data-test-subj="lnsXAxisTitle"
compressed
placeholder={i18n.translate('xpack.lens.xyChart.overwriteXaxis', {
defaultMessage: 'Overwrite X-axis title',
})}
value={xAxisTitle || ''}
disabled={state && 'showXAxisTitle' in state ? !state.showXAxisTitle : false}
onChange={({ target }) => onXTitleChange(target.value)}
aria-label={i18n.translate('xpack.lens.xyChart.overwriteXaxis', {
defaultMessage: 'Overwrite X-axis title',
})}
/>
</EuiFormRow>
<EuiFormRow
display="columnCompressed"
label={
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>Y-axis</EuiFlexItem>
<EuiFlexItem>
<EuiSwitch
compressed
data-test-subj="lnsShowYAxisTitleSwitch"
showLabel={false}
label={i18n.translate('xpack.lens.xyChart.ShowYAxisTitleLabel', {
defaultMessage: 'Show Y-axis Title',
})}
onChange={({ target }) =>
setState({ ...state, showYAxisTitle: target.checked })
}
checked={state?.showYAxisTitle ?? true}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
>
<EuiFieldText
data-test-subj="lnsYAxisTitle"
compressed
placeholder={i18n.translate('xpack.lens.xyChart.overwriteYaxis', {
defaultMessage: 'Overwrite Y-axis title',
})}
value={yAxisTitle || ''}
disabled={state && 'showYAxisTitle' in state ? !state.showYAxisTitle : false}
onChange={({ target }) => onYTitleChange(target.value)}
aria-label={i18n.translate('xpack.lens.xyChart.overwriteYaxis', {
defaultMessage: 'Overwrite Y-axis title',
})}
/>
</EuiFormRow>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -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(
<XYChart
data={{ ...data }}
args={{ ...args }}
formatFactory={getFormatSpy}
timeZone="UTC"
chartsThemeService={chartsThemeService}
histogramBarTarget={50}
onClickValue={onClickValue}
onSelectRange={onSelectRange}
/>
);
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(
<XYChart
data={{ ...data }}
args={{ ...args }}
formatFactory={getFormatSpy}
timeZone="UTC"
chartsThemeService={chartsThemeService}
histogramBarTarget={50}
onClickValue={onClickValue}
onSelectRange={onSelectRange}
/>
);
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(
<XYChart
data={{ ...data }}
args={{ ...args }}
formatFactory={getFormatSpy}
timeZone="UTC"
chartsThemeService={chartsThemeService}
histogramBarTarget={50}
onClickValue={onClickValue}
onSelectRange={onSelectRange}
/>
);
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(
<XYChart
data={{ ...data }}
args={{ ...args }}
formatFactory={getFormatSpy}
timeZone="UTC"
chartsThemeService={chartsThemeService}
histogramBarTarget={50}
onClickValue={onClickValue}
onSelectRange={onSelectRange}
/>
);
expect(component.find(Axis).at(0).prop('showGridLines')).toBeTruthy();
});
});
});

View file

@ -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 (
<Chart>
<Settings
@ -377,10 +420,11 @@ export function XYChart({
<Axis
id="x"
position={shouldRotate ? Position.Left : Position.Bottom}
title={xTitle}
showGridLines={false}
title={showXAxisTitle ? xTitle : undefined}
showGridLines={gridlinesVisibilitySettings?.x}
gridLineStyle={{ strokeWidth: 2 }}
hide={filteredLayers[0].hide}
tickFormat={(d) => 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) : () => ''}
/>
))}

View file

@ -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'],

View file

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