[Lens] Supports long legend values (#107894)

* [Lens] Supports multilines legend

* Add a truncate legends switch

* Add more unit tests

* Add tooltip condition

* Adress PR comments

* Apply PR comments

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2021-08-17 16:10:18 +03:00 committed by GitHub
parent 02817055c0
commit 8939ee6c24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 342 additions and 3 deletions

View file

@ -19,6 +19,14 @@ export interface HeatmapLegendConfig {
* Position of the legend relative to the chart
*/
position: Position;
/**
* Defines the number of lines per legend item
*/
maxLines?: number;
/**
* Defines if the legend items should be truncated
*/
shouldTruncate?: boolean;
}
export type HeatmapLegendConfigResult = HeatmapLegendConfig & {
@ -54,6 +62,19 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition<
defaultMessage: 'Specifies the legend position.',
}),
},
maxLines: {
types: ['number'],
help: i18n.translate('xpack.lens.heatmapChart.legend.maxLines.help', {
defaultMessage: 'Specifies the number of lines per legend item.',
}),
},
shouldTruncate: {
types: ['boolean'],
default: true,
help: i18n.translate('xpack.lens.heatmapChart.legend.shouldTruncate.help', {
defaultMessage: 'Specifies whether or not the legend items should be truncated.',
}),
},
},
fn(input, args) {
return {

View file

@ -74,6 +74,14 @@ export const pie: ExpressionFunctionDefinition<
types: ['boolean'],
help: '',
},
legendMaxLines: {
types: ['number'],
help: '',
},
truncateLegend: {
types: ['boolean'],
help: '',
},
legendPosition: {
types: ['string'],
options: [Position.Top, Position.Right, Position.Bottom, Position.Left],

View file

@ -17,6 +17,8 @@ export interface SharedPieLayerState {
legendPosition?: 'left' | 'right' | 'top' | 'bottom';
nestedLegend?: boolean;
percentDecimals?: number;
legendMaxLines?: number;
truncateLegend?: boolean;
}
export type PieLayerState = SharedPieLayerState & {

View file

@ -37,6 +37,14 @@ export interface LegendConfig {
* Number of columns when legend is set inside chart
*/
floatingColumns?: number;
/**
* Maximum number of lines per legend item
*/
maxLines?: number;
/**
* Flag whether the legend items are truncated or not
*/
shouldTruncate?: boolean;
}
export type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' };
@ -100,6 +108,19 @@ export const legendConfig: ExpressionFunctionDefinition<
defaultMessage: 'Specifies the number of columns when legend is displayed inside chart.',
}),
},
maxLines: {
types: ['number'],
help: i18n.translate('xpack.lens.xyChart.maxLines.help', {
defaultMessage: 'Specifies the number of lines per legend item.',
}),
},
shouldTruncate: {
types: ['boolean'],
default: true,
help: i18n.translate('xpack.lens.xyChart.shouldTruncate.help', {
defaultMessage: 'Specifies whether the legend items will be truncated or not',
}),
},
},
fn: function fn(input: unknown, args: LegendConfig) {
return {

View file

@ -321,6 +321,12 @@ export const HeatmapComponent: FC<HeatmapRenderProps> = ({
showLegend={args.legend.isVisible}
legendPosition={args.legend.position}
debugState={window._echDebugStateFlag ?? false}
theme={{
...chartTheme,
legend: {
labelOptions: { maxLines: args.legend.shouldTruncate ? args.legend?.maxLines ?? 1 : 0 },
},
}}
/>
<Heatmap
id={tableId}

View file

@ -65,6 +65,21 @@ export const HeatmapToolbar = memo(
legend: { ...state.legend, position: id as Position },
});
}}
maxLines={state?.legend.maxLines}
onMaxLinesChange={(val) => {
setState({
...state,
legend: { ...state.legend, maxLines: val },
});
}}
shouldTruncate={state?.legend.shouldTruncate ?? true}
onTruncateLegendChange={() => {
const current = state.legend.shouldTruncate ?? true;
setState({
...state,
legend: { ...state.legend, shouldTruncate: !current },
});
}}
/>
</EuiFlexGroup>
</EuiFlexItem>

View file

@ -32,6 +32,8 @@ function exampleState(): HeatmapVisualizationState {
isVisible: true,
position: Position.Right,
type: LEGEND_FUNCTION,
maxLines: 1,
shouldTruncate: true,
},
gridConfig: {
type: HEATMAP_GRID_FUNCTION,
@ -63,6 +65,8 @@ describe('heatmap', () => {
isVisible: true,
position: Position.Right,
type: LEGEND_FUNCTION,
maxLines: 1,
shouldTruncate: true,
},
gridConfig: {
type: HEATMAP_GRID_FUNCTION,

View file

@ -70,6 +70,8 @@ function getInitialState(): Omit<HeatmapVisualizationState, 'layerId' | 'layerTy
legend: {
isVisible: true,
position: Position.Right,
maxLines: 1,
shouldTruncate: true,
type: LEGEND_FUNCTION,
},
gridConfig: {

View file

@ -62,6 +62,8 @@ describe('PieVisualization component', () => {
numberDisplay: 'hidden',
categoryDisplay: 'default',
legendDisplay: 'default',
legendMaxLines: 1,
truncateLegend: true,
nestedLegend: false,
percentDecimals: 3,
hideLabels: false,
@ -106,6 +108,20 @@ describe('PieVisualization component', () => {
expect(component.find(Settings).prop('showLegend')).toEqual(false);
});
test('it sets the correct lines per legend item', () => {
const component = shallow(<PieComponent args={args} {...getDefaultArgs()} />);
expect(component.find(Settings).prop('theme')).toEqual({
background: {
color: undefined,
},
legend: {
labelOptions: {
maxLines: 1,
},
},
});
});
test('it calls the color function with the right series layers', () => {
const defaultArgs = getDefaultArgs();
const component = shallow(

View file

@ -75,6 +75,8 @@ export function PieComponent(
legendPosition,
nestedLegend,
percentDecimals,
legendMaxLines,
truncateLegend,
hideLabels,
palette,
} = props.args;
@ -297,6 +299,9 @@ export function PieComponent(
...chartTheme.background,
color: undefined, // removes background for embeddables
},
legend: {
labelOptions: { maxLines: truncateLegend ? legendMaxLines ?? 1 : 0 },
},
}}
baseTheme={chartBaseTheme}
/>

View file

@ -494,6 +494,8 @@ describe('suggestions', () => {
categoryDisplay: 'inside',
legendDisplay: 'show',
percentDecimals: 0,
legendMaxLines: 1,
truncateLegend: true,
nestedLegend: true,
},
],
@ -516,6 +518,8 @@ describe('suggestions', () => {
categoryDisplay: 'inside',
legendDisplay: 'show',
percentDecimals: 0,
legendMaxLines: 1,
truncateLegend: true,
nestedLegend: true,
},
],
@ -684,6 +688,8 @@ describe('suggestions', () => {
categoryDisplay: 'inside',
legendDisplay: 'show',
percentDecimals: 0,
legendMaxLines: 1,
truncateLegend: true,
nestedLegend: true,
},
],
@ -705,6 +711,8 @@ describe('suggestions', () => {
categoryDisplay: 'default', // This is changed
legendDisplay: 'show',
percentDecimals: 0,
legendMaxLines: 1,
truncateLegend: true,
nestedLegend: true,
},
],

View file

@ -56,6 +56,8 @@ function expressionHelper(
legendDisplay: [layer.legendDisplay],
legendPosition: [layer.legendPosition || 'right'],
percentDecimals: [layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS],
legendMaxLines: [layer.legendMaxLines ?? 1],
truncateLegend: [layer.truncateLegend ?? true],
nestedLegend: [!!layer.nestedLegend],
...(state.palette
? {

View file

@ -220,6 +220,21 @@ export function PieToolbar(props: VisualizationToolbarProps<PieVisualizationStat
layers: [{ ...layer, nestedLegend: !layer.nestedLegend }],
});
}}
shouldTruncate={layer.truncateLegend ?? true}
onTruncateLegendChange={() => {
const current = layer.truncateLegend ?? true;
setState({
...state,
layers: [{ ...layer, truncateLegend: !current }],
});
}}
maxLines={layer?.legendMaxLines}
onMaxLinesChange={(val) => {
setState({
...state,
layers: [{ ...layer, legendMaxLines: val }],
});
}}
/>
</EuiFlexGroup>
);

View file

@ -7,7 +7,11 @@
import React from 'react';
import { shallowWithIntl as shallow } from '@kbn/test/jest';
import { LegendSettingsPopover, LegendSettingsPopoverProps } from './legend_settings_popover';
import {
LegendSettingsPopover,
LegendSettingsPopoverProps,
MaxLinesInput,
} from './legend_settings_popover';
describe('Legend Settings', () => {
const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [
@ -50,6 +54,41 @@ describe('Legend Settings', () => {
expect(props.onDisplayChange).toHaveBeenCalled();
});
it('should have default the max lines input to 1 when no value is given', () => {
const component = shallow(<LegendSettingsPopover {...props} />);
expect(component.find(MaxLinesInput).prop('value')).toEqual(1);
});
it('should have the `Truncate legend text` switch enabled by default', () => {
const component = shallow(<LegendSettingsPopover {...props} />);
expect(
component.find('[data-test-subj="lens-legend-truncate-switch"]').prop('checked')
).toEqual(true);
});
it('should set the truncate switch state when truncate prop value is false', () => {
const component = shallow(<LegendSettingsPopover {...props} shouldTruncate={false} />);
expect(
component.find('[data-test-subj="lens-legend-truncate-switch"]').prop('checked')
).toEqual(false);
});
it('should have disabled the max lines input when truncate is set to false', () => {
const component = shallow(<LegendSettingsPopover {...props} shouldTruncate={false} />);
expect(component.find(MaxLinesInput).prop('isDisabled')).toEqual(true);
});
it('should have called the onTruncateLegendChange function on truncate switch change', () => {
const nestedProps = {
...props,
shouldTruncate: true,
onTruncateLegendChange: jest.fn(),
};
const component = shallow(<LegendSettingsPopover {...nestedProps} />);
component.find('[data-test-subj="lens-legend-truncate-switch"]').simulate('change');
expect(nestedProps.onTruncateLegendChange).toHaveBeenCalled();
});
it('should enable the Nested Legend Switch when renderNestedLegendSwitch prop is true', () => {
const component = shallow(<LegendSettingsPopover {...props} renderNestedLegendSwitch />);
expect(component.find('[data-test-subj="lens-legend-nested-switch"]')).toHaveLength(1);

View file

@ -7,12 +7,19 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiButtonGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui';
import {
EuiFormRow,
EuiButtonGroup,
EuiSwitch,
EuiSwitchEvent,
EuiFieldNumber,
} from '@elastic/eui';
import { Position, VerticalAlignment, HorizontalAlignment } from '@elastic/charts';
import { ToolbarPopover } from '../shared_components';
import { LegendLocationSettings } from './legend_location_settings';
import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public';
import { TooltipWrapper } from './tooltip_wrapper';
import { useDebouncedValue } from './debounced_value';
export interface LegendSettingsPopoverProps {
/**
@ -64,9 +71,25 @@ export interface LegendSettingsPopoverProps {
*/
floatingColumns?: number;
/**
* Callback on horizontal alignment option change
* Callback on alignment option change
*/
onFloatingColumnsChange?: (value: number) => void;
/**
* Sets the number of lines per legend item
*/
maxLines?: number;
/**
* Callback on max lines option change
*/
onMaxLinesChange?: (value: number) => void;
/**
* Defines if the legend items will be truncated or not
*/
shouldTruncate?: boolean;
/**
* Callback on nested switch status change
*/
onTruncateLegendChange?: (event: EuiSwitchEvent) => void;
/**
* If true, nested legend switch is rendered
*/
@ -97,6 +120,38 @@ export interface LegendSettingsPopoverProps {
groupPosition?: ToolbarButtonProps['groupPosition'];
}
const DEFAULT_TRUNCATE_LINES = 1;
const MAX_TRUNCATE_LINES = 5;
const MIN_TRUNCATE_LINES = 1;
export const MaxLinesInput = ({
value,
setValue,
isDisabled,
}: {
value: number;
setValue: (value: number) => void;
isDisabled: boolean;
}) => {
const { inputValue, handleInputChange } = useDebouncedValue({ value, onChange: setValue });
return (
<EuiFieldNumber
data-test-subj="lens-legend-max-lines-input"
value={inputValue}
min={MIN_TRUNCATE_LINES}
max={MAX_TRUNCATE_LINES}
compressed
disabled={isDisabled}
onChange={(e) => {
const val = Number(e.target.value);
// we want to automatically change the values to the limits
// if the user enters a value that is outside the limits
handleInputChange(Math.min(MAX_TRUNCATE_LINES, Math.max(val, MIN_TRUNCATE_LINES)));
}}
/>
);
};
export const LegendSettingsPopover: React.FunctionComponent<LegendSettingsPopoverProps> = ({
legendOptions,
mode,
@ -117,6 +172,10 @@ export const LegendSettingsPopover: React.FunctionComponent<LegendSettingsPopove
onValueInLegendChange = () => {},
renderValueInLegendSwitch,
groupPosition = 'right',
maxLines,
onMaxLinesChange = () => {},
shouldTruncate,
onTruncateLegendChange = () => {},
}) => {
return (
<ToolbarPopover
@ -158,6 +217,63 @@ export const LegendSettingsPopover: React.FunctionComponent<LegendSettingsPopove
position={position}
onPositionChange={onPositionChange}
/>
<EuiFormRow
display="columnCompressedSwitch"
label={i18n.translate('xpack.lens.shared.truncateLegend', {
defaultMessage: 'Truncate text',
})}
>
<TooltipWrapper
tooltipContent={i18n.translate('xpack.lens.shared.legendVisibleTooltip', {
defaultMessage: 'Requires legend to be shown',
})}
condition={mode === 'hide'}
position="top"
delay="regular"
display="block"
>
<EuiSwitch
compressed
label={i18n.translate('xpack.lens.shared.truncateLegend', {
defaultMessage: 'Truncate text',
})}
data-test-subj="lens-legend-truncate-switch"
showLabel={false}
disabled={mode === 'hide'}
checked={shouldTruncate ?? true}
onChange={onTruncateLegendChange}
/>
</TooltipWrapper>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.lens.shared.maxLinesLabel', {
defaultMessage: 'Maximum lines',
})}
fullWidth
display="columnCompressed"
>
<TooltipWrapper
tooltipContent={
mode === 'hide'
? i18n.translate('xpack.lens.shared.legendVisibleTooltip', {
defaultMessage: 'Requires legend to be shown',
})
: i18n.translate('xpack.lens.shared.legendIsTruncated', {
defaultMessage: 'Requires text to be truncated',
})
}
condition={mode === 'hide' || !shouldTruncate}
position="top"
delay="regular"
display="block"
>
<MaxLinesInput
value={maxLines ?? DEFAULT_TRUNCATE_LINES}
setValue={onMaxLinesChange}
isDisabled={mode === 'hide' || !shouldTruncate}
/>
</TooltipWrapper>
</EuiFormRow>
{renderNestedLegendSwitch && (
<EuiFormRow
display="columnCompressedSwitch"

View file

@ -27,6 +27,11 @@ exports[`xy_expression XYChart component it renders area 1`] = `
"color": undefined,
},
"barSeriesStyle": Object {},
"legend": Object {
"labelOptions": Object {
"maxLines": 0,
},
},
}
}
tooltip={
@ -245,6 +250,11 @@ exports[`xy_expression XYChart component it renders bar 1`] = `
"color": undefined,
},
"barSeriesStyle": Object {},
"legend": Object {
"labelOptions": Object {
"maxLines": 0,
},
},
}
}
tooltip={
@ -477,6 +487,11 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = `
"color": undefined,
},
"barSeriesStyle": Object {},
"legend": Object {
"labelOptions": Object {
"maxLines": 0,
},
},
}
}
tooltip={
@ -709,6 +724,11 @@ exports[`xy_expression XYChart component it renders line 1`] = `
"color": undefined,
},
"barSeriesStyle": Object {},
"legend": Object {
"labelOptions": Object {
"maxLines": 0,
},
},
}
}
tooltip={
@ -927,6 +947,11 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = `
"color": undefined,
},
"barSeriesStyle": Object {},
"legend": Object {
"labelOptions": Object {
"maxLines": 0,
},
},
}
}
tooltip={
@ -1153,6 +1178,11 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = `
"color": undefined,
},
"barSeriesStyle": Object {},
"legend": Object {
"labelOptions": Object {
"maxLines": 0,
},
},
}
}
tooltip={
@ -1393,6 +1423,11 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] =
"color": undefined,
},
"barSeriesStyle": Object {},
"legend": Object {
"labelOptions": Object {
"maxLines": 0,
},
},
}
}
tooltip={

View file

@ -145,9 +145,13 @@ Object {
"isVisible": Array [
true,
],
"maxLines": Array [],
"position": Array [
"bottom",
],
"shouldTruncate": Array [
true,
],
"showSingleSeries": Array [],
"verticalAlignment": Array [],
},

View file

@ -517,6 +517,9 @@ export function XYChart({
background: {
color: undefined, // removes background for embeddables
},
legend: {
labelOptions: { maxLines: legend.shouldTruncate ? legend?.maxLines ?? 1 : 0 },
},
}}
baseTheme={chartBaseTheme}
tooltip={{

View file

@ -156,6 +156,8 @@ export const buildExpression = (
floatingColumns: state.legend.floatingColumns
? [Math.min(5, state.legend.floatingColumns)]
: [],
maxLines: state.legend.maxLines ? [state.legend.maxLines] : [],
shouldTruncate: [state.legend.shouldTruncate ?? true],
},
},
],

View file

@ -480,6 +480,21 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp
legend: { ...state.legend, floatingColumns: val },
});
}}
maxLines={state?.legend.maxLines}
onMaxLinesChange={(val) => {
setState({
...state,
legend: { ...state.legend, maxLines: val },
});
}}
shouldTruncate={state?.legend.shouldTruncate ?? true}
onTruncateLegendChange={() => {
const current = state?.legend.shouldTruncate ?? true;
setState({
...state,
legend: { ...state.legend, shouldTruncate: !current },
});
}}
onPositionChange={(id) => {
setState({
...state,