[Lens] Metric style options improvements (#186929)

## Summary
Adds 3 new options to the new `Metric` vis including:
- Title/subtitle alignment
- Value alignment
- Icon alignment
- Value fontSizing
This commit is contained in:
Nick Partridge 2024-07-09 02:40:29 -07:00 committed by GitHub
parent f2614a45d9
commit 234f97ab69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 848 additions and 234 deletions

View file

@ -53,7 +53,7 @@ export class EuiButtonGroupTestHarness {
}
/**
* Returns selected value of button group
* Returns selected option of button group
*/
public get selected() {
return within(this.#buttonGroup).getByRole('button', { pressed: true });
@ -136,3 +136,59 @@ export class EuiSuperDatePickerTestHarness {
userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
}
}
export class EuiSelectTestHarness {
#testId: string;
/**
* Returns select or throws
*/
get #selectEl() {
return screen.getByTestId(this.#testId);
}
constructor(testId: string) {
this.#testId = testId;
}
/**
* Returns `data-test-subj` of select
*/
public get testId() {
return this.#testId;
}
/**
* Returns button select if found, otherwise `null`
*/
public get self() {
return screen.queryByTestId(this.#testId);
}
/**
* Returns all options of select
*/
public get options(): HTMLOptionElement[] {
return within(this.#selectEl).getAllByRole('option');
}
/**
* Returns selected option
*/
public get selected() {
return (this.#selectEl as HTMLSelectElement).value;
}
/**
* Select option by value
*/
public select(optionName: string | RegExp) {
const option = this.options.find((o) => o.value === optionName)?.value;
if (!option) {
throw new Error(`Option [${optionName}] not found`);
}
fireEvent.change(this.#selectEl, { target: { value: option } });
}
}

View file

@ -25,6 +25,10 @@ describe('interpreter/functions#metricVis', () => {
progressDirection: 'horizontal',
maxCols: 1,
inspectorTableId: 'random-id',
titlesTextAlign: 'left',
valuesTextAlign: 'right',
iconAlign: 'left',
valueFontSize: 'default',
};
it('should pass over overrides from variables', async () => {

View file

@ -77,6 +77,30 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
'The direction the progress bar should grow. Must be provided to render a progress bar.',
}),
},
titlesTextAlign: {
types: ['string'],
help: i18n.translate('expressionMetricVis.function.titlesTextAlign.help', {
defaultMessage: 'The alignment of the Title and Subtitle.',
}),
},
valuesTextAlign: {
types: ['string'],
help: i18n.translate('expressionMetricVis.function.valuesTextAlign.help', {
defaultMessage: 'The alignment of the Primary and Secondary Metric.',
}),
},
iconAlign: {
types: ['string'],
help: i18n.translate('expressionMetricVis.function.iconAlign.help', {
defaultMessage: 'The alignment of icon.',
}),
},
valueFontSize: {
types: ['string', 'number'],
help: i18n.translate('expressionMetricVis.function.valueFontSize.help', {
defaultMessage: 'The value font size.',
}),
},
color: {
types: ['string'],
help: i18n.translate('expressionMetricVis.function.color.help', {
@ -189,6 +213,10 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
icon: args.icon,
palette: args.palette?.params,
progressDirection: args.progressDirection,
titlesTextAlign: args.titlesTextAlign,
valuesTextAlign: args.valuesTextAlign,
iconAlign: args.iconAlign,
valueFontSize: args.valueFontSize,
maxCols: args.maxCols,
minTiles: args.minTiles,
trends: args.trendline?.trends,

View file

@ -7,7 +7,7 @@
*/
import type { PaletteOutput } from '@kbn/coloring';
import { LayoutDirection, MetricWTrend } from '@elastic/charts';
import { LayoutDirection, MetricStyle, MetricWTrend } from '@elastic/charts';
import { $Values } from '@kbn/utility-types';
import {
Datatable,
@ -38,6 +38,10 @@ export interface MetricArguments {
subtitle?: string;
secondaryPrefix?: string;
progressDirection?: LayoutDirection;
titlesTextAlign: MetricStyle['titlesTextAlign'];
valuesTextAlign: MetricStyle['valuesTextAlign'];
iconAlign: MetricStyle['iconAlign'];
valueFontSize: MetricStyle['valueFontSize'];
color?: string;
icon?: string;
palette?: PaletteOutput<CustomPaletteState>;

View file

@ -8,7 +8,7 @@
import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common';
import { CustomPaletteState } from '@kbn/charts-plugin/common';
import { LayoutDirection } from '@elastic/charts';
import { LayoutDirection, MetricStyle } from '@elastic/charts';
import { TrendlineResult } from './expression_functions';
export const visType = 'metric';
@ -27,6 +27,10 @@ export interface MetricVisParam {
icon?: string;
palette?: CustomPaletteState;
progressDirection?: LayoutDirection;
titlesTextAlign: MetricStyle['titlesTextAlign'];
valuesTextAlign: MetricStyle['valuesTextAlign'];
iconAlign: MetricStyle['iconAlign'];
valueFontSize: MetricStyle['valueFontSize'];
maxCols: number;
minTiles?: number;
trends?: TrendlineResult['trends'];

View file

@ -23,7 +23,7 @@ import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common';
import { SerializableRecord } from '@kbn/utility-types';
import type { IUiSettingsClient } from '@kbn/core/public';
import { CustomPaletteState } from '@kbn/charts-plugin/common/expressions/palette/types';
import { DimensionsVisParam } from '../../common';
import { DimensionsVisParam, MetricVisParam } from '../../common';
import { euiThemeVars } from '@kbn/ui-theme';
import { DEFAULT_TRENDLINE_NAME } from '../../common/constants';
import faker from 'faker';
@ -73,6 +73,15 @@ const dayOfWeekColumnId = 'col-0-0';
const basePriceColumnId = 'col-1-1';
const minPriceColumnId = 'col-2-2';
const defaultMetricParams: MetricVisParam = {
progressDirection: 'vertical',
maxCols: 5,
titlesTextAlign: 'left',
valuesTextAlign: 'right',
iconAlign: 'left',
valueFontSize: 'default',
};
const table: Datatable = {
type: 'datatable',
columns: [
@ -217,8 +226,7 @@ describe('MetricVisComponent', function () {
describe('single metric', () => {
const config: Props['config'] = {
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
icon: 'empty',
},
dimensions: {
@ -402,8 +410,7 @@ describe('MetricVisComponent', function () {
describe('metric grid', () => {
const config: Props['config'] = {
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
},
dimensions: {
metric: basePriceColumnId,
@ -856,8 +863,7 @@ describe('MetricVisComponent', function () {
data={table}
config={{
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
},
dimensions: {
metric: basePriceColumnId,
@ -911,8 +917,7 @@ describe('MetricVisComponent', function () {
<MetricVis
config={{
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
},
dimensions: {
metric: basePriceColumnId,
@ -953,8 +958,7 @@ describe('MetricVisComponent', function () {
<MetricVis
config={{
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
},
dimensions: {
metric: metricId,
@ -980,8 +984,7 @@ describe('MetricVisComponent', function () {
<MetricVis
config={{
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
},
dimensions: {
metric: basePriceColumnId,
@ -1057,8 +1060,7 @@ describe('MetricVisComponent', function () {
<MetricVis
config={{
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
},
dimensions: {
metric: basePriceColumnId,
@ -1088,8 +1090,7 @@ describe('MetricVisComponent', function () {
metric: basePriceColumnId,
},
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
// should be overridden
color: 'static-color',
palette: {
@ -1141,9 +1142,8 @@ describe('MetricVisComponent', function () {
config={{
dimensions,
metric: {
...defaultMetricParams,
palette,
progressDirection: 'vertical',
maxCols: 5,
},
}}
data={table}
@ -1205,8 +1205,7 @@ describe('MetricVisComponent', function () {
metric: basePriceColumnId,
},
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
color: staticColor,
palette: undefined,
},
@ -1230,8 +1229,7 @@ describe('MetricVisComponent', function () {
metric: basePriceColumnId,
},
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
color: undefined,
palette: undefined,
},
@ -1260,8 +1258,7 @@ describe('MetricVisComponent', function () {
) => {
const config: Props['config'] = {
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
},
dimensions: {
metric: '1',
@ -1416,8 +1413,7 @@ describe('MetricVisComponent', function () {
<MetricVis
config={{
metric: {
progressDirection: 'vertical',
maxCols: 5,
...defaultMetricParams,
},
dimensions: {
metric: basePriceColumnId,

View file

@ -335,6 +335,10 @@ export const MetricVis = ({
barBackground: euiThemeVars.euiColorLightShade,
emptyBackground: euiThemeVars.euiColorEmptyShade,
blendingBackground: euiThemeVars.euiColorEmptyShade,
titlesTextAlign: config.metric.titlesTextAlign,
valuesTextAlign: config.metric.valuesTextAlign,
iconAlign: config.metric.iconAlign,
valueFontSize: config.metric.valueFontSize,
},
},
...(Array.isArray(settingsThemeOverrides)

View file

@ -1,3 +1,3 @@
.lnsVisToolbar__popover {
width: 404px;
width: 410px;
}

View file

@ -60,7 +60,7 @@ export const HeatmapToolbar = memo(
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="none" responsive={false}>
<ToolbarPopover
title={i18n.translate('xpack.lens.shared.curveLabel', {
title={i18n.translate('xpack.lens.shared.visualOptionsLabel', {
defaultMessage: 'Visual options',
})}
type="visualOptions"

View file

@ -5,6 +5,9 @@
* 2.0.
*/
import { OptionalKeys } from 'utility-types';
import { MetricVisualizationState } from './types';
export const LENS_METRIC_ID = 'lnsMetric';
export const GROUP_ID = {
@ -17,3 +20,23 @@ export const GROUP_ID = {
TREND_TIME: 'trendTime',
TREND_BREAKDOWN_BY: 'trendBreakdownBy',
} as const;
type MetricVisualizationStateOptionals = Pick<
MetricVisualizationState,
OptionalKeys<MetricVisualizationState>
>;
/**
* Defaults for select optional Metric vis state options
*/
export const metricStateDefaults: Required<
Pick<
MetricVisualizationStateOptionals,
'titlesTextAlign' | 'valuesTextAlign' | 'iconAlign' | 'valueFontMode'
>
> = {
titlesTextAlign: 'left',
valuesTextAlign: 'right',
iconAlign: 'left',
valueFontMode: 'default',
};

View file

@ -13,7 +13,7 @@ import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { euiLightVars } from '@kbn/ui-theme';
import { CustomPaletteParams, PaletteOutput, PaletteRegistry } from '@kbn/coloring';
import { VisualizationDimensionEditorProps } from '../../types';
import { MetricVisualizationState } from './visualization';
import { MetricVisualizationState } from './types';
import {
DimensionEditor,
DimensionEditorAdditionalSection,
@ -59,6 +59,10 @@ describe('dimension editor', () => {
palette,
icon: 'tag',
showBar: true,
titlesTextAlign: 'left',
valuesTextAlign: 'right',
iconAlign: 'left',
valueFontMode: 'default',
trendlineLayerId: 'second',
trendlineLayerType: 'metricTrendline',
trendlineMetricAccessor: 'trendline-metric-col-id',

View file

@ -34,14 +34,10 @@ import { isNumericFieldForDatatable } from '../../../common/expressions/datatabl
import { applyPaletteParams, PalettePanelContainer } from '../../shared_components';
import type { VisualizationDimensionEditorProps } from '../../types';
import { defaultNumberPaletteParams, defaultPercentagePaletteParams } from './palette_config';
import {
DEFAULT_MAX_COLUMNS,
getDefaultColor,
MetricVisualizationState,
showingBar,
} from './visualization';
import { DEFAULT_MAX_COLUMNS, getDefaultColor, showingBar } from './visualization';
import { CollapseSetting } from '../../shared_components/collapse_setting';
import { iconsSet } from './icon_set';
import { MetricVisualizationState } from './types';
export type SupportingVisType = 'none' | 'bar' | 'trendline';

View file

@ -7,7 +7,7 @@
import { getSuggestions } from './suggestions';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { MetricVisualizationState } from './visualization';
import { MetricVisualizationState } from './types';
import { IconChartMetric } from '@kbn/chart-icons';
const metricColumn = {

View file

@ -8,7 +8,8 @@
import { IconChartMetric } from '@kbn/chart-icons';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { TableSuggestion, Visualization } from '../../types';
import { metricLabel, MetricVisualizationState, supportedDataTypes } from './visualization';
import { MetricVisualizationState } from './types';
import { metricLabel, supportedDataTypes } from './visualization';
const MAX_BUCKETED_COLUMNS = 1;
const MAX_METRIC_COLUMNS = 2; // primary and secondary metric

View file

@ -17,7 +17,9 @@ import { CollapseArgs, CollapseFunction } from '../../../common/expressions';
import { CollapseExpressionFunction } from '../../../common/expressions/collapse/types';
import { DatasourceLayers } from '../../types';
import { showingBar } from './metric_visualization';
import { DEFAULT_MAX_COLUMNS, getDefaultColor, MetricVisualizationState } from './visualization';
import { DEFAULT_MAX_COLUMNS, getDefaultColor } from './visualization';
import { MetricVisualizationState } from './types';
import { metricStateDefaults } from './constants';
// TODO - deduplicate with gauges?
function computePaletteParams(params: CustomPaletteParams) {
@ -148,6 +150,10 @@ export const toExpression = (
progressDirection: showingBar(state)
? state.progressDirection || LayoutDirection.Vertical
: undefined,
titlesTextAlign: state.titlesTextAlign ?? metricStateDefaults.titlesTextAlign,
valuesTextAlign: state.valuesTextAlign ?? metricStateDefaults.valuesTextAlign,
iconAlign: state.iconAlign ?? metricStateDefaults.iconAlign,
valueFontSize: state.valueFontMode ?? metricStateDefaults.valueFontMode,
color: state.color || getDefaultColor(state, isMetricNumeric),
icon: state.icon,
palette:

View file

@ -1,93 +0,0 @@
/*
* 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 { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { Toolbar } from './toolbar';
import { MetricVisualizationState } from './visualization';
import { createMockFramePublicAPI } from '../../mocks';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
describe('metric toolbar', () => {
const palette: PaletteOutput<CustomPaletteParams> = {
type: 'palette',
name: 'foo',
params: {
rangeType: 'percent',
},
};
const fullState: Required<MetricVisualizationState> = {
layerId: 'first',
layerType: 'data',
metricAccessor: 'metric-col-id',
secondaryMetricAccessor: 'secondary-metric-col-id',
maxAccessor: 'max-metric-col-id',
breakdownByAccessor: 'breakdown-col-id',
collapseFn: 'sum',
subtitle: 'subtitle',
secondaryPrefix: 'extra-text',
progressDirection: 'vertical',
maxCols: 5,
color: 'static-color',
icon: 'compute',
palette,
showBar: true,
trendlineLayerId: 'second',
trendlineLayerType: 'metricTrendline',
trendlineMetricAccessor: 'trendline-metric-col-id',
trendlineSecondaryMetricAccessor: 'trendline-secondary-metric-col-id',
trendlineTimeAccessor: 'trendline-time-col-id',
trendlineBreakdownByAccessor: 'trendline-breakdown-col-id',
};
const frame = createMockFramePublicAPI();
const mockSetState = jest.fn();
const renderToolbar = (state: MetricVisualizationState) => {
return { ...render(<Toolbar state={state} setState={mockSetState} frame={frame} />) };
};
afterEach(() => mockSetState.mockClear());
describe('text options', () => {
it('sets a subtitle', async () => {
renderToolbar({
...fullState,
breakdownByAccessor: undefined,
});
const textOptionsButton = screen.getByTestId('lnsLabelsButton');
textOptionsButton.click();
const newSubtitle = 'new subtitle hey';
const subtitleField = screen.getByDisplayValue('subtitle');
// cannot use userEvent because the element cannot be clicked on
fireEvent.change(subtitleField, { target: { value: newSubtitle + ' 1' } });
await waitFor(() => expect(mockSetState).toHaveBeenCalled());
fireEvent.change(subtitleField, { target: { value: newSubtitle + ' 2' } });
await waitFor(() => expect(mockSetState).toHaveBeenCalledTimes(2));
fireEvent.change(subtitleField, { target: { value: newSubtitle + ' 3' } });
await waitFor(() => expect(mockSetState).toHaveBeenCalledTimes(3));
expect(mockSetState.mock.calls.map(([state]) => state.subtitle)).toMatchInlineSnapshot(`
Array [
"new subtitle hey 1",
"new subtitle hey 2",
"new subtitle hey 3",
]
`);
});
it('hides text options when has breakdown by', () => {
renderToolbar({
...fullState,
breakdownByAccessor: 'some-accessor',
});
expect(screen.queryByTestId('lnsLabelsButton')).not.toBeInTheDocument();
});
});
});

View file

@ -1,62 +0,0 @@
/*
* 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, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFormRow, EuiFieldText } from '@elastic/eui';
import { useDebouncedValue } from '@kbn/visualization-utils';
import { VisualizationToolbarProps } from '../../types';
import { ToolbarPopover } from '../../shared_components';
import { MetricVisualizationState } from './visualization';
export function Toolbar(props: VisualizationToolbarProps<MetricVisualizationState>) {
const { state, setState } = props;
const setSubtitle = useCallback(
(prefix: string) => setState({ ...state, subtitle: prefix }),
[setState, state]
);
const { inputValue: subtitleInputVal, handleInputChange: handleSubtitleChange } =
useDebouncedValue<string>(
{
onChange: setSubtitle,
value: state.subtitle || '',
},
{ allowFalsyValue: true }
);
const hasBreakdownBy = Boolean(state.breakdownByAccessor);
return (
<EuiFlexGroup alignItems="center" gutterSize="none" responsive={false}>
{!hasBreakdownBy && (
<ToolbarPopover
title={i18n.translate('xpack.lens.metric.labels', {
defaultMessage: 'Labels',
})}
type="labels"
groupPosition="none"
buttonDataTestSubj="lnsLabelsButton"
>
<EuiFormRow
label={i18n.translate('xpack.lens.metric.subtitleLabel', {
defaultMessage: 'Subtitle',
})}
fullWidth
display="columnCompressed"
>
<EuiFieldText
value={subtitleInputVal}
onChange={({ target: { value } }) => handleSubtitleChange(value)}
/>
</EuiFormRow>
</ToolbarPopover>
)}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { Toolbar } from './toolbar';

View file

@ -0,0 +1,94 @@
/*
* 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 { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MetricVisualizationState } from '../types';
import { LabelOptionsPopover } from './label_options_popover';
describe('LabelOptionsPopover', () => {
const palette: PaletteOutput<CustomPaletteParams> = {
type: 'palette',
name: 'foo',
params: {
rangeType: 'percent',
},
};
const fullState: Required<MetricVisualizationState> = {
layerId: 'first',
layerType: 'data',
metricAccessor: 'metric-col-id',
secondaryMetricAccessor: 'secondary-metric-col-id',
maxAccessor: 'max-metric-col-id',
breakdownByAccessor: 'breakdown-col-id',
collapseFn: 'sum',
subtitle: 'subtitle',
secondaryPrefix: 'extra-text',
progressDirection: 'vertical',
maxCols: 5,
color: 'static-color',
icon: 'compute',
palette,
showBar: true,
trendlineLayerId: 'second',
trendlineLayerType: 'metricTrendline',
trendlineMetricAccessor: 'trendline-metric-col-id',
trendlineSecondaryMetricAccessor: 'trendline-secondary-metric-col-id',
trendlineTimeAccessor: 'trendline-time-col-id',
trendlineBreakdownByAccessor: 'trendline-breakdown-col-id',
titlesTextAlign: 'left',
valuesTextAlign: 'right',
iconAlign: 'left',
valueFontMode: 'default',
};
const mockSetState = jest.fn();
const renderToolbarOptions = (state: MetricVisualizationState) => {
return {
...render(<LabelOptionsPopover state={state} setState={mockSetState} />),
};
};
afterEach(() => mockSetState.mockClear());
it('should set a subtitle', async () => {
renderToolbarOptions({
...fullState,
breakdownByAccessor: undefined,
});
const labelOptionsButton = screen.getByTestId('lnsLabelsButton');
labelOptionsButton.click();
const newSubtitle = 'new subtitle hey';
const subtitleField = screen.getByDisplayValue('subtitle');
// cannot use userEvent because the element cannot be clicked on
fireEvent.change(subtitleField, { target: { value: newSubtitle + ' 1' } });
await waitFor(() => expect(mockSetState).toHaveBeenCalled());
fireEvent.change(subtitleField, { target: { value: newSubtitle + ' 2' } });
await waitFor(() => expect(mockSetState).toHaveBeenCalledTimes(2));
fireEvent.change(subtitleField, { target: { value: newSubtitle + ' 3' } });
await waitFor(() => expect(mockSetState).toHaveBeenCalledTimes(3));
expect(mockSetState.mock.calls.map(([state]) => state.subtitle)).toMatchInlineSnapshot(`
Array [
"new subtitle hey 1",
"new subtitle hey 2",
"new subtitle hey 3",
]
`);
});
it('should disable labels options when Metric has breakdown by', () => {
renderToolbarOptions({
...fullState,
breakdownByAccessor: 'some-accessor',
});
expect(screen.getByTestId('lnsLabelsButton')).toBeDisabled();
});
});

View file

@ -0,0 +1,72 @@
/*
* 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, { FC, useCallback } from 'react';
import { EuiFormRow, EuiFieldText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDebouncedValue } from '@kbn/visualization-utils';
import { TooltipWrapper } from '@kbn/visualization-utils';
import { ToolbarPopover } from '../../../shared_components';
import { MetricVisualizationState } from '../types';
export interface LabelOptionsPopoverProps {
state: MetricVisualizationState;
setState: (newState: MetricVisualizationState) => void;
}
export const LabelOptionsPopover: FC<LabelOptionsPopoverProps> = ({ state, setState }) => {
const setSubtitle = useCallback(
(prefix: string) => setState({ ...state, subtitle: prefix }),
[setState, state]
);
const { inputValue: subtitleInputVal, handleInputChange: handleSubtitleChange } =
useDebouncedValue<string>(
{
onChange: setSubtitle,
value: state.subtitle || '',
},
{ allowFalsyValue: true }
);
const hasBreakdownBy = Boolean(state.breakdownByAccessor);
return (
<TooltipWrapper
tooltipContent={i18n.translate('xpack.lens.metric.toolbarLabelOptions.disabled', {
defaultMessage: 'Not supported with Break down by',
})}
condition={hasBreakdownBy}
position="bottom"
>
<ToolbarPopover
title={i18n.translate('xpack.lens.metric.labels', {
defaultMessage: 'Labels',
})}
type="labels"
groupPosition="right"
buttonDataTestSubj="lnsLabelsButton"
isDisabled={hasBreakdownBy}
>
<EuiFormRow
label={i18n.translate('xpack.lens.metric.subtitleLabel', {
defaultMessage: 'Subtitle',
})}
fullWidth
display="columnCompressed"
>
<EuiFieldText
value={subtitleInputVal}
onChange={({ target: { value } }) => handleSubtitleChange(value)}
/>
</EuiFormRow>
</ToolbarPopover>
</TooltipWrapper>
);
};

View file

@ -0,0 +1,28 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { VisualizationToolbarProps } from '../../../types';
import { LabelOptionsPopover } from './label_options_popover';
import { VisualOptionsPopover } from './visual_options_popover';
import { MetricVisualizationState } from '../types';
export function Toolbar(props: VisualizationToolbarProps<MetricVisualizationState>) {
const { state, setState } = props;
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="none" responsive={false}>
<VisualOptionsPopover state={state} setState={setState} />
<LabelOptionsPopover state={state} setState={setState} />
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,140 @@
/*
* 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 { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { render, screen } from '@testing-library/react';
import { MetricVisualizationState } from '../types';
import { VisualOptionsPopover } from './visual_options_popover';
import { EuiButtonGroupTestHarness } from '@kbn/test-eui-helpers';
jest.mock('lodash', () => ({
...jest.requireActual('lodash'),
debounce: (fn: unknown) => fn,
}));
describe('VisualOptionsPopover', () => {
const palette: PaletteOutput<CustomPaletteParams> = {
type: 'palette',
name: 'foo',
params: {
rangeType: 'percent',
},
};
const fullState: Required<MetricVisualizationState> = {
layerId: 'first',
layerType: 'data',
metricAccessor: 'metric-col-id',
secondaryMetricAccessor: 'secondary-metric-col-id',
maxAccessor: 'max-metric-col-id',
breakdownByAccessor: 'breakdown-col-id',
collapseFn: 'sum',
subtitle: 'subtitle',
secondaryPrefix: 'extra-text',
progressDirection: 'vertical',
maxCols: 5,
color: 'static-color',
icon: 'compute',
palette,
showBar: true,
trendlineLayerId: 'second',
trendlineLayerType: 'metricTrendline',
trendlineMetricAccessor: 'trendline-metric-col-id',
trendlineSecondaryMetricAccessor: 'trendline-secondary-metric-col-id',
trendlineTimeAccessor: 'trendline-time-col-id',
trendlineBreakdownByAccessor: 'trendline-breakdown-col-id',
titlesTextAlign: 'left',
valuesTextAlign: 'right',
iconAlign: 'left',
valueFontMode: 'default',
};
const mockSetState = jest.fn();
const renderToolbarOptions = (state: MetricVisualizationState) => {
return {
...render(<VisualOptionsPopover state={state} setState={mockSetState} />),
};
};
afterEach(() => mockSetState.mockClear());
it('should set titlesTextAlign', async () => {
renderToolbarOptions({ ...fullState });
const textOptionsButton = screen.getByTestId('lnsVisualOptionsButton');
textOptionsButton.click();
const titlesAlignBtnGroup = new EuiButtonGroupTestHarness('lens-titles-alignment-btn');
titlesAlignBtnGroup.select('Right');
titlesAlignBtnGroup.select('Center');
titlesAlignBtnGroup.select('Left');
expect(mockSetState.mock.calls.map(([s]) => s.titlesTextAlign)).toEqual([
'right',
'center',
'left',
]);
});
it('should set valuesTextAlign', async () => {
renderToolbarOptions({ ...fullState });
const textOptionsButton = screen.getByTestId('lnsVisualOptionsButton');
textOptionsButton.click();
const valueAlignBtnGroup = new EuiButtonGroupTestHarness('lens-values-alignment-btn');
valueAlignBtnGroup.select('Center');
valueAlignBtnGroup.select('Left');
valueAlignBtnGroup.select('Right');
expect(mockSetState.mock.calls.map(([s]) => s.valuesTextAlign)).toEqual([
'center',
'left',
'right',
]);
});
it('should set valueFontMode', async () => {
renderToolbarOptions({ ...fullState });
const textOptionsButton = screen.getByTestId('lnsVisualOptionsButton');
textOptionsButton.click();
const modeBtnGroup = new EuiButtonGroupTestHarness('lens-value-font-mode-btn');
expect(modeBtnGroup.selected.textContent).toBe('Default');
modeBtnGroup.select('Fit');
modeBtnGroup.select('Default');
expect(mockSetState.mock.calls.map(([s]) => s.valueFontMode)).toEqual(['fit', 'default']);
});
it('should set iconAlign', async () => {
renderToolbarOptions({ ...fullState, icon: 'sortUp' });
const textOptionsButton = screen.getByTestId('lnsVisualOptionsButton');
textOptionsButton.click();
const iconAlignBtnGroup = new EuiButtonGroupTestHarness('lens-icon-alignment-btn');
expect(iconAlignBtnGroup.selected.textContent).toBe('Left');
iconAlignBtnGroup.select('Right');
iconAlignBtnGroup.select('Left');
expect(mockSetState.mock.calls.map(([s]) => s.iconAlign)).toEqual(['right', 'left']);
});
it.each([undefined, 'empty'])('should hide iconAlign option when icon is %j', async (icon) => {
renderToolbarOptions({ ...fullState, icon });
const textOptionsButton = screen.getByTestId('lnsVisualOptionsButton');
textOptionsButton.click();
expect(screen.queryByTestId('lens-icon-alignment-btn')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,290 @@
/*
* 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, { FC } from 'react';
import { EuiFormRow, EuiIconTip, EuiButtonGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { MetricStyle } from '@elastic/charts';
import { ToolbarPopover } from '../../../shared_components';
import { MetricVisualizationState, ValueFontMode } from '../types';
import { metricStateDefaults } from '../constants';
export interface VisualOptionsPopoverProps {
state: MetricVisualizationState;
setState: (newState: MetricVisualizationState) => void;
}
export const VisualOptionsPopover: FC<VisualOptionsPopoverProps> = ({ state, setState }) => {
return (
<ToolbarPopover
title={i18n.translate('xpack.lens.shared.visualOptionsLabel', {
defaultMessage: 'Visual options',
})}
type="visualOptions"
groupPosition="left"
buttonDataTestSubj="lnsVisualOptionsButton"
>
<TitlesAlignmentOption
value={state.titlesTextAlign ?? metricStateDefaults.titlesTextAlign}
onChange={(titlesTextAlign) => {
setState({ ...state, titlesTextAlign });
}}
/>
<ValuesAlignmentOption
value={state.valuesTextAlign ?? metricStateDefaults.valuesTextAlign}
onChange={(valuesTextAlign) => {
setState({ ...state, valuesTextAlign });
}}
/>
{state.icon && state.icon !== 'empty' && (
<IconAlignmentOption
value={state.iconAlign ?? metricStateDefaults.iconAlign}
onChange={(iconAlign) => {
setState({ ...state, iconAlign });
}}
/>
)}
<ValueFontOption
value={state.valueFontMode ?? metricStateDefaults.valueFontMode}
onChange={(value) => {
setState({ ...state, valueFontMode: value });
}}
/>
</ToolbarPopover>
);
};
const valueFontModes: Array<{
id: ValueFontMode;
label: string;
}> = [
{
id: 'default',
label: i18n.translate('xpack.lens.metric.toolbarVisOptions.default', {
defaultMessage: 'Default',
}),
},
{
id: 'fit',
label: i18n.translate('xpack.lens.metric.toolbarVisOptions.fit', {
defaultMessage: 'Fit',
}),
},
];
function ValueFontOption({
value,
onChange,
}: {
value: typeof valueFontModes[number]['id'];
onChange: (mode: ValueFontMode) => void;
}) {
const label = i18n.translate('xpack.lens.metric.toolbarVisOptions.valueFontSize', {
defaultMessage: 'Value fontSize',
});
return (
<EuiFormRow
display="columnCompressed"
label={
<span>
{label}{' '}
<EuiIconTip
content={i18n.translate('xpack.lens.metric.toolbarVisOptions.valueFontSizeTip', {
defaultMessage: 'Font size of the Primary metric value',
})}
iconProps={{
className: 'eui-alignTop',
}}
color="subdued"
position="top"
size="s"
type="questionInCircle"
/>
</span>
}
>
<EuiButtonGroup
isFullWidth
legend={label}
data-test-subj="lens-value-font-mode-btn"
buttonSize="compressed"
idSelected={value}
options={valueFontModes}
onChange={(mode) => {
onChange(mode as ValueFontMode);
}}
/>
</EuiFormRow>
);
}
const alignmentOptions: Array<{
id: MetricStyle['titlesTextAlign'] | MetricStyle['valuesTextAlign'];
label: string;
}> = [
{
id: 'left',
label: i18n.translate('xpack.lens.shared.left', {
defaultMessage: 'Left',
}),
},
{
id: 'center',
label: i18n.translate('xpack.lens.shared.center', {
defaultMessage: 'Center',
}),
},
{
id: 'right',
label: i18n.translate('xpack.lens.shared.right', {
defaultMessage: 'Right',
}),
},
];
function TitlesAlignmentOption({
value,
onChange,
}: {
value: MetricStyle['titlesTextAlign'];
onChange: (alignment: MetricStyle['titlesTextAlign']) => void;
}) {
const label = i18n.translate('xpack.lens.metric.toolbarVisOptions.titlesAlignment', {
defaultMessage: 'Titles alignment',
});
return (
<EuiFormRow
display="columnCompressed"
label={
<span>
{label}{' '}
<EuiIconTip
content={i18n.translate('xpack.lens.metric.toolbarVisOptions.titlesAlignmentTip', {
defaultMessage: 'Alignment of the Title and Subtitle',
})}
iconProps={{
className: 'eui-alignTop',
}}
color="subdued"
position="top"
size="s"
type="questionInCircle"
/>
</span>
}
>
<EuiButtonGroup
isFullWidth
legend={label}
data-test-subj="lens-titles-alignment-btn"
buttonSize="compressed"
options={alignmentOptions}
idSelected={value}
onChange={(alignment) => {
onChange(alignment as MetricStyle['titlesTextAlign']);
}}
/>
</EuiFormRow>
);
}
function ValuesAlignmentOption({
value,
onChange,
}: {
value: MetricStyle['valuesTextAlign'];
onChange: (alignment: MetricStyle['valuesTextAlign']) => void;
}) {
const label = i18n.translate('xpack.lens.metric.toolbarVisOptions.valuesAlignment', {
defaultMessage: 'Values alignment',
});
return (
<EuiFormRow
display="columnCompressed"
label={
<span>
{label}{' '}
<EuiIconTip
color="subdued"
content={i18n.translate('xpack.lens.metric.toolbarVisOptions.valuesAlignmentTip', {
defaultMessage: 'Alignment of the Primary and Secondary Metric',
})}
iconProps={{
className: 'eui-alignTop',
}}
position="top"
size="s"
type="questionInCircle"
/>
</span>
}
>
<EuiButtonGroup
isFullWidth
legend={label}
data-test-subj="lens-values-alignment-btn"
buttonSize="compressed"
options={alignmentOptions}
idSelected={value}
onChange={(alignment) => {
onChange(alignment as MetricStyle['valuesTextAlign']);
}}
/>
</EuiFormRow>
);
}
const iconAlignmentOptions: Array<{
id: MetricStyle['titlesTextAlign'] | MetricStyle['valuesTextAlign'];
label: string;
}> = [
{
id: 'left',
label: i18n.translate('xpack.lens.shared.left', {
defaultMessage: 'Left',
}),
},
{
id: 'right',
label: i18n.translate('xpack.lens.shared.right', {
defaultMessage: 'Right',
}),
},
];
function IconAlignmentOption({
value,
onChange,
}: {
value: MetricStyle['iconAlign'];
onChange: (alignment: MetricStyle['iconAlign']) => void;
}) {
const label = i18n.translate('xpack.lens.metric.toolbarVisOptions.iconAlignment', {
defaultMessage: 'Icon alignment',
});
return (
<EuiFormRow display="columnCompressed" label={label}>
<EuiButtonGroup
isFullWidth
legend={label}
data-test-subj="lens-icon-alignment-btn"
buttonSize="compressed"
options={iconAlignmentOptions}
idSelected={value}
onChange={(alignment) => {
onChange(alignment as MetricStyle['iconAlign']);
}}
/>
</EuiFormRow>
);
}

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import type { LayoutDirection } from '@elastic/charts';
import type { LayoutDirection, MetricStyle } from '@elastic/charts';
import type { PaletteOutput, CustomPaletteParams } from '@kbn/coloring';
import type { CollapseFunction } from '@kbn/visualizations-plugin/common';
import type { LayerType } from '../../../common/types';
export type ValueFontMode = Exclude<MetricStyle['valueFontSize'], number>;
export interface MetricVisualizationState {
layerId: string;
layerType: LayerType;
@ -24,7 +26,12 @@ export interface MetricVisualizationState {
secondaryPrefix?: string;
progressDirection?: LayoutDirection;
showBar?: boolean;
titlesTextAlign?: MetricStyle['titlesTextAlign'];
valuesTextAlign?: MetricStyle['valuesTextAlign'];
iconAlign?: MetricStyle['iconAlign'];
valueFontMode?: ValueFontMode;
color?: string;
icon?: string;
palette?: PaletteOutput<CustomPaletteParams>;
maxCols?: number;

View file

@ -19,10 +19,11 @@ import {
Visualization,
} from '../../types';
import { GROUP_ID } from './constants';
import { getMetricVisualization, MetricVisualizationState } from './visualization';
import { getMetricVisualization } from './visualization';
import { themeServiceMock } from '@kbn/core/public/mocks';
import { Ast } from '@kbn/interpreter';
import { LayoutDirection } from '@elastic/charts';
import { MetricVisualizationState } from './types';
const paletteService = chartPluginMock.createPaletteRegistry();
const theme = themeServiceMock.createStartContract();
@ -76,6 +77,10 @@ describe('metric visualization', () => {
color: 'static-color',
palette,
showBar: false,
titlesTextAlign: 'left',
valuesTextAlign: 'right',
iconAlign: 'left',
valueFontMode: 'default',
};
const fullStateWTrend: Required<MetricVisualizationState> = {
@ -316,6 +321,9 @@ describe('metric visualization', () => {
"icon": Array [
"empty",
],
"iconAlign": Array [
"left",
],
"inspectorTableId": Array [
"first",
],
@ -353,7 +361,16 @@ describe('metric visualization', () => {
"subtitle": Array [
"subtitle",
],
"titlesTextAlign": Array [
"left",
],
"trendline": Array [],
"valueFontSize": Array [
"default",
],
"valuesTextAlign": Array [
"right",
],
},
"function": "metricVis",
"type": "function",
@ -380,6 +397,9 @@ describe('metric visualization', () => {
"icon": Array [
"empty",
],
"iconAlign": Array [
"left",
],
"inspectorTableId": Array [
"first",
],
@ -420,7 +440,16 @@ describe('metric visualization', () => {
"subtitle": Array [
"subtitle",
],
"titlesTextAlign": Array [
"left",
],
"trendline": Array [],
"valueFontSize": Array [
"default",
],
"valuesTextAlign": Array [
"right",
],
},
"function": "metricVis",
"type": "function",
@ -778,8 +807,12 @@ describe('metric visualization', () => {
expect(visualization.clearLayer(fullState, 'some-id', 'indexPattern1')).toMatchInlineSnapshot(`
Object {
"icon": "empty",
"iconAlign": "left",
"layerId": "first",
"layerType": "data",
"titlesTextAlign": "left",
"valueFontMode": "default",
"valuesTextAlign": "right",
}
`);
});

View file

@ -7,16 +7,13 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { PaletteOutput, PaletteRegistry, CustomPaletteParams } from '@kbn/coloring';
import { PaletteRegistry } from '@kbn/coloring';
import { ThemeServiceStart } from '@kbn/core/public';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import { LayoutDirection } from '@elastic/charts';
import { euiLightVars, euiThemeVars } from '@kbn/ui-theme';
import { IconChartMetric } from '@kbn/chart-icons';
import { AccessorConfig } from '@kbn/visualization-ui-components';
import { isNumericFieldForDatatable } from '../../../common/expressions/datatable/utils';
import { CollapseFunction } from '../../../common/expressions';
import type { LayerType } from '../../../common/types';
import { layerTypes } from '../../../common/layer_types';
import type { FormBasedPersistedState } from '../../datasources/form_based/types';
import { getSuggestions } from './suggestions';
@ -35,6 +32,7 @@ import { generateId } from '../../id_generator';
import { toExpression } from './to_expression';
import { nonNullable } from '../../utils';
import { METRIC_NUMERIC_MAX } from '../../user_messages_ids';
import { MetricVisualizationState } from './types';
export const DEFAULT_MAX_COLUMNS = 3;
@ -49,33 +47,6 @@ export const getDefaultColor = (state: MetricVisualizationState, isMetricNumeric
: euiThemeVars.euiColorEmptyShade;
};
export interface MetricVisualizationState {
layerId: string;
layerType: LayerType;
metricAccessor?: string;
secondaryMetricAccessor?: string;
maxAccessor?: string;
breakdownByAccessor?: string;
// the dimensions can optionally be single numbers
// computed by collapsing all rows
collapseFn?: CollapseFunction;
subtitle?: string;
secondaryPrefix?: string;
progressDirection?: LayoutDirection;
showBar?: boolean;
color?: string;
icon?: string;
palette?: PaletteOutput<CustomPaletteParams>;
maxCols?: number;
trendlineLayerId?: string;
trendlineLayerType?: LayerType;
trendlineTimeAccessor?: string;
trendlineMetricAccessor?: string;
trendlineSecondaryMetricAccessor?: string;
trendlineBreakdownByAccessor?: string;
}
export const supportedDataTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
const isSupportedMetric = (op: OperationMetadata) =>

View file

@ -79,7 +79,7 @@ export const VisualOptionsPopover: React.FC<VisualOptionsPopoverProps> = ({
return (
<TooltipWrapper tooltipContent={valueLabelsDisabledReason} condition={isDisabled}>
<ToolbarPopover
title={i18n.translate('xpack.lens.shared.curveLabel', {
title={i18n.translate('xpack.lens.shared.visualOptionsLabel', {
defaultMessage: 'Visual options',
})}
type="visualOptions"

View file

@ -23425,7 +23425,7 @@
"xpack.lens.shared.axisNameLabel": "Titre de l'axe",
"xpack.lens.shared.chartValueLabelVisibilityLabel": "Étiquettes",
"xpack.lens.shared.chartValueLabelVisibilityTooltip": "Si l'espace est insuffisant, les étiquettes de valeurs pourront être masquées",
"xpack.lens.shared.curveLabel": "Options visuelles",
"xpack.lens.shared.visualOptionsLabel": "Options visuelles",
"xpack.lens.shared.legendAlignmentLabel": "Alignement",
"xpack.lens.shared.legendInsideColumnsLabel": "Nombre de colonnes",
"xpack.lens.shared.legendInsideLocationAlignmentLabel": "Alignement",

View file

@ -23406,7 +23406,7 @@
"xpack.lens.shared.axisNameLabel": "軸のタイトル",
"xpack.lens.shared.chartValueLabelVisibilityLabel": "ラベル",
"xpack.lens.shared.chartValueLabelVisibilityTooltip": "十分なスペースがない場合、値ラベルが非表示になることがあります。",
"xpack.lens.shared.curveLabel": "視覚オプション",
"xpack.lens.shared.visualOptionsLabel": "視覚オプション",
"xpack.lens.shared.legendAlignmentLabel": "アラインメント",
"xpack.lens.shared.legendInsideColumnsLabel": "列の数",
"xpack.lens.shared.legendInsideLocationAlignmentLabel": "アラインメント",

View file

@ -23439,7 +23439,7 @@
"xpack.lens.shared.axisNameLabel": "轴标题",
"xpack.lens.shared.chartValueLabelVisibilityLabel": "标签",
"xpack.lens.shared.chartValueLabelVisibilityTooltip": "如果没有足够的空间,可能会隐藏值标签",
"xpack.lens.shared.curveLabel": "视觉选项",
"xpack.lens.shared.visualOptionsLabel": "视觉选项",
"xpack.lens.shared.legendAlignmentLabel": "对齐方式",
"xpack.lens.shared.legendInsideColumnsLabel": "列数目",
"xpack.lens.shared.legendInsideLocationAlignmentLabel": "对齐方式",