From 1bfdc726924de181356aca0dc2d7f15cff88b463 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 21 Jul 2021 09:09:39 +0300 Subject: [PATCH] [Lens] Display legend inside chart (#105571) * [Lens] Legend inside chart * Apply design feedback * Add unit tests * Update snapshot * Add disabled state and unit tests * revert css change * Address PR comments * Reduce the max columns to 5 * Address last comments * minor * Add a comment Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../legend_location_settings.test.tsx | 132 +++++++ .../legend_location_settings.tsx | 329 ++++++++++++++++++ .../legend_settings_popover.test.tsx | 21 -- .../legend_settings_popover.tsx | 168 +++++---- .../__snapshots__/to_expression.test.ts.snap | 4 + .../xy_visualization/expression.test.tsx | 27 ++ .../public/xy_visualization/expression.tsx | 12 +- .../public/xy_visualization/to_expression.ts | 12 + .../lens/public/xy_visualization/types.ts | 46 ++- .../xy_visualization/xy_config_panel.tsx | 54 ++- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 12 files changed, 704 insertions(+), 103 deletions(-) create mode 100644 x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx diff --git a/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx new file mode 100644 index 0000000000000..0c494f4d0090d --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Position } from '@elastic/charts'; +import { shallowWithIntl as shallow, mountWithIntl as mount } from '@kbn/test/jest'; +import { LegendLocationSettings, LegendLocationSettingsProps } from './legend_location_settings'; + +describe('Legend Location Settings', () => { + let props: LegendLocationSettingsProps; + beforeEach(() => { + props = { + onLocationChange: jest.fn(), + onPositionChange: jest.fn(), + }; + }); + + it('should have default the Position to right when no position is given', () => { + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-position-btn"]').prop('idSelected') + ).toEqual(Position.Right); + }); + + it('should have called the onPositionChange function on ButtonGroup change', () => { + const component = shallow(); + component.find('[data-test-subj="lens-legend-position-btn"]').simulate('change'); + expect(props.onPositionChange).toHaveBeenCalled(); + }); + + it('should disable the position group if isDisabled prop is true', () => { + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-position-btn"]').prop('isDisabled') + ).toEqual(true); + }); + + it('should hide the position button group if location inside is given', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + expect(component.find('[data-test-subj="lens-legend-position-btn"]').length).toEqual(0); + }); + + it('should render the location settings if location inside is given', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + expect(component.find('[data-test-subj="lens-legend-location-btn"]').length).toEqual(1); + }); + + it('should have selected the given location', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-location-btn"]').prop('idSelected') + ).toEqual('xy_location_inside'); + }); + + it('should have called the onLocationChange function on ButtonGroup change', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + component + .find('[data-test-subj="lens-legend-location-btn"]') + .simulate('change', 'xy_location_outside'); + expect(props.onLocationChange).toHaveBeenCalled(); + }); + + it('should default the alignment to top right when no value is given', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-inside-alignment-btn"]').prop('idSelected') + ).toEqual('xy_location_alignment_top_right'); + }); + + it('should have called the onAlignmentChange function on ButtonGroup change', () => { + const newProps = { + ...props, + onAlignmentChange: jest.fn(), + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + component + .find('[data-test-subj="lens-legend-inside-alignment-btn"]') + .simulate('change', 'xy_location_alignment_top_left'); + expect(newProps.onAlignmentChange).toHaveBeenCalled(); + }); + + it('should have default the columns input to 1 when no value is given', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = mount(); + expect( + component.find('[data-test-subj="lens-legend-location-columns-input"]').at(0).prop('value') + ).toEqual(1); + }); + + it('should disable the components when is Disabled is true', () => { + const newProps = { + ...props, + location: 'inside', + isDisabled: true, + } as LegendLocationSettingsProps; + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-location-btn"]').prop('isDisabled') + ).toEqual(true); + expect( + component.find('[data-test-subj="lens-legend-inside-alignment-btn"]').prop('isDisabled') + ).toEqual(true); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx b/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx new file mode 100644 index 0000000000000..4265dee2251b5 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx @@ -0,0 +1,329 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiButtonGroup, EuiFieldNumber } from '@elastic/eui'; +import { VerticalAlignment, HorizontalAlignment, Position } from '@elastic/charts'; +import { useDebouncedValue } from './debounced_value'; +import { TooltipWrapper } from './tooltip_wrapper'; + +export interface LegendLocationSettingsProps { + /** + * Sets the legend position + */ + position?: Position; + /** + * Callback on position option change + */ + onPositionChange: (id: string) => void; + /** + * Determines the legend location + */ + location?: 'inside' | 'outside'; + /** + * Callback on location option change + */ + onLocationChange?: (id: string) => void; + /** + * Sets the vertical alignment for legend inside chart + */ + verticalAlignment?: VerticalAlignment; + /** + * Sets the vertical alignment for legend inside chart + */ + horizontalAlignment?: HorizontalAlignment; + /** + * Callback on horizontal alignment option change + */ + onAlignmentChange?: (id: string) => void; + /** + * Sets the number of columns for legend inside chart + */ + floatingColumns?: number; + /** + * Callback on horizontal alignment option change + */ + onFloatingColumnsChange?: (value: number) => void; + /** + * Flag to disable the location settings + */ + isDisabled?: boolean; +} + +const DEFAULT_FLOATING_COLUMNS = 1; + +const toggleButtonsIcons = [ + { + id: Position.Top, + label: i18n.translate('xpack.lens.shared.legendPositionTop', { + defaultMessage: 'Top', + }), + iconType: 'arrowUp', + }, + { + id: Position.Right, + label: i18n.translate('xpack.lens.shared.legendPositionRight', { + defaultMessage: 'Right', + }), + iconType: 'arrowRight', + }, + { + id: Position.Bottom, + label: i18n.translate('xpack.lens.shared.legendPositionBottom', { + defaultMessage: 'Bottom', + }), + iconType: 'arrowDown', + }, + { + id: Position.Left, + label: i18n.translate('xpack.lens.shared.legendPositionLeft', { + defaultMessage: 'Left', + }), + iconType: 'arrowLeft', + }, +]; + +const locationOptions: Array<{ + id: string; + value: 'outside' | 'inside'; + label: string; +}> = [ + { + id: `xy_location_outside`, + value: 'outside', + label: i18n.translate('xpack.lens.xyChart.legendLocation.outside', { + defaultMessage: 'Outside', + }), + }, + { + id: `xy_location_inside`, + value: 'inside', + label: i18n.translate('xpack.lens.xyChart.legendLocation.inside', { + defaultMessage: 'Inside', + }), + }, +]; + +const locationAlignmentButtonsIcons: Array<{ + id: string; + value: 'bottom_left' | 'bottom_right' | 'top_left' | 'top_right'; + label: string; + iconType: string; +}> = [ + { + id: 'xy_location_alignment_top_right', + value: 'top_right', + label: i18n.translate('xpack.lens.shared.legendLocationTopRight', { + defaultMessage: 'Top right', + }), + iconType: 'editorPositionTopRight', + }, + { + id: 'xy_location_alignment_top_left', + value: 'top_left', + label: i18n.translate('xpack.lens.shared.legendLocationTopLeft', { + defaultMessage: 'Top left', + }), + iconType: 'editorPositionTopLeft', + }, + { + id: 'xy_location_alignment_bottom_right', + value: 'bottom_right', + label: i18n.translate('xpack.lens.shared.legendLocationBottomRight', { + defaultMessage: 'Bottom right', + }), + iconType: 'editorPositionBottomRight', + }, + { + id: 'xy_location_alignment_bottom_left', + value: 'bottom_left', + label: i18n.translate('xpack.lens.shared.legendLocationBottomLeft', { + defaultMessage: 'Bottom left', + }), + iconType: 'editorPositionBottomLeft', + }, +]; + +const FloatingColumnsInput = ({ + value, + setValue, + isDisabled, +}: { + value: number; + setValue: (value: number) => void; + isDisabled: boolean; +}) => { + const { inputValue, handleInputChange } = useDebouncedValue({ value, onChange: setValue }); + return ( + { + handleInputChange(Number(e.target.value)); + }} + /> + ); +}; + +export const LegendLocationSettings: React.FunctionComponent = ({ + location, + onLocationChange = () => {}, + position, + onPositionChange, + verticalAlignment, + horizontalAlignment, + onAlignmentChange = () => {}, + floatingColumns, + onFloatingColumnsChange = () => {}, + isDisabled = false, +}) => { + const alignment = `${verticalAlignment || VerticalAlignment.Top}_${ + horizontalAlignment || HorizontalAlignment.Right + }`; + return ( + <> + {location && ( + + + value === location)!.id} + onChange={(optionId) => { + const newLocation = locationOptions.find(({ id }) => id === optionId)!.value; + onLocationChange(newLocation); + }} + /> + + + )} + + <> + {(!location || location === 'outside') && ( + + + + )} + {location === 'inside' && ( + + value === alignment)!.id + } + onChange={(optionId) => { + const newAlignment = locationAlignmentButtonsIcons.find( + ({ id }) => id === optionId + )!.value; + onAlignmentChange(newAlignment); + }} + isIconOnly + /> + + )} + + + {location && ( + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx index 5a6f1b91234e8..e2fd630702b6b 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { Position } from '@elastic/charts'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; import { LegendSettingsPopover, LegendSettingsPopoverProps } from './legend_settings_popover'; @@ -51,26 +50,6 @@ describe('Legend Settings', () => { expect(props.onDisplayChange).toHaveBeenCalled(); }); - it('should have default the Position to right when no position is given', () => { - const component = shallow(); - expect( - component.find('[data-test-subj="lens-legend-position-btn"]').prop('idSelected') - ).toEqual(Position.Right); - }); - - it('should have called the onPositionChange function on ButtonGroup change', () => { - const component = shallow(); - component.find('[data-test-subj="lens-legend-position-btn"]').simulate('change'); - expect(props.onPositionChange).toHaveBeenCalled(); - }); - - it('should disable the position button group on hide mode', () => { - const component = shallow(); - expect( - component.find('[data-test-subj="lens-legend-position-btn"]').prop('isDisabled') - ).toEqual(true); - }); - it('should enable the Nested Legend Switch when renderNestedLegendSwitch prop is true', () => { const component = shallow(); expect(component.find('[data-test-subj="lens-legend-nested-switch"]')).toHaveLength(1); diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx index e86a81ba66203..0ec7c11f6fdc1 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx @@ -8,15 +8,21 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiButtonGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; -import { Position } from '@elastic/charts'; +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'; export interface LegendSettingsPopoverProps { /** * Determines the legend display options */ - legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide' | 'default'; label: string }>; + legendOptions: Array<{ + id: string; + value: 'auto' | 'show' | 'hide' | 'default'; + label: string; + }>; /** * Determines the legend mode */ @@ -33,6 +39,34 @@ export interface LegendSettingsPopoverProps { * Callback on position option change */ onPositionChange: (id: string) => void; + /** + * Determines the legend location + */ + location?: 'inside' | 'outside'; + /** + * Callback on location option change + */ + onLocationChange?: (id: string) => void; + /** + * Sets the vertical alignment for legend inside chart + */ + verticalAlignment?: VerticalAlignment; + /** + * Sets the vertical alignment for legend inside chart + */ + horizontalAlignment?: HorizontalAlignment; + /** + * Callback on horizontal alignment option change + */ + onAlignmentChange?: (id: string) => void; + /** + * Sets the number of columns for legend inside chart + */ + floatingColumns?: number; + /** + * Callback on horizontal alignment option change + */ + onFloatingColumnsChange?: (value: number) => void; /** * If true, nested legend switch is rendered */ @@ -63,42 +97,18 @@ export interface LegendSettingsPopoverProps { groupPosition?: ToolbarButtonProps['groupPosition']; } -const toggleButtonsIcons = [ - { - id: Position.Bottom, - label: i18n.translate('xpack.lens.shared.legendPositionBottom', { - defaultMessage: 'Bottom', - }), - iconType: 'arrowDown', - }, - { - id: Position.Left, - label: i18n.translate('xpack.lens.shared.legendPositionLeft', { - defaultMessage: 'Left', - }), - iconType: 'arrowLeft', - }, - { - id: Position.Right, - label: i18n.translate('xpack.lens.shared.legendPositionRight', { - defaultMessage: 'Right', - }), - iconType: 'arrowRight', - }, - { - id: Position.Top, - label: i18n.translate('xpack.lens.shared.legendPositionTop', { - defaultMessage: 'Top', - }), - iconType: 'arrowUp', - }, -]; - export const LegendSettingsPopover: React.FunctionComponent = ({ legendOptions, mode, onDisplayChange, position, + location, + onLocationChange = () => {}, + verticalAlignment, + horizontalAlignment, + floatingColumns, + onAlignmentChange = () => {}, + onFloatingColumnsChange = () => {}, onPositionChange, renderNestedLegendSwitch, nestedLegend, @@ -136,26 +146,18 @@ export const LegendSettingsPopover: React.FunctionComponent - - - + {renderNestedLegendSwitch && ( - + condition={mode === 'hide'} + position="top" + delay="regular" + display="block" + > + + )} {renderValueInLegendSwitch && ( @@ -183,17 +195,27 @@ export const LegendSettingsPopover: React.FunctionComponent - + condition={mode === 'hide'} + position="top" + delay="regular" + display="block" + > + + )} 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 ac8f089d46487..bcf54c6696ee0 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 @@ -114,6 +114,9 @@ Object { "chain": Array [ Object { "arguments": Object { + "floatingColumns": Array [], + "horizontalAlignment": Array [], + "isInside": Array [], "isVisible": Array [ true, ], @@ -121,6 +124,7 @@ Object { "bottom", ], "showSingleSeries": Array [], + "verticalAlignment": Array [], }, "function": "lens_xy_legendConfig", "type": "function", diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 930f6888ce532..b018e62f1fd8f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -17,6 +17,9 @@ import { XYChartSeriesIdentifier, SeriesNameFn, Fit, + HorizontalAlignment, + VerticalAlignment, + LayoutDirection, } from '@elastic/charts'; import { PaletteOutput } from 'src/plugins/charts/public'; import { @@ -2251,6 +2254,30 @@ describe('xy_expression', () => { expect(component.find(Settings).prop('showLegend')).toEqual(true); }); + test('it should populate the correct legendPosition if isInside is set', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + + expect(component.find(Settings).prop('legendPosition')).toEqual({ + vAlign: VerticalAlignment.Top, + hAlign: HorizontalAlignment.Right, + direction: LayoutDirection.Vertical, + floating: true, + floatingColumns: 1, + }); + }); + test('it not show legend if isVisible is set to false', () => { const { data, args } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 404608f9da43a..7c767cd1d1b04 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -22,9 +22,11 @@ import { StackMode, VerticalAlignment, HorizontalAlignment, + LayoutDirection, ElementClickListener, BrushEndListener, CurveType, + LegendPositionConfig, LabelOverflowConstraint, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; @@ -602,6 +604,14 @@ export function XYChart({ onSelectRange(context); }; + const legendInsideParams = { + vAlign: legend.verticalAlignment ?? VerticalAlignment.Top, + hAlign: legend?.horizontalAlignment ?? HorizontalAlignment.Right, + direction: LayoutDirection.Vertical, + floating: true, + floatingColumns: legend?.floatingColumns ?? 1, + } as LegendPositionConfig; + return ( , index: n }; } -const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [ +const legendOptions: Array<{ + id: string; + value: 'auto' | 'show' | 'hide'; + label: string; +}> = [ { id: `xy_legend_auto`, value: 'auto', @@ -319,32 +323,72 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp { + setState({ + ...state, + legend: { + ...state.legend, + isInside: location === 'inside', + }, + }); + }} onDisplayChange={(optionId) => { const newMode = legendOptions.find(({ id }) => id === optionId)!.value; if (newMode === 'auto') { setState({ ...state, - legend: { ...state.legend, isVisible: true, showSingleSeries: false }, + legend: { + ...state.legend, + isVisible: true, + showSingleSeries: false, + }, }); } else if (newMode === 'show') { setState({ ...state, - legend: { ...state.legend, isVisible: true, showSingleSeries: true }, + legend: { + ...state.legend, + isVisible: true, + showSingleSeries: true, + }, }); } else if (newMode === 'hide') { setState({ ...state, - legend: { ...state.legend, isVisible: false, showSingleSeries: false }, + legend: { + ...state.legend, + isVisible: false, + showSingleSeries: false, + }, }); } }} position={state?.legend.position} + horizontalAlignment={state?.legend.horizontalAlignment} + verticalAlignment={state?.legend.verticalAlignment} + floatingColumns={state?.legend.floatingColumns} + onFloatingColumnsChange={(val) => { + setState({ + ...state, + legend: { ...state.legend, floatingColumns: val }, + }); + }} onPositionChange={(id) => { setState({ ...state, legend: { ...state.legend, position: id as Position }, }); }} + onAlignmentChange={(value) => { + const [vertical, horizontal] = value.split('_'); + const verticalAlignment = vertical as VerticalAlignment; + const horizontalAlignment = horizontal as HorizontalAlignment; + setState({ + ...state, + legend: { ...state.legend, verticalAlignment, horizontalAlignment }, + }); + }} renderValueInLegendSwitch={nonOrdinalXAxis} valueInLegend={state?.valuesInLegend} onValueInLegendChange={() => { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0cf63b94f1758..ad3a58e0e9584 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12712,7 +12712,6 @@ "xpack.lens.shared.curveLabel": "", "xpack.lens.shared.legendLabel": "凡例", "xpack.lens.shared.legendPositionBottom": "一番下", - "xpack.lens.shared.legendPositionLabel": "位置", "xpack.lens.shared.legendPositionLeft": "左", "xpack.lens.shared.legendPositionRight": "右", "xpack.lens.shared.legendPositionTop": "トップ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6659c51867fa6..f7b8e216f3a4f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12884,7 +12884,6 @@ "xpack.lens.shared.curveLabel": "视觉选项", "xpack.lens.shared.legendLabel": "图例", "xpack.lens.shared.legendPositionBottom": "底部", - "xpack.lens.shared.legendPositionLabel": "位置", "xpack.lens.shared.legendPositionLeft": "左", "xpack.lens.shared.legendPositionRight": "右", "xpack.lens.shared.legendPositionTop": "顶部",